diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e3b7ef7f..c731eb3dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,7 @@ jobs: - run: find examples/php -name '*.php' -print0 | xargs -0 -n1 php -l - run: php -d zend.assertions=1 examples/php/scaffolding/assertions.php - run: php -l examples/laravel/app/Demo.php + - run: find examples/symfony/src examples/symfony/config -name '*.php' -exec php -l {} \; benchmark: name: Benchmark diff --git a/README.md b/README.md index 0aa46dc5f..789ab4f3c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ PHPantom focuses on deep type intelligence. Here's how it compares: | Closure parameter inference | βœ… | 🚧 | 🚧 | 🚧 | 🚧 | | Laravel | βœ… | ❌ | 🚧 | ❌ | 🚧 | | Blade templates | 🚧 | ❌ | βœ… | ❌ | 🚧 | +| Symfony / Twig / Doctrine | βœ… | 🚧 | 🚧 | 🚧 | 🧩 | | Other frameworks4 | 🚧 | 🚧 | 🚧 | 🚧 | 🧩 | | **Refactoring** | | | | | | | Rename | βœ… | πŸ”’ | πŸ”’ | βœ… | βœ… | @@ -60,14 +61,14 @@ PHPantom focuses on deep type intelligence. Here's how it compares: 1 Completion, hover, signature help, go-to-definition, find references, diagnostics, document symbols.
2 Auto-import, go-to implementation / type-definition, smart select, folding ranges, formatting, code lens, inlay hints, type hierarchy, document links.
3 Implement interface methods, extract method/function, extract/inline variable, generate constructor, generate getter/setter.
-4 CakePHP, non-Composer WordPress, Symfony, Behat, PHPUnit, and Prophecy, and Twig.
+4 CakePHP, non-Composer WordPress, Behat, PHPUnit, and Prophecy.
5 Convert between arrow functions and closures, and switch statements to match expressions.
Performance measured on a production codebase: 21K PHP files, 1.5M lines of code (vendor + application). Time to ready is CPU time consumed until full type intelligence is available on a cold start (first index); tools with a disk cache launch faster on subsequent starts.

> [!TIP] -> **Want to verify?** Open [`examples/php/`](examples/php/) in your editor and trigger completion at the marked locations in `completion.php`. It exercises every type intelligence feature in the table, including edge cases where tools diverge. For Laravel specifically, open [`examples/laravel/`](examples/laravel/) β€” a standalone project with real Eloquent models, config, routes, views, and translations that exercises Eloquent property resolution, query builder chaining, scopes, custom collections, and go-to-definition for config keys, route names, and translation strings. +> **Want to verify?** Open [`examples/php/`](examples/php/) in your editor and trigger completion at the marked locations in `completion.php`. It exercises every type intelligence feature in the table, including edge cases where tools diverge. Framework playgrounds live in [`examples/laravel/`](examples/laravel/) and [`examples/symfony/`](examples/symfony/). ## Context-Aware Intelligence @@ -77,6 +78,7 @@ Performance measured on a production codebase: 21K PHP files, 1.5M lines of code - **Conditional return types.** PHPStan-style conditional `@return` types resolve to the concrete branch at each call site. - **Type aliases and shapes.** `@phpstan-type`, `@phpstan-import-type`, and `object{...}` shapes all resolve through to completions. - **Laravel.** Eloquent relationships, scopes, accessors, casts, and Builder chains resolve end-to-end. Macros behave like real methods. Container strings like `app('cache')` resolve to the bound class, `auth()->user()` resolves to your configured model, authorization strings resolve to the gate definition or policy method that declares them, and query string compleation on both relation and column names. Blade templates get completion, hover, go-to-definition, and diagnostics through virtual PHP preprocessing. No ide-helper or database access required. +- **Symfony.** Container configuration, routes, Twig templates, translations, events, Messenger, forms, validation mappings, Doctrine metadata, and local configuration schemas participate in completion, navigation, references, rename, diagnostics, and code lenses across PHP, YAML, XML, XLIFF, and Twig. - **Everything else you'd expect.** Generics, type narrowing, named arguments, destructuring, first-class callables, anonymous classes, `@deprecated` detection, and namespace segment drilling. ## Project Awareness diff --git a/config-schema.json b/config-schema.json index 0efd65239..f085a10ca 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": [] } } }, @@ -147,6 +172,267 @@ } } }, + "symfony": { + "type": "object", + "description": "Symfony runtime metadata recovered statically from compiled containers and configured PHP attributes.", + "properties": { + "container": { + "type": "object", + "description": "Controls static compiled-container discovery. Container PHP is read as text and is never executed.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Read Symfony compiled-container metadata.", + "default": true + }, + "environment": { + "type": "string", + "description": "Cache environment used by automatic var/cache/ discovery.", + "default": "dev" + }, + "paths": { + "type": "array", + "description": "Optional workspace-relative compiled-container files, directories, or glob patterns. The newest useful match wins.", + "items": { + "type": "string" + } + } + } + }, + "events": { + "type": "object", + "description": "Configures event-name matching and attribute adapters. Exact listener wiring comes from the compiled container.", + "properties": { + "ignored-prefixes": { + "type": "array", + "description": "Prefixes removed before event names are compared.", + "items": { + "type": "string" + } + }, + "ignored-suffixes": { + "type": "array", + "description": "Suffixes removed before event names are compared.", + "items": { + "type": "string" + } + }, + "publishers": { + "type": "array", + "description": "Rules that derive Symfony event publishers from PHP method attributes.", + "items": { + "type": "object", + "properties": { + "attribute": { + "type": "string", + "description": "Fully-qualified publisher attribute class." + }, + "name-argument": { + "type": "string", + "description": "Named argument containing an explicit event name." + }, + "name-position": { + "type": "integer", + "description": "Zero-based positional fallback for the explicit event-name argument.", + "minimum": 0 + }, + "dispatch-argument": { + "type": "string", + "description": "Named argument containing dispatch enum cases." + }, + "dispatch-position": { + "type": "integer", + "description": "Zero-based positional fallback for the dispatch argument.", + "minimum": 0 + }, + "default-dispatch": { + "type": "array", + "description": "Dispatch names used when the attribute omits its dispatch argument.", + "items": { + "type": "string" + } + }, + "dispatch-cases": { + "type": "object", + "description": "Map from PHP enum case name to the event-name dispatch segment.", + "additionalProperties": { + "type": "string" + } + }, + "name-template": { + "type": "string", + "description": "Derived event-name template. Supports {dispatch}, {class}, {class_snake}, {method}, {method_snake}, {method_suffix}, and {method_suffix_snake}." + }, + "explicit-name-template": { + "type": "string", + "description": "Template used for explicit names. Supports the same placeholders plus {name}.", + "default": "{name}" + }, + "default-methods": { + "type": "array", + "description": "Method names that omit the method suffix.", + "items": { + "type": "string" + } + }, + "skip": { + "type": "array", + "description": "Conditions that omit one derived dispatch when another attribute argument is set.", + "items": { + "type": "object", + "properties": { + "dispatch": { + "type": "string" + }, + "argument": { + "type": "string" + }, + "position": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "dispatch", + "argument" + ] + } + } + }, + "required": [ + "attribute", + "name-template" + ] + } + }, + "subscribers": { + "type": "array", + "description": "Optional rules that expose event names written in subscriber method attributes before or without a compiled container.", + "items": { + "type": "object", + "properties": { + "attribute": { + "type": "string", + "description": "Fully-qualified subscriber attribute class." + }, + "name-argument": { + "type": "string" + }, + "name-position": { + "type": "integer", + "minimum": 0 + }, + "transport-argument": { + "type": "string" + }, + "transport-position": { + "type": "integer", + "minimum": 0 + }, + "transport-cases": { + "type": "object", + "description": "Map from PHP enum case name to an event-name suffix.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "attribute" + ] + } + } + } + }, + "expression-language": { + "type": "object", + "description": "Maps configured PHP attribute and constructor arguments to Symfony ExpressionLanguage strings and their PHP type contracts.", + "properties": { + "attributes": { + "type": "array", + "description": "Method-attribute arguments that contain an expression string or an array of expression strings.", + "items": { + "type": "object", + "properties": { + "attribute": { + "type": "string", + "description": "Fully-qualified PHP method attribute class." + }, + "argument": { + "type": "string", + "description": "Named argument containing expressions." + }, + "position": { + "type": "integer", + "description": "Zero-based positional fallback for the expression argument.", + "minimum": 0 + }, + "method-parameters": { + "type": "boolean", + "description": "Bind expression roots to method parameters with the same name.", + "default": false + }, + "bindings": { + "type": "object", + "description": "Map expression roots to return, parameter:, or class: type sources.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "attribute" + ] + } + }, + "constructors": { + "type": "array", + "description": "Expression object constructors nested inside PHP method attributes.", + "items": { + "type": "object", + "properties": { + "class": { + "type": "string", + "description": "Fully-qualified expression object class." + }, + "argument": { + "type": "string", + "description": "Named constructor argument containing the expression." + }, + "position": { + "type": "integer", + "description": "Zero-based positional fallback for the constructor argument.", + "minimum": 0 + }, + "inside-attribute-prefixes": { + "type": "array", + "description": "Only match constructors nested in attributes whose FQN starts with one of these prefixes. An empty list matches any attribute.", + "items": { + "type": "string" + } + }, + "method-parameters": { + "type": "boolean", + "description": "Bind expression roots to method parameters with the same name.", + "default": false + }, + "bindings": { + "type": "object", + "description": "Map expression roots to return, parameter:, or class: type sources.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "class" + ] + } + } + } + } + } + }, "formatting": { "type": "object", "description": "Controls the formatting strategy. PHPantom ships a built-in formatter (PER-CS 2.0 style). Projects with php-cs-fixer or PHP_CodeSniffer in composer.json require-dev automatically use those tools instead. Explicit configuration here always takes priority.", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cc37501a..faf998237 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,10 +63,12 @@ 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) +β”œβ”€β”€ proxy_metadata.rs # Transparent proxy β†’ real-class relations for metadata consumers +β”œβ”€β”€ symfony/ # Compiled-container, event, and ExpressionLanguage adapters β”œβ”€β”€ 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) @@ -98,6 +100,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 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 @@ -117,7 +120,8 @@ src/ β”œβ”€β”€ fix.rs # `fix` CLI subcommand (automated code fixes) β”œβ”€β”€ self_update.rs # Binary self-update β”‚ -β”‚ # Blade & shared utilities +β”‚ # Framework resources & shared utilities +β”œβ”€β”€ framework.rs # Symfony/Doctrine PHP, YAML, XML, XLIFF, and Twig reference index β”œβ”€β”€ blade/ # Laravel Blade template support (directives, preprocessor, source map) └── util.rs, text_position.rs, text_scan.rs, atom.rs, call_args.rs, return_collection.rs, toposort.rs, ci_map.rs, process.rs, progress.rs @@ -141,6 +145,30 @@ diagnostics, hover, go-to-definition, and signature help, not just completion below). Do not build a second type-resolution path: extend the engine here so every consumer benefits. +### Framework Resource Index + +**Symfony and Doctrine resources use a lightweight parallel reference index.** +`framework.rs` scans PHP configurators and attributes alongside YAML, XML, +XLIFF, and Twig resources. It records classes, members, paths, named framework +symbols, form and validation properties, Messenger relationships, and local +`TreeBuilder` keys. Definition, references, rename, highlights, completion, +diagnostics, and code lenses consume that shared index without parsing +non-PHP resources as PHP. + +### Framework Runtime Metadata + +Framework-generated files feed small metadata adapters instead of creating a +second symbol resolver. `proxy_metadata.rs` is the shared proxy-to-real-class +relation. `symfony/container.rs` reads compiled containers as text and exposes +listener registrations and proxied service candidates; it never includes PHP. +`symfony/events.rs` combines that exact runtime wiring with configured +attribute rules, then serves go-to-definition, references, and code lenses. +`symfony/expressions.rs` maps configured attribute and constructor arguments to +method parameter, return, or fixed-class types, then delegates member chains to +the shared PHP type engine for navigation and diagnostics. +Metadata owners are canonicalized through the proxy relation before lookup, so +all consumers agree on the real class without rewriting normal PHP types. + ## External Crates PHPantom uses several crates from the [Mago](https://github.com/carthage-software/mago) @@ -877,12 +905,14 @@ 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. +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 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..e1f50c7bb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,8 +9,24 @@ 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. 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. +- **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. +- **Call Hierarchy.** PHP functions and methods expose standard incoming and outgoing call navigation by reusing the existing references and definition pipelines. Contributed by @sidux. +- **Symfony event publishers and listeners navigate in both directions.** PHPantom reads the final listener wiring from the generated container without executing it, while project-defined publisher attributes and event-name rules stay in `.phpantom.toml`. Event links and lenses follow transparent proxies back to the real class. Contributed by @sidux. +- **Configured Symfony ExpressionLanguage strings understand PHP members.** Declare the attribute or expression-object argument and map its variables to method parameters, the return type, or a fixed class. Ctrl+Click follows roots, properties, and method chains to PHP declarations, while missing members use the normal `unknown_member` warning. Package-specific names and contracts stay in `.phpantom.toml`. 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. +#### Symfony and Doctrine + +- **Symfony forms, validation, and configuration schemas.** Form field names and YAML/XML validation property mappings now complete or navigate against their data-class properties, validation constraint names navigate to constraint classes, and local `TreeBuilder` schemas provide YAML configuration completion, navigation, missing-key diagnostics, references, and code lenses. Contributed by @sidux. +- **Symfony events and Messenger intelligence.** Named events declared by listener attributes or service tags and Messenger buses declared in configuration now complete, navigate, find references, diagnose missing project-local names, and show declaration-side code lenses. Event listener methods and Messenger message-to-handler relationships link directly to their PHP declarations. Contributed by @sidux. +- **Symfony translation intelligence.** Translation keys declared in YAML, XLIFF, and PHP catalogues now complete and navigate from PHP translator calls, translatable messages, and Twig filters with domain-aware references, diagnostics, and declaration-side code lenses. Contributed by @sidux. +- **Symfony Twig template intelligence.** Template files now complete and navigate from controller rendering, templated emails, and Twig inheritance or inclusion expressions, with cross-file references and declaration-side code lenses. Missing project templates produce diagnostics with a create-template quick fix. Contributed by @sidux. +- **Symfony route intelligence.** Route names and path parameters declared with attributes or in YAML, XML, and PHP now complete, navigate, find references, rename, highlight, and show declaration-side code lenses across controllers and Twig templates. Project-local missing route names produce diagnostics. Contributed by @sidux. +- **Symfony service container intelligence.** Service IDs and parameters declared in YAML, XML, and PHP configuration now complete, navigate, find references, rename, highlight, and show declaration-side code lenses across configuration and PHP usage sites. Project-local missing IDs and parameters produce diagnostics. Contributed by @sidux. +- **Symfony and Doctrine configuration navigation.** Service declarations, route controllers, and Doctrine mappings in YAML, XML, and Symfony PHP configurators now participate in go-to-definition, find references, rename, document highlights, and PHP code lenses. Namespace and resource-path refactors also update matching framework configuration. Contributed by @sidux. + ### Changed ### Fixed @@ -53,6 +69,11 @@ 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.** 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. +- **Workspace diagnostics stay bounded when nullable array shapes merge repeatedly.** Equivalent spellings such as `array{...}|null` and `?array{...}` now normalize to the same type instead of multiplying candidates across branches. 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. @@ -234,6 +255,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. @@ -564,6 +587,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Docblock navigation lands on the right name in types that mix a `*` wildcard with a non-ASCII name.** In a type such as `@return Map`, go-to-definition, find references, and rename measured every name after the accented one against the wrong bytes, so clicking `User` resolved nothing (or the wrong symbol). The PHPStan `*` wildcard is now read directly by the type grammar rather than rewritten to `mixed` beforehand, which removes the byte-offset bookkeeping that was corrupting the positions. - **Formatting a short method chain starting with `new X(...)` no longer breaks it across lines unnecessarily.** When the constructor call's own arguments were long enough to wrap, the formatter also forced a short trailing chain like `(new Foo(...))->bar()` onto separate lines even though it would have fit on one. Upstream fix from mago 1.44.0. - **`@method` and `@property` tags on an implemented interface are now always applied.** A class that declared no docblock of its own missed the magic methods and properties its interfaces declared, so they did not complete, hover, or resolve, and calls to them were reported as unknown members. Tags on an interface (and on the interfaces it extends) are now picked up regardless of what the implementing class documents. +- **Symfony PHP resource scanning handles non-ASCII source safely.** Indexing framework references no longer crashes when a multibyte character falls near the bounded call-context scan window. Contributed by @sidux. - **Find References, Rename, and Go to Implementation no longer look stalled during startup indexing.** A search started while the background index is still parsing the workspace waits for that index to finish, since acting on a partial index would silently miss results. That wait now shows in the request's own progress bar as "Waiting for workspace index" alongside the index's live file counts, instead of sitting at "Resolving…" with no indication of what it is waiting for. - **Type narrowing against `@phpstan-assert`/`@psalm-assert` no longer leaks memory.** Evaluating a narrowing call such as `Assert::isInstanceOf($x, Foo::class)` or a custom function/method with the same annotations allocated a small amount of memory that was never freed. This ran on every conditional touched during completion, hover, diagnostics, and go-to-definition, so memory held by a long-running editor session grew slowly but permanently the more the project was edited. Fixed by no longer leaking the allocation. - **Renaming a namespace no longer corrupts group `use` statements.** Renaming a namespace segment that is imported with a group `use` (e.g. `use App\Old\{Foo, Bar};`) previously rewrote the group's shared prefix and then also spliced the new prefix into each member name, producing invalid PHP like `use App\New\{App\New\Foo, Bar};`. The member names are left untouched now, since the prefix rewrite alone already updates the whole statement correctly. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e8a070cab..1aaaccd56 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -10,7 +10,7 @@ Thanks for your interest in contributing! ## Before Submitting a PR -All six CI checks must pass with zero warnings and zero failures: +All CI checks must pass with zero warnings and zero failures: ```bash cargo test @@ -19,10 +19,11 @@ cargo fmt --check find examples/php -name '*.php' -print0 | xargs -0 -n1 php -l php -d zend.assertions=1 examples/php/scaffolding/assertions.php php -l examples/laravel/app/Demo.php +find examples/symfony/src examples/symfony/config -name '*.php' -exec php -l {} \; phpantom_lsp analyze --project-root examples/laravel --no-colour ``` -Note that clippy runs twice, once for library code and once including test code. The `php -l` checks ensure the `examples/php/` playground remains valid PHP. The `php -d zend.assertions=1` run executes `assertions.php`'s `runDemoAssertions()` to verify that `scaffolding/scaffolding.php`'s stubs actually return what their docblocks claim. The final `php -l` and `phpantom_lsp analyze` runs check `examples/laravel/` for syntax errors and diagnostic regressions. `app/Demo.php` carries three deliberate mistakes: `Artisan::call('does:not-exist')` demonstrates `invalid_laravel_command`, and one `view('welcome', …)` call both leaves out a variable the template declares and passes a misspelled key, demonstrating `missing_view_variable` and `unused_view_variable`. So the analyze run must report exactly `[ERROR] Found 3 errors` on those two lines, not `[OK] No errors`; any other count, or an error on a different line, is a regression. +Note that clippy runs twice, once for library code and once including test code. The `php -l` checks keep the PHP and framework playgrounds valid. The `php -d zend.assertions=1` run executes `assertions.php`'s `runDemoAssertions()` to verify that `scaffolding/scaffolding.php`'s stubs actually return what their docblocks claim. The final `php -l` and `phpantom_lsp analyze` runs check `examples/laravel/` for syntax errors and diagnostic regressions. `app/Demo.php` carries three deliberate mistakes: `Artisan::call('does:not-exist')` demonstrates `invalid_laravel_command`, and one `view('welcome', …)` call both leaves out a variable the template declares and passes a misspelled key, demonstrating `missing_view_variable` and `unused_view_variable`. So the analyze run must report exactly `[ERROR] Found 3 errors` on those two lines, not `[OK] No errors`; any other count, or an error on a different line, is a regression. ## Code Style @@ -36,7 +37,7 @@ Note that clippy runs twice, once for library code and once including test code. - Use `create_test_backend()` from `tests/common/mod.rs` for same-file tests - Use `create_psr4_workspace()` for cross-file / PSR-4 tests - Test the happy path, edge cases, and interactions with existing features -- When adding a feature, update `examples/php/demo.php` with working examples (and verify with `php -l examples/php/demo.php`). For Laravel-specific features, also update `examples/laravel/app/Demo.php` (and verify with `php -l examples/laravel/app/Demo.php`). +- When adding a feature, update `examples/php/demo.php` with working examples (and verify with `php -l examples/php/demo.php`). Put framework-specific examples in the matching `examples//` project and lint its PHP files. See [BUILDING.md](BUILDING.md) for more on running tests and manual LSP testing. diff --git a/docs/configuration.md b/docs/configuration.md index 3eac6b620..89e8ab501 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,6 +52,122 @@ 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. + +### `[symfony]` + +PHPantom can read Symfony's generated dependency-injection container as text to +recover final event-listener wiring. It never includes or executes the +container PHP. By default it checks `var/cache/dev`, tries the newest generated +container first, and skips stale wrapper files that contain no useful wiring. + +#### `[symfony.container]` + +| Key | Type | Default | Description | +| ------------- | -------- | ------- | ----------- | +| `enabled` | bool | `true` | Read compiled-container metadata. | +| `environment` | string | `"dev"` | Cache environment used by automatic discovery. | +| `paths` | string[] | unset | Optional workspace-relative files, directories, or globs. The newest useful match wins. | + +#### `[symfony.events]` + +The compiled container supplies exact `addListener()` registrations. Publisher +attributes and their naming convention are package choices, so they are +declarative rules. This example models an attribute package without adding that +package to PHPantom: + +```toml +[symfony.container] +environment = "dev" + +[symfony.events] +ignored-prefixes = ["use_case."] +ignored-suffixes = [".async"] + +[[symfony.events.publishers]] +attribute = 'Acme\Event\Publish' +name-argument = "name" +name-position = 2 +dispatch-argument = "dispatch" +dispatch-position = 4 +default-dispatch = ["post"] +dispatch-cases = { PRE = "pre", POST = "post", EXCEPTION = "exception" } +name-template = "{dispatch}.{class_snake}{method_suffix_snake}" +explicit-name-template = "{name}" +default-methods = ["execute", "__invoke"] + +[[symfony.events.publishers.skip]] +dispatch = "post" +argument = "messageClass" +position = 5 + +[[symfony.events.subscribers]] +attribute = 'Acme\Event\Listen' +name-argument = "name" +name-position = 0 +transport-argument = "transport" +transport-position = 2 +transport-cases = { ASYNC = ".async" } +``` + +Argument positions are zero-based fallbacks for positional PHP attribute +arguments. Named arguments win. Publisher templates support `{dispatch}`, +`{class}`, `{class_snake}`, `{method}`, `{method_snake}`, `{method_suffix}`, +`{method_suffix_snake}`, and `{name}`. A `skip` rule omits one derived dispatch +when another argument is set, such as an event sent to Messenger instead of +Symfony's event dispatcher. + +The result is bidirectional go-to-definition, references, and `Symfony event` +code lenses between publisher and listener methods. Listener classes that are +configured transparent proxies use the shared `[[php.proxies]]` relation, so +the links land on the real class. + +#### `[symfony.expression-language]` + +Declare which attribute or constructor arguments contain ExpressionLanguage +strings. PHPantom then uses the normal PHP type engine for member navigation and +`unknown_member` diagnostics. + +```toml +[[symfony.expression-language.attributes]] +attribute = 'Acme\Expression\Attribute\Rule' +argument = "tags" +position = 3 +method-parameters = true + +[[symfony.expression-language.constructors]] +class = 'Symfony\Component\ExpressionLanguage\Expression' +position = 0 +inside-attribute-prefixes = [ + 'Acme\Expression\Attribute\', + 'Vendor\Policy\Attribute\', +] +bindings = { request = "parameter:0", response = "return" } +``` + +An attribute rule accepts one string or an array of strings. Named arguments +win over the zero-based positional fallback. `method-parameters = true` maps +each expression root to a same-named method parameter. Explicit `bindings` can +map a root to `parameter:0`, `parameter:name`, `return`, or `class:FQN`. +Constructor prefixes keep a shared expression class scoped to attributes that +use the declared variable contract; an empty prefix list matches any attribute. + ### `[diagnostics]` | Key | Type | Default | Description | diff --git a/docs/index.md b/docs/index.md index d83d15045..fdc1cdc99 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ You may want to jump to: - **Deep type intelligence.** Generics, conditional return types, closure parameter inference, array shapes, PHPStan types. - **Laravel support.** Eloquent relationships, scopes, accessors, casts, Builder chains, macros, Blade templates -- no ide-helper or database access required. +- **Symfony support.** Navigate and refactor container services, routes, Twig templates, translations, events, Messenger handlers, forms, validation mappings, Doctrine metadata, and configuration schemas across PHP and resource files. - **Fast.** 5 seconds to ready on a 21K-file codebase. 360 MB RAM. No disk cache. - **PHPStan, PHPCS, and Mago integration.** Run external tools on save and surface their diagnostics in the editor. - **CLI tools.** Batch diagnostics (`analyze`) and automated fixes (`fix`) for CI and bulk cleanup. diff --git a/docs/todo.md b/docs/todo.md index 52b13c904..18cde2b44 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -143,7 +143,6 @@ unlikely to move the needle for most users. | F12 | [IntelliJ / PHPStorm plugin](todo/lsp-features.md#f12-intellij-phpstorm-plugin) | High | Medium-High | | F13 | [Homebrew formula](todo/lsp-features.md#f13-homebrew-formula) | Medium | Low | | F17 | [Wire class move to `workspace/willRenameFiles`](todo/lsp-features.md#f17-wire-class-move-to-workspacewillrenamefiles) | Medium | Medium | -| F5 | [Call hierarchy](todo/lsp-features.md#f5-call-hierarchy) (incoming/outgoing calls) | Medium | Medium | | F2 | [Partial result streaming via `$/progress`](todo/lsp-features.md#f2-partial-result-streaming-via-progress) | Medium | Medium-High | | F7 | [Evaluatable expression support (DAP integration)](todo/lsp-features.md#f7-evaluatable-expression-support-dap-integration) | Low-Medium | Low | | F15 | [Go-to-declaration](todo/lsp-features.md#f15-go-to-declaration) | Low-Medium | Low | diff --git a/docs/todo/lsp-features.md b/docs/todo/lsp-features.md index 323f78aec..3244ca5cb 100644 --- a/docs/todo/lsp-features.md +++ b/docs/todo/lsp-features.md @@ -89,55 +89,6 @@ developer arrive before vendor matches, even within a single phase. 4. If no token was provided, fall back to the current behaviour: collect everything, return once. ---- - -## F5. Call hierarchy - -**Impact: Medium Β· Complexity: Medium** - -Implement `callHierarchy/incomingCalls` and -`callHierarchy/outgoingCalls` to answer "who calls this function?" and -"what does this function call?" - -### Incoming calls (who calls this) - -Given a function or method, find all call sites across the project. -This is conceptually similar to Find References but filtered to call -expressions and structured as a tree (each caller is itself a callable -with a location). - -The existing Find References infrastructure -(`find_references_in_file`, cross-file scanning) provides the core -search. The call hierarchy handler wraps the results into -`CallHierarchyIncomingCall` items, grouping by containing function. - -### Outgoing calls (what does this call) - -Given a function or method, walk its AST body and collect all call -expressions (function calls, method calls, static calls, `new` -expressions). Resolve each callee to its declaration location. - -This is a single-file AST walk with cross-file resolution for each -callee, similar to what go-to-definition already does. - -### Prepare - -`callHierarchy/prepare` returns a `CallHierarchyItem` for the symbol -at the cursor. This is straightforward: resolve the symbol, return its -name, kind, URI, range, and selection range. - -### Dependencies - -Call hierarchy benefits significantly from a full project index. -Without an index, incoming calls can only be found via the existing -classmap + PSR-4 scan approach (same as Find References). Now that -full background indexing is available, the lookup can become a -simple index query instead of relying on the scan-based approach that -Find References uses on its own. - -**References:** -- Phpactor: call hierarchy via its references index. - ## F7. Evaluatable expression support (DAP integration) **Impact: Low-Medium Β· Complexity: Low** diff --git a/examples/symfony/.gitignore b/examples/symfony/.gitignore new file mode 100644 index 000000000..cac762f1d --- /dev/null +++ b/examples/symfony/.gitignore @@ -0,0 +1,2 @@ +/vendor/ +/.idea/ diff --git a/examples/symfony/README.md b/examples/symfony/README.md new file mode 100644 index 000000000..1dadc1c7f --- /dev/null +++ b/examples/symfony/README.md @@ -0,0 +1,29 @@ +# Symfony Demo Project for PHPantom LSP + +A standalone editor playground for PHPantom's Symfony and Doctrine +intelligence. It intentionally uses attributes, YAML, XML, PHP configurators, +Twig, and translation catalogues together. + +## What to try + +- Open `src/Controller/DemoController.php` and use completion, + go-to-definition, find references, rename, and diagnostics on route names, + route parameters, service IDs, parameters, templates, translations, and + event names. +- Open `src/Entity/User.php` to see code lenses for Doctrine mappings, form + fields, and validation properties. +- Open `src/Message/SendWelcomeEmail.php` or its handler to navigate the + Messenger relationship in either direction. +- Open `config/services.php` to navigate between a PHP service declaration and + its class. YAML and XML references work the same way. +- Open `config/packages/acme_demo.yaml` to complete and navigate keys from the + local `TreeBuilder` schema. + +## Getting started + +1. Optionally run `composer install` here to install the real Symfony classes. +2. Open this directory as a project or workspace folder in your editor. +3. Trigger completion or go-to-definition inside the example strings. + +The files are a language-server playground, not a bootable Symfony +application, so no kernel or database is required. diff --git a/examples/symfony/composer.json b/examples/symfony/composer.json new file mode 100644 index 000000000..09dbe8de3 --- /dev/null +++ b/examples/symfony/composer.json @@ -0,0 +1,26 @@ +{ + "name": "phpantom/symfony-demo", + "description": "Symfony intelligence playground for PHPantom", + "license": "MIT", + "type": "project", + "require": { + "php": ">=8.2", + "doctrine/doctrine-bundle": "^2.10", + "symfony/form": "^7.0", + "symfony/framework-bundle": "^7.0", + "symfony/messenger": "^7.0", + "symfony/twig-bundle": "^7.0", + "symfony/validator": "^7.0" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "config": { + "platform": { + "php": "8.2.0" + }, + "sort-packages": true + } +} diff --git a/examples/symfony/composer.lock b/examples/symfony/composer.lock new file mode 100644 index 000000000..90f3e6cf8 --- /dev/null +++ b/examples/symfony/composer.lock @@ -0,0 +1,4250 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c22dc3058b038d5e4288a91507f9d71f", + "packages": [ + { + "name": "doctrine/dbal", + "version": "4.4.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.4.4" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-07-21T14:34:40+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/doctrine-bundle", + "version": "2.19.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "07b90f707b82981097731c419f546e7ba97fba3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/07b90f707b82981097731c419f546e7ba97fba3c", + "reference": "07b90f707b82981097731c419f546e7ba97fba3c", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/deprecations": "^1.0", + "doctrine/persistence": "^3.1 || ^4", + "doctrine/sql-formatter": "^1.0.1", + "php": "^8.1", + "symfony/cache": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/service-contracts": "^2.5 || ^3" + }, + "conflict": { + "doctrine/annotations": ">=3.0", + "doctrine/cache": "< 1.11", + "doctrine/orm": "<2.17 || >=4.0", + "symfony/var-exporter": "< 6.4.1 || 7.0.0", + "twig/twig": "<2.13 || >=3.0 <3.0.4 || >=5" + }, + "require-dev": { + "doctrine/annotations": "^1 || ^2", + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.17 || ^3.1", + "friendsofphp/proxy-manager-lts": "^1.0", + "phpstan/phpstan": "2.1.1", + "phpstan/phpstan-phpunit": "2.0.3", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.53 || ^12.3.10", + "psr/log": "^1.1.4 || ^2.0 || ^3.0", + "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/runtime": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/stopwatch": "^6.4 || ^7.0", + "symfony/string": "^6.4 || ^7.0", + "symfony/twig-bridge": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/var-exporter": "^6.4.1 || ^7.0.1", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0", + "twig/twig": "^2.14.7 || ^3.0.4 || ^4" + }, + "suggest": { + "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", + "ext-pdo": "*", + "symfony/web-profiler-bundle": "To use the data collector." + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\DoctrineBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org/" + } + ], + "description": "Symfony DoctrineBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database", + "dbal", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineBundle/issues", + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-bundle", + "type": "tidelift" + } + ], + "time": "2026-07-23T14:52:05+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/persistence", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "doctrine/event-manager": "^1 || ^2", + "php": "^8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Persistence\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/4.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2026-04-26T12:12:52+00:00" + }, + { + "name": "doctrine/sql-formatter", + "version": "1.5.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/sql-formatter.git", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" + }, + "bin": [ + "bin/sql-formatter" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\SqlFormatter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "https://jeremydorn.com/" + } + ], + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/doctrine/sql-formatter/", + "keywords": [ + "highlight", + "sql" + ], + "support": { + "issues": "https://github.com/doctrine/sql-formatter/issues", + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" + }, + "time": "2026-02-08T16:21:46+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "c1e7abe8e8c9b315d6d8b86446ffd9cf73679303" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/c1e7abe8e8c9b315d6d8b86446ffd9cf73679303", + "reference": "c1e7abe8e8c9b315d6d8b86446ffd9cf73679303", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-29T04:03:42+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "b18e33881ef402ad940f36e85935420624009bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/b18e33881ef402ad940f36e85935420624009bf4", + "reference": "b18e33881ef402ad940f36e85935420624009bf4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T12:54:40+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "088ec6fe0ef6819cbc301174093b6bfa4ad26930" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/088ec6fe0ef6819cbc301174093b6bfa4ad26930", + "reference": "088ec6fe0ef6819cbc301174093b6bfa4ad26930", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T13:51:00+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "b7825671c553af46a98c744e23f37f972aee6427" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b7825671c553af46a98c744e23f37f972aee6427", + "reference": "b7825671c553af46a98c744e23f37f972aee6427", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T08:40:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/doctrine-bridge", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-bridge.git", + "reference": "76ebc0ca1680f766c57cf23d334784ef8e83a5e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/76ebc0ca1680f766c57cf23d334784ef8e83a5e7", + "reference": "76ebc0ca1680f766c57cf23d334784ef8e83a5e7", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^2", + "doctrine/persistence": "^3.1|^4", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/collections": "<1.8", + "doctrine/dbal": "<3.6", + "doctrine/lexer": "<1.1", + "doctrine/orm": "<2.15", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/form": "<6.4.6|>=7,<7.0.6", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/lock": "<6.4", + "symfony/messenger": "<6.4", + "symfony/property-info": "<6.4", + "symfony/security-bundle": "<6.4", + "symfony/security-core": "<6.4", + "symfony/validator": "<7.4" + }, + "require-dev": { + "doctrine/collections": "^1.8|^2.0", + "doctrine/data-fixtures": "^1.1|^2", + "doctrine/dbal": "^3.6|^4", + "doctrine/orm": "^2.15|^3", + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/doctrine-messenger": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.2|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Doctrine with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261", + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/336e7f3b9e95aba04f93ea9143920c2186abfbb9", + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/ff16a16bf87fdf264638b8f6995b3515975e3c79", + "reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T07:36:05+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "13b38720174286f55d1761152b575a8d1436fc25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:31:18+00:00" + }, + { + "name": "symfony/form", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/form.git", + "reference": "24687cfe07dbf29912a86e68cb25208805b70097" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/form/zipball/24687cfe07dbf29912a86e68cb25208805b70097", + "reference": "24687cfe07dbf29912a86e68cb25208805b70097", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/options-resolver": "^7.3|^8.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/error-handler": "<6.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<7.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4.12|^7.1.5|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Form\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows to easily create, process and reuse HTML forms", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/form/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T21:44:23+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "a430728797dda13ec60add8afd18e3fea50f5e93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a430728797dda13ec60add8afd18e3fea50f5e93", + "reference": "a430728797dda13ec60add8afd18e3fea50f5e93", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4.12|^7.0|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.15|~8.0.15|^8.1.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4.43|>=7.0,<7.4.15|>=8.0,<8.0.15", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<7.4", + "symfony/http-client": "<6.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<7.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4.43|^7.4.15|^8.0.15", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/json-streamer": "^7.3|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/notifier": "^6.4|^7.0|^8.0", + "symfony/object-mapper": "^7.3|^8.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/semaphore": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.2.5|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/webhook": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.3|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-26T12:33:49+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "1f898ee8188adda9417fb52cf8425a8342c254e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/1f898ee8188adda9417fb52cf8425a8342c254e7", + "reference": "1f898ee8188adda9417fb52cf8425a8342c254e7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-29T07:12:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "403275d94f94d5626c3288c599b3b48093ba24f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/403275d94f94d5626c3288c599b3b48093ba24f7", + "reference": "403275d94f94d5626c3288c599b3b48093ba24f7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-29T11:40:42+00:00" + }, + { + "name": "symfony/messenger", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "6338eed8f65c593469fdbdbe043d2a7552264403" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/6338eed8f65c593469fdbdbe043d2a7552264403", + "reference": "6338eed8f65c593469fdbdbe043d2a7552264403", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<7.3", + "symfony/lock": "<7.4", + "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T12:46:20+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "445c90e341fccda10311019cf82ff73bb7343945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/445c90e341fccda10311019cf82ff73bb7343945", + "reference": "445c90e341fccda10311019cf82ff73bb7343945", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T11:52:53+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/property-access", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "fce3f4d9cfeb4ddc674c357b5a4c3a23ccf408ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/fce3f4d9cfeb4ddc674c357b5a4c3a23ccf408ad", + "reference": "fce3f4d9cfeb4ddc674c357b5a4c3a23ccf408ad", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KΓ©vin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:09:44+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "c81843850c1c791b7f516db62cb81beaadd8ab22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/c81843850c1c791b7f516db62cb81beaadd8ab22", + "reference": "c81843850c1c791b7f516db62cb81beaadd8ab22", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.21|^4.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/console": "<6.4", + "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.4.37|>7,<7.4.9|>8.0,<8.0.9", + "symfony/serializer": "<6.4", + "symfony/translation": "<6.4", + "symfony/workflow": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/workflow": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:27:07+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "e3d2bea0b594aa50c3482a278a5cd09d83d94517" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/e3d2bea0b594aa50c3482a278a5cd09d83d94517", + "reference": "e3d2bea0b594aa50c3482a278a5cd09d83d94517", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.2", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/twig-bridge": "^7.3|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "conflict": { + "symfony/framework-bundle": "<6.4", + "symfony/translation": "<6.4" + }, + "require-dev": { + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-22T15:21:55+00:00" + }, + { + "name": "symfony/validator", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "a1a345b72800e05b4366c43e06fbc57754abdd3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/a1a345b72800e05b4366c43e06fbc57754abdd3e", + "reference": "a1a345b72800e05b4366c43e06fbc57754abdd3e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", + "symfony/expression-language": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<6.4", + "symfony/property-info": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/var-exporter": "<6.4.25|>=7.0,<7.3.3", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/type-info": "^7.1.8", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T21:44:23+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "0118811b1d59f323bf131250b3fb919febfece28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0118811b1d59f323bf131250b3fb919febfece28", + "reference": "0118811b1d59f323bf131250b3fb919febfece28", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:41:53+00:00" + }, + { + "name": "twig/twig", + "version": "v3.28.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-07-03T20:44:34+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/examples/symfony/config/doctrine/User.orm.xml b/examples/symfony/config/doctrine/User.orm.xml new file mode 100644 index 000000000..b24aed284 --- /dev/null +++ b/examples/symfony/config/doctrine/User.orm.xml @@ -0,0 +1,7 @@ + + + + diff --git a/examples/symfony/config/packages/acme_demo.yaml b/examples/symfony/config/packages/acme_demo.yaml new file mode 100644 index 000000000..711b932f8 --- /dev/null +++ b/examples/symfony/config/packages/acme_demo.yaml @@ -0,0 +1,4 @@ +acme_demo: + api_key: local-development-key + mailer: + dsn: smtp://localhost diff --git a/examples/symfony/config/packages/messenger.yaml b/examples/symfony/config/packages/messenger.yaml new file mode 100644 index 000000000..58774a174 --- /dev/null +++ b/examples/symfony/config/packages/messenger.yaml @@ -0,0 +1,6 @@ +framework: + messenger: + default_bus: command.bus + buses: + command.bus: ~ + query.bus: ~ diff --git a/examples/symfony/config/routes.yaml b/examples/symfony/config/routes.yaml new file mode 100644 index 000000000..d3a2ab6f6 --- /dev/null +++ b/examples/symfony/config/routes.yaml @@ -0,0 +1,3 @@ +app_demo_legacy: + path: /legacy + controller: App\Controller\DemoController::legacy diff --git a/examples/symfony/config/services.php b/examples/symfony/config/services.php new file mode 100644 index 000000000..d7d4913c5 --- /dev/null +++ b/examples/symfony/config/services.php @@ -0,0 +1,14 @@ +services(); + $parameters = $container->parameters(); + + $services->set('app.welcome_mailer', WelcomeMailer::class); + $services->alias('app.mailer', 'app.welcome_mailer'); + $parameters->set('app.sender_name', 'PHPantom'); +}; diff --git a/examples/symfony/config/services.yaml b/examples/symfony/config/services.yaml new file mode 100644 index 000000000..76903b276 --- /dev/null +++ b/examples/symfony/config/services.yaml @@ -0,0 +1,6 @@ +services: + App\: + resource: '../src/' + App\EventListener\OrderPlacedListener: + tags: + - { name: kernel.event_listener, event: app.order.placed, method: onOrderPlaced } diff --git a/examples/symfony/config/validator/User.yaml b/examples/symfony/config/validator/User.yaml new file mode 100644 index 000000000..3835c3c72 --- /dev/null +++ b/examples/symfony/config/validator/User.yaml @@ -0,0 +1,7 @@ +App\Entity\User: + properties: + email: + - NotBlank: ~ + displayName: + - Length: + min: 2 diff --git a/examples/symfony/src/Controller/DemoController.php b/examples/symfony/src/Controller/DemoController.php new file mode 100644 index 000000000..65b14bebf --- /dev/null +++ b/examples/symfony/src/Controller/DemoController.php @@ -0,0 +1,46 @@ +has('app.welcome_mailer'); + $this->generateUrl('app_demo', ['userId' => $userId]); + $title = $this->translator->trans('demo.title'); + $this->events->dispatch(new OrderPlaced($userId), 'app.order.placed'); + $this->commandBus->dispatch(new SendWelcomeEmail($userId)); + + return $this->render('demo/index.html.twig', [ + 'sender' => $this->senderName, + 'title' => $title, + ]); + } + + public function legacy(): Response + { + return new Response('Configured in routes.yaml'); + } +} diff --git a/examples/symfony/src/DependencyInjection/Configuration.php b/examples/symfony/src/DependencyInjection/Configuration.php new file mode 100644 index 000000000..890d0315b --- /dev/null +++ b/examples/symfony/src/DependencyInjection/Configuration.php @@ -0,0 +1,26 @@ +getRootNode(); + assert($rootNode instanceof ArrayNodeDefinition); + $rootNode + ->children() + ->scalarNode('api_key')->end() + ->arrayNode('mailer') + ->children() + ->scalarNode('dsn')->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/examples/symfony/src/Entity/User.php b/examples/symfony/src/Entity/User.php new file mode 100644 index 000000000..e50e3405d --- /dev/null +++ b/examples/symfony/src/Entity/User.php @@ -0,0 +1,21 @@ +email; + } + + public function displayName(): string + { + return $this->displayName; + } +} diff --git a/examples/symfony/src/Event/OrderPlaced.php b/examples/symfony/src/Event/OrderPlaced.php new file mode 100644 index 000000000..4e778c31a --- /dev/null +++ b/examples/symfony/src/Event/OrderPlaced.php @@ -0,0 +1,8 @@ +userId; + } +} diff --git a/examples/symfony/src/Form/RegistrationType.php b/examples/symfony/src/Form/RegistrationType.php new file mode 100644 index 000000000..bee6785ec --- /dev/null +++ b/examples/symfony/src/Form/RegistrationType.php @@ -0,0 +1,28 @@ + $options + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('email') + ->add('displayName'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => User::class]); + } +} diff --git a/examples/symfony/src/Message/SendWelcomeEmail.php b/examples/symfony/src/Message/SendWelcomeEmail.php new file mode 100644 index 000000000..17d5709da --- /dev/null +++ b/examples/symfony/src/Message/SendWelcomeEmail.php @@ -0,0 +1,8 @@ +mailer->send($message->userId); + } +} diff --git a/examples/symfony/src/Repository/UserRepository.php b/examples/symfony/src/Repository/UserRepository.php new file mode 100644 index 000000000..32339e0b3 --- /dev/null +++ b/examples/symfony/src/Repository/UserRepository.php @@ -0,0 +1,13 @@ + + */ +final class UserRepository extends ServiceEntityRepository +{ +} diff --git a/examples/symfony/src/Service/WelcomeMailer.php b/examples/symfony/src/Service/WelcomeMailer.php new file mode 100644 index 000000000..d0c73cc12 --- /dev/null +++ b/examples/symfony/src/Service/WelcomeMailer.php @@ -0,0 +1,11 @@ + + + {% block body %}{% endblock %} + diff --git a/examples/symfony/templates/demo/_card.html.twig b/examples/symfony/templates/demo/_card.html.twig new file mode 100644 index 000000000..d549e523b --- /dev/null +++ b/examples/symfony/templates/demo/_card.html.twig @@ -0,0 +1 @@ +
{{ 'demo.card'|trans }}
diff --git a/examples/symfony/templates/demo/index.html.twig b/examples/symfony/templates/demo/index.html.twig new file mode 100644 index 000000000..b05d03f39 --- /dev/null +++ b/examples/symfony/templates/demo/index.html.twig @@ -0,0 +1,7 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

{{ 'demo.title'|trans }}

+ {% include 'demo/_card.html.twig' %} + {{ sender }} +{% endblock %} diff --git a/examples/symfony/translations/messages.en.yaml b/examples/symfony/translations/messages.en.yaml new file mode 100644 index 000000000..c0ded029f --- /dev/null +++ b/examples/symfony/translations/messages.en.yaml @@ -0,0 +1,3 @@ +demo: + title: Symfony intelligence + card: Every string links back to its declaration diff --git a/examples/symfony/translations/validators.en.xlf b/examples/symfony/translations/validators.en.xlf new file mode 100644 index 000000000..47c66732f --- /dev/null +++ b/examples/symfony/translations/validators.en.xlf @@ -0,0 +1,11 @@ + + + + + + user.invalid_email + Please enter a valid email address. + + + + diff --git a/src/call_hierarchy.rs b/src/call_hierarchy.rs new file mode 100644 index 000000000..b26b5dd22 --- /dev/null +++ b/src/call_hierarchy.rs @@ -0,0 +1,409 @@ +//! PHP call hierarchy support built on the existing definition and reference +//! pipelines. +//! +//! The hierarchy stores only stable declaration coordinates in LSP item data. +//! Incoming calls reuse Find References; outgoing calls reuse Go to Definition +//! for call-like symbol spans inside the callable body. This keeps call +//! hierarchy aligned with every improvement made to the shared type engine. + +use std::collections::HashMap; + +use tower_lsp::lsp_types::{ + CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, Location, Position, + Range, SymbolKind as LspSymbolKind, Url, +}; + +use crate::Backend; +use crate::symbol_map::{SymbolKind, SymbolMap}; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::types::{ClassInfo, FunctionInfo, MethodInfo}; + +#[derive(Clone)] +struct PhpCallable { + item: CallHierarchyItem, + body: Option<(u32, u32)>, +} + +impl Backend { + pub(crate) fn prepare_call_hierarchy_impl( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option> { + let offset = position_to_offset(content, position); + if let Some(callable) = self.php_callable_at(uri, content, offset) { + return Some(vec![callable.item]); + } + + self.resolve_definition(uri, content, position) + .into_iter() + .find_map(|location| self.php_callable_at_location(&location)) + .map(|callable| vec![callable.item]) + } + + pub(crate) fn incoming_calls_impl( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let event_calls = self.symfony_event_incoming_calls(item); + let Some(target) = self.php_callable_from_item(item) else { + return event_calls; + }; + let content = self.get_file_content(target.item.uri.as_str())?; + let references = self + .find_references( + target.item.uri.as_str(), + &content, + target.item.selection_range.start, + false, + ) + .unwrap_or_default(); + + let mut grouped: HashMap)> = HashMap::new(); + for reference in references { + let Some(caller) = self.php_callable_at_location(&reference) else { + continue; + }; + let key = php_item_key(&caller.item); + grouped + .entry(key) + .and_modify(|(_, ranges)| push_unique_range(ranges, reference.range)) + .or_insert_with(|| (caller.item, vec![reference.range])); + } + + let mut calls: Vec<_> = grouped + .into_values() + .map(|(from, from_ranges)| CallHierarchyIncomingCall { from, from_ranges }) + .collect(); + calls.extend(event_calls.unwrap_or_default()); + calls.sort_by_key(|left| php_item_key(&left.from)); + calls.dedup_by(|left, right| { + left.from == right.from && left.from_ranges == right.from_ranges + }); + Some(calls) + } + + pub(crate) fn outgoing_calls_impl( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let event_calls = self.symfony_event_outgoing_calls(item); + let Some(callable) = self.php_callable_from_item(item) else { + return event_calls; + }; + let Some((body_start, body_end)) = callable.body else { + return event_calls.or_else(|| Some(Vec::new())); + }; + let uri = callable.item.uri.as_str(); + let content = self.get_file_content(uri)?; + let symbol_map = self.symbol_maps.read().get(uri).cloned()?; + + let mut grouped: HashMap)> = HashMap::new(); + for span in symbol_map.spans.iter().filter(|span| { + span.start >= body_start + && span.start <= body_end + && matches!( + span.kind, + SymbolKind::FunctionCall { + is_definition: false, + .. + } | SymbolKind::MemberAccess { + is_method_call: true, + .. + } + ) + }) { + let position = offset_to_position(&content, span.start as usize); + let from_range = Range::new(position, offset_to_position(&content, span.end as usize)); + for location in self.resolve_definition(uri, &content, position) { + let Some(callee) = self.php_callable_at_location(&location) else { + continue; + }; + let key = php_item_key(&callee.item); + grouped + .entry(key) + .and_modify(|(_, ranges)| push_unique_range(ranges, from_range)) + .or_insert_with(|| (callee.item, vec![from_range])); + } + } + + let mut calls: Vec<_> = grouped + .into_values() + .map(|(to, from_ranges)| CallHierarchyOutgoingCall { to, from_ranges }) + .collect(); + calls.extend(event_calls.unwrap_or_default()); + calls.sort_by_key(|left| php_item_key(&left.to)); + calls.dedup_by(|left, right| left.to == right.to && left.from_ranges == right.from_ranges); + Some(calls) + } + + fn php_callable_from_item(&self, item: &CallHierarchyItem) -> Option { + let data = item.data.as_ref()?; + if data.get("kind")?.as_str()? != "php" { + return None; + } + let offset = data.get("offset")?.as_u64()? as u32; + let content = self.get_file_content(item.uri.as_str())?; + self.php_callable_at(item.uri.as_str(), &content, offset) + } + + fn php_callable_at_location(&self, location: &Location) -> Option { + let uri = location.uri.as_str(); + let content = self.get_file_content(uri)?; + let offset = position_to_offset(&content, location.range.start); + self.php_callable_at(uri, &content, offset) + } + + pub(crate) fn call_hierarchy_item_at_location( + &self, + location: &Location, + ) -> Option { + self.php_callable_at_location(location) + .map(|callable| callable.item) + } + + fn php_callable_at(&self, uri: &str, content: &str, offset: u32) -> Option { + let symbol_map = self.symbol_maps.read().get(uri).cloned()?; + let classes = self + .symbols + .uri_classes_index + .read() + .get(uri) + .cloned() + .unwrap_or_default(); + + for class in &classes { + if let Some(callable) = method_callable_at(uri, content, &symbol_map, class, offset) { + return Some(callable); + } + } + + let function_names = self + .symbols + .uri_globals_index + .read() + .get(uri) + .map(|(functions, _)| functions.clone()) + .unwrap_or_default(); + let functions = self.symbols.global_functions.read(); + for fqn in function_names { + let Some((declaring_uri, function)) = functions.get(&fqn) else { + continue; + }; + if declaring_uri == uri + && let Some(callable) = + function_callable_at(uri, content, &symbol_map, &fqn, function, offset) + { + return Some(callable); + } + } + None + } +} + +fn method_callable_at( + uri: &str, + content: &str, + symbol_map: &SymbolMap, + class: &ClassInfo, + offset: u32, +) -> Option { + for (index, method) in class.methods.iter().enumerate() { + if method.is_virtual || method.name_offset == 0 { + continue; + } + let upper = class + .methods + .iter() + .skip(index + 1) + .filter(|next| next.name_offset > method.name_offset) + .map(|next| next.name_offset) + .min() + .unwrap_or(class.end_offset); + let body = declaration_body(symbol_map, method.name_offset, upper); + let name_end = method.name_offset.saturating_add(method.name.len() as u32); + let contains = (method.name_offset..=name_end).contains(&offset) + || body.is_some_and(|(start, end)| start <= offset && offset <= end); + if contains { + return build_method_callable(uri, content, class, method, body); + } + } + None +} + +fn function_callable_at( + uri: &str, + content: &str, + symbol_map: &SymbolMap, + fqn: &str, + function: &FunctionInfo, + offset: u32, +) -> Option { + if function.name_offset == 0 { + return None; + } + let body = declaration_body(symbol_map, function.name_offset, content.len() as u32); + let name_end = function + .name_offset + .saturating_add(function.name.len() as u32); + if !(function.name_offset..=name_end).contains(&offset) + && !body.is_some_and(|(start, end)| start <= offset && offset <= end) + { + return None; + } + build_function_callable(uri, content, fqn, function, body) +} + +fn declaration_body(symbol_map: &SymbolMap, name_offset: u32, upper: u32) -> Option<(u32, u32)> { + symbol_map + .scopes + .iter() + .copied() + .filter(|(start, _)| *start > name_offset && *start < upper) + .min_by_key(|(start, _)| *start) +} + +fn build_method_callable( + uri: &str, + content: &str, + class: &ClassInfo, + method: &MethodInfo, + body: Option<(u32, u32)>, +) -> Option { + let uri = Url::parse(uri).ok()?; + let selection_range = offset_range(content, method.name_offset, method.name.len() as u32); + let range = Range::new( + selection_range.start, + body.map_or(selection_range.end, |(_, end)| { + offset_to_position(content, end as usize) + }), + ); + let class_fqn = class.fqn().to_string(); + Some(PhpCallable { + item: CallHierarchyItem { + name: method.name.to_string(), + kind: LspSymbolKind::METHOD, + tags: None, + detail: Some(class_fqn.clone()), + uri, + range, + selection_range, + data: Some(serde_json::json!({ + "kind": "php", + "owner": class_fqn, + "method": method.name.as_str(), + "offset": method.name_offset, + })), + }, + body, + }) +} + +fn build_function_callable( + uri: &str, + content: &str, + fqn: &str, + function: &FunctionInfo, + body: Option<(u32, u32)>, +) -> Option { + let uri = Url::parse(uri).ok()?; + let selection_range = offset_range(content, function.name_offset, function.name.len() as u32); + let range = Range::new( + selection_range.start, + body.map_or(selection_range.end, |(_, end)| { + offset_to_position(content, end as usize) + }), + ); + Some(PhpCallable { + item: CallHierarchyItem { + name: function.name.to_string(), + kind: LspSymbolKind::FUNCTION, + tags: None, + detail: function.namespace.clone(), + uri, + range, + selection_range, + data: Some(serde_json::json!({ + "kind": "php", + "function": fqn, + "offset": function.name_offset, + })), + }, + body, + }) +} + +fn offset_range(content: &str, start: u32, len: u32) -> Range { + Range::new( + offset_to_position(content, start as usize), + offset_to_position(content, start.saturating_add(len) as usize), + ) +} + +fn php_item_key(item: &CallHierarchyItem) -> String { + format!( + "{}:{}:{}:{}", + item.uri, item.selection_range.start.line, item.selection_range.start.character, item.name + ) +} + +fn push_unique_range(ranges: &mut Vec, range: Range) { + if !ranges.contains(&range) { + ranges.push(range); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const URI: &str = "file:///call_hierarchy.php"; + + fn parse(content: &str) -> Backend { + let backend = Backend::new_test(); + backend + .open_files + .write() + .insert(URI.to_string(), std::sync::Arc::new(content.to_string())); + backend.update_ast(URI, content); + backend + } + + #[test] + fn prepares_methods_and_resolves_outgoing_calls() { + let content = r#"leaf(); } +} +"#; + let backend = parse(content); + let run = backend + .prepare_call_hierarchy_impl(URI, content, Position::new(3, 22)) + .unwrap() + .remove(0); + let outgoing = backend.outgoing_calls_impl(&run).unwrap(); + assert_eq!(outgoing.len(), 1); + assert_eq!(outgoing[0].to.name, "leaf"); + assert_eq!(outgoing[0].from_ranges.len(), 1); + } + + #[test] + fn resolves_incoming_calls_through_find_references() { + let content = r#"leaf(); } +} +"#; + let backend = parse(content); + let leaf = backend + .prepare_call_hierarchy_impl(URI, content, Position::new(2, 22)) + .unwrap() + .remove(0); + let incoming = backend.incoming_calls_impl(&leaf).unwrap(); + assert_eq!(incoming.len(), 1); + assert_eq!(incoming[0].from.name, "run"); + } +} diff --git a/src/code_actions/mod.rs b/src/code_actions/mod.rs index cf31e82ad..d41ddd661 100644 --- a/src/code_actions/mod.rs +++ b/src/code_actions/mod.rs @@ -110,6 +110,7 @@ mod replace_deprecated; mod replace_fqcn; mod simplify_null; mod sort_use_statements; +mod symfony_template; mod update_docblock; use std::collections::HashMap; @@ -254,6 +255,11 @@ impl Backend { ) -> Vec { let mut actions = Vec::new(); + self.collect_create_symfony_template_actions(uri, content, params, &mut actions); + if crate::framework::is_framework_resource_uri(uri) { + return actions; + } + // Parse the file once and share the result across every collector // below. Each collector resolves cursor context by walking the // AST via `with_parsed_program(content, …)`; without this guard diff --git a/src/code_actions/symfony_template.rs b/src/code_actions/symfony_template.rs new file mode 100644 index 000000000..3afb85a1d --- /dev/null +++ b/src/code_actions/symfony_template.rs @@ -0,0 +1,88 @@ +use std::collections::HashSet; + +use tower_lsp::lsp_types::{ + CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, CreateFile, + CreateFileOptions, DocumentChangeOperation, DocumentChanges, OneOf, + OptionalVersionedTextDocumentIdentifier, Position, Range, ResourceOp, TextDocumentEdit, + TextEdit, WorkspaceEdit, +}; + +use crate::Backend; +use crate::framework::{FrameworkReferenceKind, SymfonySymbolKind}; + +impl Backend { + pub(super) fn collect_create_symfony_template_actions( + &self, + uri: &str, + content: &str, + params: &CodeActionParams, + actions: &mut Vec, + ) { + let mut seen = HashSet::new(); + for diagnostic in ¶ms.context.diagnostics { + if diagnostic.code.as_ref().and_then(|code| match code { + tower_lsp::lsp_types::NumberOrString::String(code) => Some(code.as_str()), + tower_lsp::lsp_types::NumberOrString::Number(_) => None, + }) != Some("unknown_symfony_template") + { + continue; + } + + let Some(reference) = + self.framework_reference_at_position(uri, content, diagnostic.range.start) + else { + continue; + }; + let FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Template, + name, + declaration: false, + } = reference.kind + else { + continue; + }; + let Some(template_uri) = self.symfony_template_uri(&name) else { + continue; + }; + if !seen.insert(template_uri.clone()) { + continue; + } + + let operations = vec![ + DocumentChangeOperation::Op(ResourceOp::Create(CreateFile { + uri: template_uri.clone(), + options: Some(CreateFileOptions { + overwrite: Some(false), + ignore_if_exists: Some(true), + }), + annotation_id: None, + })), + DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: template_uri, + version: None, + }, + edits: vec![OneOf::Left(TextEdit { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + new_text: format!("{{# {name} #}}\n"), + })], + }), + ]; + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: format!("Create Twig template '{name}'"), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diagnostic.clone()]), + edit: Some(WorkspaceEdit { + changes: None, + document_changes: Some(DocumentChanges::Operations(operations)), + change_annotations: None, + }), + is_preferred: Some(true), + ..Default::default() + })); + } + } +} diff --git a/src/code_lens.rs b/src/code_lens.rs index 9061c1d85..aa9a3f81b 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -1,14 +1,24 @@ //! Code Lens (`textDocument/codeLens`) support. //! -//! Shows override/implement annotations linking to the prototype declaration. +//! 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::framework::{FrameworkReferenceKind, SymfonySymbolKind}; +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}; +use crate::types::{ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, MethodInfo, Visibility}; +use crate::util::short_name; fn line_indent(content: &str, byte_offset: usize) -> u32 { let line_start = content[..byte_offset] @@ -36,23 +46,40 @@ 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, 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(); - map.get(uri)?.clone() + map.get(uri).cloned().unwrap_or_default() }; - let mut lenses = Vec::new(); + let mut lenses = self.symfony_event_lenses(&classes, uri, content); + 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(); + 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); } - + 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 @@ -75,6 +102,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); @@ -86,15 +126,125 @@ 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, + (&mut lenses, &mut seen), + ); + } + + let mut hierarchy = HashSet::new(); + hierarchy.insert(class_fqn.to_string()); + hierarchy.extend(self.class_hierarchy_names(class)); + for property in &class.properties { + if property.name_offset == 0 { + continue; + } + + if !property.is_virtual && property.visibility != Visibility::Private { + 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); + } + } + + let locations = + self.framework_property_reference_locations(&property.name, Some(&hierarchy)); + if !locations.is_empty() { + self.push_locations_lens( + uri, + offset_to_position(content, property.name_offset as usize), + format!( + "Symfony form/validation: {} {}", + locations.len(), + if locations.len() == 1 { "ref" } else { "refs" } + ), + locations, + &mut lenses, + &mut seen, + ); + } + } + + 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); } } } + 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, + ); + self.push_symfony_resource_lenses(uri, content, &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 { @@ -102,6 +252,258 @@ 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 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( + 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, + }); + } + + 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( + 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 supports_refresh { + 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_from_workspace_index(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. @@ -202,6 +604,530 @@ impl Backend { }) } + fn push_symfony_resource_lenses( + &self, + uri: &str, + content: &str, + lenses: &mut Vec, + seen: &mut HashSet, + ) { + let Some(references) = self.framework_references.read().get(uri).cloned() else { + return; + }; + + for (idx, declaration) in references.iter().enumerate() { + if let FrameworkReferenceKind::ConfigKey { + path, + declaration: true, + } = &declaration.kind + { + let usages = self.framework_config_key_locations(path, false, true); + if !usages.is_empty() { + self.push_locations_lens( + uri, + offset_to_position(content, declaration.start as usize), + format!( + "Symfony configuration: {} {}", + usages.len(), + if usages.len() == 1 { "ref" } else { "refs" } + ), + usages, + lenses, + seen, + ); + } + continue; + } + if let FrameworkReferenceKind::Translation { + domain, + name, + declaration: true, + } = &declaration.kind + { + let usages = self.framework_translation_locations(domain, name, false, true); + if !usages.is_empty() { + self.push_locations_lens( + uri, + offset_to_position(content, declaration.start as usize), + format!( + "Symfony translation: {} {}", + usages.len(), + if usages.len() == 1 { "ref" } else { "refs" } + ), + usages, + lenses, + seen, + ); + } + continue; + } + let FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration: true, + } = &declaration.kind + else { + continue; + }; + if !matches!( + kind, + SymfonySymbolKind::Service + | SymfonySymbolKind::Parameter + | SymfonySymbolKind::Route + | SymfonySymbolKind::Template + | SymfonySymbolKind::Event + | SymfonySymbolKind::MessengerBus + ) { + continue; + } + + let pos = offset_to_position(content, declaration.start as usize); + let usages = self.framework_symfony_symbol_locations(*kind, name, false, true); + if !usages.is_empty() { + let title = format!( + "Symfony {}: {} {}", + kind.label(), + usages.len(), + if usages.len() == 1 { "ref" } else { "refs" } + ); + self.push_locations_lens(uri, pos, title, usages, lenses, seen); + } + + if matches!( + kind, + SymfonySymbolKind::Parameter + | SymfonySymbolKind::Template + | SymfonySymbolKind::Event + | SymfonySymbolKind::MessengerBus + ) { + continue; + } + let block_end = references + .iter() + .skip(idx + 1) + .find_map(|candidate| { + matches!( + &candidate.kind, + FrameworkReferenceKind::SymfonySymbol { + kind: candidate_kind, + declaration: true, + .. + } if candidate_kind == kind + ) + .then_some(candidate.start) + }) + .unwrap_or(content.len() as u32); + + if *kind == SymfonySymbolKind::Route { + if let Some((class_fqn, member_name)) = references.iter().find_map(|candidate| { + if candidate.start <= declaration.start || candidate.start >= block_end { + return None; + } + let FrameworkReferenceKind::Method { + class_fqn, + member_name, + } = &candidate.kind + else { + return None; + }; + Some((class_fqn.as_str(), member_name.as_str())) + }) && let Some(location) = + self.resolve_framework_member_definition(uri, content, class_fqn, member_name) + { + self.push_locations_lens( + uri, + pos, + format!( + "Symfony controller: {}::{}", + short_name(class_fqn), + member_name + ), + vec![location], + lenses, + seen, + ); + } + continue; + } + + if let Some(class_fqn) = references.iter().find_map(|candidate| { + if candidate.start <= declaration.start || candidate.start >= block_end { + return None; + } + let FrameworkReferenceKind::Class { fqn } = &candidate.kind else { + return None; + }; + Some(fqn.as_str()) + }) && let Some(location) = self.class_location(class_fqn, uri, content) + { + self.push_locations_lens( + uri, + pos, + format!("Symfony service class: {}", short_name(class_fqn)), + vec![location], + lenses, + seen, + ); + } + } + } + + fn push_framework_class_lenses( + &self, + uri: &str, + content: &str, + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, + output: (&mut Vec, &mut HashSet), + ) { + let (lenses, seen) = output; + let class_fqn = class.fqn(); + let Some(source_pos) = class_lens_position(content, class) else { + return; + }; + + 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, + &mut *lenses, + &mut *seen, + ); + } + + for (message_fqn, handler_fqn) in self.framework_messenger_mappings_for_class(&class_fqn) { + let (target, title) = if framework_fqn_eq(&class_fqn, &message_fqn) { + ( + handler_fqn.as_str(), + format!("Symfony Messenger handler: {}", short_name(&handler_fqn)), + ) + } else if framework_fqn_eq(&class_fqn, &handler_fqn) { + ( + message_fqn.as_str(), + format!("Symfony Messenger message: {}", short_name(&message_fqn)), + ) + } else { + continue; + }; + if let Some(location) = self.class_location(target, uri, content) { + self.push_locations_lens( + uri, + source_pos, + title, + vec![location], + &mut *lenses, + &mut *seen, + ); + } + } + + 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], + &mut *lenses, + &mut *seen, + ); + } + } + + 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], + &mut *lenses, + &mut *seen, + ); + } + } + } + + fn push_framework_method_lenses( + &self, + uri: &str, + content: &str, + class: &ClassInfo, + method: &MethodInfo, + output: (&mut Vec, &mut HashSet), + ) { + let (lenses, seen) = output; + let pos = offset_to_position(content, method.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 config: 1 ref".to_string() + } else { + format!("Symfony config: {} refs", route_locations.len()) + }; + self.push_locations_lens(uri, pos, title, route_locations, lenses, seen); + } + + 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); + 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(); + { + 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; + } + if doctrine_repository_matches_entity_convention(&entity_fqn, &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. /// @@ -457,6 +1383,24 @@ impl Backend { } } + fn build_code_lens_locations_command( + &self, + title: String, + origin_uri: Url, + origin_position: Position, + locations: Vec, + ) -> Command { + 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 /// ancestor's source file. fn build_prototype( @@ -492,3 +1436,434 @@ impl Backend { }) } } + +fn framework_fqn_eq(lhs: &str, rhs: &str) -> bool { + lhs.trim_start_matches('\\') + .eq_ignore_ascii_case(rhs.trim_start_matches('\\')) +} + +#[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/completion/handler/mod.rs b/src/completion/handler/mod.rs index 853fb6489..51ce42879 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -197,6 +197,10 @@ impl Backend { }; if let Some(content) = content { + if crate::framework::is_framework_resource_uri(&uri) { + return Ok(self.try_symfony_completion(&uri, &content, position)); + } + let response = (|| -> Result> { // Activate the chain resolution cache so that shared chain // prefixes are resolved once and reused within this completion @@ -359,6 +363,15 @@ impl Backend { return Ok(Some(response)); } + // ── Symfony named resources (services and parameters) ─────── + if matches!( + string_ctx, + StringContext::InStringLiteral | StringContext::NotInString + ) && let Some(response) = self.try_symfony_completion(&uri, &content, position) + { + return Ok(Some(response)); + } + // ── Laravel string key completion (route/config/view/trans) ── // Inside `route('|')`, `config('|')`, `view('|')`, `__('|')`, // etc., offer matching key names from the project. diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 4d97ec7e1..977122ebb 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -85,6 +85,7 @@ pub(crate) mod laravel_route_params; pub(crate) mod laravel_string_keys; pub mod named_args; pub(crate) mod resolve; +pub(crate) mod symfony; pub(crate) mod target; pub(crate) mod use_edit; diff --git a/src/completion/symfony.rs b/src/completion/symfony.rs new file mode 100644 index 000000000..88bd152c9 --- /dev/null +++ b/src/completion/symfony.rs @@ -0,0 +1,848 @@ +//! Symfony named-resource completion. +//! +//! The framework index stores semantic names that PHP's AST treats as plain +//! strings: service IDs and container parameters. This module recognizes the +//! corresponding PHP, YAML, and XML string positions and completes from the +//! declarations already present in the workspace index. + +use tower_lsp::lsp_types::{ + CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Position, Range, + TextEdit, +}; + +use crate::Backend; +use crate::framework::{SymfonySymbolKind, is_framework_resource_uri}; +use crate::text_position::{offset_to_position, position_to_offset}; + +struct SymfonyCompletionContext { + kind: SymfonySymbolKind, + prefix: String, + content_start: usize, + escape_backslashes: bool, + route_name: Option, + translation_domain: Option, +} + +impl Backend { + pub(crate) fn try_symfony_completion( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option { + if !is_framework_resource_uri(uri) + && let Some(response) = self.try_symfony_form_field_completion(content, position) + { + return Some(response); + } + if is_yaml_uri(uri) + && let Some((parent, prefix, content_start)) = + yaml_config_completion_context(content, position) + { + let candidates = self.framework_config_key_children(&parent); + if !candidates.is_empty() { + return completion_response( + candidates, + &prefix, + content, + content_start, + position, + CompletionItemKind::FIELD, + "Symfony configuration key", + ); + } + } + let context = if is_framework_resource_uri(uri) { + detect_resource_context(uri, content, position)? + } else { + detect_php_context(content, position)? + }; + let candidates = if context.kind == SymfonySymbolKind::RouteParameter { + self.framework_route_parameter_names(context.route_name.as_deref()?) + } else if context.kind == SymfonySymbolKind::Translation { + self.framework_translation_names(context.translation_domain.as_deref()?) + } else { + self.framework_symfony_symbol_names(context.kind) + }; + if candidates.is_empty() { + return None; + } + + let prefix = context.prefix.to_ascii_lowercase(); + let range = Range { + start: offset_to_position(content, context.content_start), + end: position, + }; + let items = candidates + .into_iter() + .filter(|name| prefix.is_empty() || name.to_ascii_lowercase().starts_with(&prefix)) + .enumerate() + .map(|(index, name)| { + let inserted = if context.escape_backslashes { + name.replace('\\', "\\\\") + } else { + name.clone() + }; + CompletionItem { + label: name, + kind: Some(match context.kind { + SymfonySymbolKind::Parameter => CompletionItemKind::PROPERTY, + SymfonySymbolKind::Service => CompletionItemKind::REFERENCE, + SymfonySymbolKind::Route => CompletionItemKind::VALUE, + SymfonySymbolKind::RouteParameter => CompletionItemKind::FIELD, + SymfonySymbolKind::Template => CompletionItemKind::FILE, + SymfonySymbolKind::Translation => CompletionItemKind::VALUE, + SymfonySymbolKind::Event => CompletionItemKind::EVENT, + SymfonySymbolKind::MessengerBus => CompletionItemKind::REFERENCE, + }), + detail: Some(format!("Symfony {}", context.kind.label())), + sort_text: Some(format!("{index:05}")), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range, + new_text: inserted, + })), + ..Default::default() + } + }) + .collect::>(); + + (!items.is_empty()).then_some(CompletionResponse::Array(items)) + } + + fn try_symfony_form_field_completion( + &self, + content: &str, + position: Position, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let (quote_start, _) = opening_quote(content, cursor)?; + let (call_name, argument_index, _) = php_call_context(content, quote_start)?; + if argument_index != 0 + || !matches!( + call_name.to_ascii_lowercase().as_str(), + "add" | "get" | "has" | "remove" + ) + { + return None; + } + let raw_class = php_form_data_class(content)?; + let use_map = self.parse_use_statements(content); + let namespace = self.parse_namespace(content); + let fqn = crate::util::resolve_to_fqn(&raw_class, &use_map, &namespace); + let class = self.find_or_load_class(&fqn)?; + let mut candidates = class + .properties + .iter() + .map(|property| property.name.to_string()) + .collect::>(); + candidates.sort_unstable(); + candidates.dedup(); + completion_response( + candidates, + &content[quote_start + 1..cursor], + content, + quote_start + 1, + position, + CompletionItemKind::FIELD, + "Symfony form field", + ) + } +} + +fn completion_response( + candidates: Vec, + prefix: &str, + content: &str, + content_start: usize, + position: Position, + kind: CompletionItemKind, + detail: &str, +) -> Option { + let prefix = prefix.to_ascii_lowercase(); + let range = Range { + start: offset_to_position(content, content_start), + end: position, + }; + let items = candidates + .into_iter() + .filter(|name| prefix.is_empty() || name.to_ascii_lowercase().starts_with(&prefix)) + .enumerate() + .map(|(index, name)| CompletionItem { + label: name.clone(), + kind: Some(kind), + detail: Some(detail.to_string()), + sort_text: Some(format!("{index:05}")), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range, + new_text: name, + })), + ..Default::default() + }) + .collect::>(); + (!items.is_empty()).then_some(CompletionResponse::Array(items)) +} + +fn php_form_data_class(content: &str) -> Option { + let marker = content.find("data_class")?; + let suffix = &content[marker + "data_class".len()..]; + let class_suffix = suffix.find("::class")?; + let bytes = suffix.as_bytes(); + let mut name_end = class_suffix; + while name_end > 0 && bytes[name_end - 1].is_ascii_whitespace() { + name_end -= 1; + } + let mut name_start = name_end; + while name_start > 0 + && (bytes[name_start - 1] == b'\\' + || bytes[name_start - 1] == b'_' + || bytes[name_start - 1].is_ascii_alphanumeric()) + { + name_start -= 1; + } + let name = suffix[name_start..name_end].trim_start_matches('\\'); + (!name.is_empty()).then(|| name.to_string()) +} + +fn is_yaml_uri(uri: &str) -> bool { + uri.split('?').next().is_some_and(|path| { + let path = path.to_ascii_lowercase(); + path.ends_with(".yaml") || path.ends_with(".yml") + }) +} + +fn yaml_config_completion_context( + content: &str, + position: Position, +) -> Option<(String, String, usize)> { + let cursor = position_to_offset(content, position) as usize; + let line_start = content[..cursor].rfind('\n').map_or(0, |start| start + 1); + let current = &content[line_start..cursor]; + let indent = current.bytes().take_while(|byte| *byte == b' ').count(); + let typed = current[indent..].trim_start(); + if typed.contains(':') + || !typed + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return None; + } + + let mut parents: Vec<(usize, String)> = Vec::new(); + for line in content[..line_start].split_inclusive('\n') { + let line_without_newline = line.trim_end_matches(['\r', '\n']); + let semantic = line_without_newline + .split_once('#') + .map_or(line_without_newline, |(before, _)| before); + let line_indent = semantic.bytes().take_while(|byte| *byte == b' ').count(); + let trimmed = semantic.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') { + continue; + } + while parents + .last() + .is_some_and(|(parent_indent, _)| *parent_indent >= line_indent) + { + parents.pop(); + } + if let Some((key, value)) = trimmed.split_once(':') { + let key = key.trim().trim_matches(['\'', '"']); + if !key.is_empty() + && value.trim().is_empty() + && key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + parents.push((line_indent, key.to_string())); + } + } + } + while parents + .last() + .is_some_and(|(parent_indent, _)| *parent_indent >= indent) + { + parents.pop(); + } + let parent = parents + .iter() + .map(|(_, key)| key.as_str()) + .collect::>() + .join("."); + let content_start = line_start + current.find(typed).unwrap_or(indent); + Some((parent, typed.to_string(), content_start)) +} + +fn detect_php_context(content: &str, position: Position) -> Option { + let cursor = position_to_offset(content, position) as usize; + let (quote_start, quote) = opening_quote(content, cursor)?; + let raw_prefix = content.get(quote_start + 1..cursor)?; + + if let Some(percent) = raw_prefix.rfind('%') + && !raw_prefix[percent + 1..].contains('%') + { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Parameter, + prefix: raw_prefix[percent + 1..].to_string(), + content_start: quote_start + percent + 2, + escape_backslashes: false, + route_name: None, + translation_domain: None, + }); + } + + let service_prefix = raw_prefix + .bytes() + .take_while(|byte| matches!(byte, b'@' | b'?' | b'!')) + .count(); + let (call_name, argument_index, args_start) = php_call_context(content, quote_start)?; + let call_name = call_name.to_ascii_lowercase(); + let named_argument = named_argument_before(content, args_start, quote_start); + if argument_index > 0 + && is_route_reference_call(&call_name, content, quote_start) + && content[args_start..quote_start].contains('[') + && let Some(route_name) = first_string_argument(content, args_start, quote_start) + { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::RouteParameter, + prefix: raw_prefix.to_string(), + content_start: quote_start + 1, + escape_backslashes: false, + route_name: Some(route_name), + translation_domain: None, + }); + } + let service_context = (matches!(call_name.as_str(), "service" | "decorate" | "target") + && argument_index == 0) + || (call_name == "alias" && argument_index == 1) + || (matches!(call_name.as_str(), "get" | "has") + && argument_index == 0 + && looks_like_container_call(content, quote_start)) + || (call_name == "autowire" + && named_argument.is_some_and(|name| name.eq_ignore_ascii_case("service"))) + || service_prefix > 0; + let parameter_context = (matches!( + call_name.as_str(), + "param" | "getparameter" | "hasparameter" + ) && argument_index == 0) + || (call_name == "autowire" + && named_argument.is_some_and(|name| name.eq_ignore_ascii_case("param"))); + let route_context = (matches!(call_name.as_str(), "generateurl" | "redirecttoroute") + && argument_index == 0) + || (call_name == "generate" + && argument_index == 0 + && looks_like_route_generator_call(content, quote_start)); + let template_context = argument_index == 0 + && (matches!( + call_name.as_str(), + "render" | "renderview" | "renderblock" | "htmltemplate" | "texttemplate" + ) || (call_name == "template" + && named_argument.is_none_or(|name| name.eq_ignore_ascii_case("template")))); + let translation_context = + argument_index == 0 && matches!(call_name.as_str(), "trans" | "translatablemessage"); + let event_context = (call_name.ends_with("eventlistener") + && (argument_index == 0 + || named_argument.is_some_and(|name| name.eq_ignore_ascii_case("event")))) + || (call_name == "dispatch" && argument_index == 1) + || (call_name == "addlistener" && argument_index == 0); + let messenger_bus_context = call_name.ends_with("messagehandler") + && named_argument.is_some_and(|name| name.eq_ignore_ascii_case("bus")); + let kind = if service_context { + SymfonySymbolKind::Service + } else if parameter_context { + SymfonySymbolKind::Parameter + } else if route_context { + SymfonySymbolKind::Route + } else if template_context { + SymfonySymbolKind::Template + } else if translation_context { + SymfonySymbolKind::Translation + } else if event_context { + SymfonySymbolKind::Event + } else if messenger_bus_context { + SymfonySymbolKind::MessengerBus + } else { + return None; + }; + + Some(SymfonyCompletionContext { + kind, + prefix: raw_prefix[service_prefix..].replace("\\\\", "\\"), + content_start: quote_start + 1 + service_prefix, + escape_backslashes: quote == b'\'' || quote == b'"', + route_name: None, + translation_domain: (kind == SymfonySymbolKind::Translation) + .then(|| php_translation_domain(content, args_start)) + .flatten(), + }) +} + +fn detect_resource_context( + uri: &str, + content: &str, + position: Position, +) -> Option { + let cursor = position_to_offset(content, position) as usize; + let line_start = content[..cursor].rfind('\n').map_or(0, |idx| idx + 1); + let prefix = &content[line_start..cursor]; + + if let Some((quote_start, _)) = opening_quote(content, cursor) + && let Some((call_name, argument_index, args_start)) = + php_call_context(content, quote_start) + { + if matches!(call_name, "path" | "url") && argument_index == 0 { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Route, + prefix: content[quote_start + 1..cursor].to_string(), + content_start: quote_start + 1, + escape_backslashes: false, + route_name: None, + translation_domain: None, + }); + } + if matches!(call_name, "path" | "url") + && content[args_start..quote_start].contains('{') + && let Some(route_name) = first_string_argument(content, args_start, quote_start) + { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::RouteParameter, + prefix: content[quote_start + 1..cursor].to_string(), + content_start: quote_start + 1, + escape_backslashes: false, + route_name: Some(route_name), + translation_domain: None, + }); + } + if is_twig_uri(uri) + && matches!( + call_name.to_ascii_lowercase().as_str(), + "include" | "source" + ) + && argument_index == 0 + { + return template_context(content, quote_start, cursor); + } + } + + if is_twig_uri(uri) + && let Some((quote_start, _)) = opening_quote(content, cursor) + { + if let Some(domain) = twig_translation_domain(content, quote_start) { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Translation, + prefix: content[quote_start + 1..cursor].to_string(), + content_start: quote_start + 1, + escape_backslashes: false, + route_name: None, + translation_domain: Some(domain), + }); + } + let statement = content[line_start..quote_start].trim_end(); + let keyword = statement + .rsplit_once("{%") + .map(|(_, tail)| tail.trim_start()) + .and_then(|tail| tail.split_whitespace().next()) + .unwrap_or_default() + .to_ascii_lowercase(); + if matches!( + keyword.as_str(), + "extends" | "include" | "embed" | "use" | "import" | "from" + ) { + return template_context(content, quote_start, cursor); + } + } + + if let Some((quote_start, _)) = opening_quote(content, cursor) { + let nearby_start = line_start.saturating_sub(512); + let nearby = &content[nearby_start..cursor]; + let line_before_quote = &content[line_start..quote_start]; + if nearby.contains("kernel.event_listener") + && (line_before_quote.contains("event:") + || line_before_quote.to_ascii_lowercase().contains("event=")) + { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Event, + prefix: content[quote_start + 1..cursor].to_string(), + content_start: quote_start + 1, + escape_backslashes: false, + route_name: None, + translation_domain: None, + }); + } + } + + if let Some(percent) = prefix.rfind('%') + && !prefix[percent + 1..].contains('%') + { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Parameter, + prefix: prefix[percent + 1..].to_string(), + content_start: line_start + percent + 1, + escape_backslashes: false, + route_name: None, + translation_domain: None, + }); + } + + if let Some(at) = prefix.rfind('@') { + let typed = prefix[at + 1..].trim_start_matches(['?', '!']); + let adjust = prefix[at + 1..].len() - typed.len(); + if typed.bytes().all(is_symbol_char) { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Service, + prefix: typed.to_string(), + content_start: line_start + at + 1 + adjust, + escape_backslashes: false, + route_name: None, + translation_domain: None, + }); + } + } + + let lower = prefix.to_ascii_lowercase(); + let service_attribute = ["alias=\"", "decorates=\"", "parent=\"", "service=\""] + .iter() + .find_map(|needle| lower.rfind(needle).map(|start| (needle.len(), start))); + if let Some((needle_len, start)) = service_attribute { + let typed_start = start + needle_len; + let typed = &prefix[typed_start..]; + if !typed.contains('"') && typed.bytes().all(is_symbol_char) { + return Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Service, + prefix: typed.to_string(), + content_start: line_start + typed_start, + escape_backslashes: false, + route_name: None, + translation_domain: None, + }); + } + } + + None +} + +fn template_context( + content: &str, + quote_start: usize, + cursor: usize, +) -> Option { + Some(SymfonyCompletionContext { + kind: SymfonySymbolKind::Template, + prefix: content.get(quote_start + 1..cursor)?.to_string(), + content_start: quote_start + 1, + escape_backslashes: false, + route_name: None, + translation_domain: None, + }) +} + +fn php_translation_domain(content: &str, args_start: usize) -> Option { + string_argument(content, args_start, 2) + .or_else(|| named_string_argument(content, args_start, "domain")) + .or_else(|| Some("messages".to_string())) +} + +fn string_argument(content: &str, args_start: usize, target: usize) -> Option { + let bytes = content.as_bytes(); + let mut cursor = args_start; + let mut argument = 0usize; + let mut depth = 0u32; + while cursor < bytes.len() { + match bytes[cursor] { + b'\'' | b'"' => { + let quote = bytes[cursor]; + let start = cursor + 1; + let mut end = start; + while end < bytes.len() { + if bytes[end] == b'\\' { + end = (end + 2).min(bytes.len()); + continue; + } + if bytes[end] == quote { + break; + } + end += 1; + } + if argument == target && depth == 0 { + return Some(content[start..end].to_string()); + } + cursor = end; + } + b'(' | b'[' | b'{' => depth += 1, + b')' if depth == 0 => break, + b')' | b']' | b'}' => depth = depth.saturating_sub(1), + b',' if depth == 0 => argument += 1, + _ => {} + } + cursor += 1; + } + None +} + +fn named_string_argument(content: &str, args_start: usize, target: &str) -> Option { + let call = content.get(args_start..)?; + let end = call.find(')')?; + let call = &call[..end]; + let target = format!("{target}:"); + let start = call.to_ascii_lowercase().find(&target)? + target.len(); + let quote_rel = call[start..].find(['\'', '"'])?; + let quote_start = start + quote_rel; + let quote = call.as_bytes()[quote_start]; + let value_start = quote_start + 1; + let value_end = call[value_start..].find(quote as char)? + value_start; + Some(call[value_start..value_end].to_string()) +} + +fn twig_translation_domain(content: &str, quote_start: usize) -> Option { + let bytes = content.as_bytes(); + let quote = *bytes.get(quote_start)?; + let mut quote_end = quote_start + 1; + while quote_end < bytes.len() { + if bytes[quote_end] == b'\\' { + quote_end = (quote_end + 2).min(bytes.len()); + continue; + } + if bytes[quote_end] == quote { + break; + } + quote_end += 1; + } + let mut cursor = quote_end + 1; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'|') { + return None; + } + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + let name_start = cursor; + while bytes + .get(cursor) + .is_some_and(|byte| is_identifier_char(*byte)) + { + cursor += 1; + } + if !content[name_start..cursor].eq_ignore_ascii_case("trans") { + return None; + } + twig_filter_domain(content, cursor) + .or_else(|| twig_default_domain(content)) + .or_else(|| Some("messages".to_string())) +} + +fn twig_filter_domain(content: &str, filter_end: usize) -> Option { + let bytes = content.as_bytes(); + let mut cursor = filter_end; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'(') { + return None; + } + string_argument(content, cursor + 1, 1) + .or_else(|| named_string_argument(content, cursor + 1, "domain")) +} + +fn twig_default_domain(content: &str) -> Option { + let lower = content.to_ascii_lowercase(); + let start = lower.find("trans_default_domain")? + "trans_default_domain".len(); + let quote_rel = content[start..].find(['\'', '"'])?; + let quote_start = start + quote_rel; + let quote = content.as_bytes()[quote_start]; + let value_start = quote_start + 1; + let value_end = content[value_start..].find(quote as char)? + value_start; + Some(content[value_start..value_end].to_string()) +} + +fn is_twig_uri(uri: &str) -> bool { + uri.split('?') + .next() + .is_some_and(|path| path.to_ascii_lowercase().ends_with(".twig")) +} + +fn opening_quote(content: &str, cursor: usize) -> Option<(usize, u8)> { + let bytes = content.as_bytes(); + let mut index = cursor; + while index > 0 { + index -= 1; + let byte = bytes[index]; + if byte == b'\n' || byte == b'\r' { + return None; + } + if matches!(byte, b'\'' | b'"') { + let mut backslashes = 0usize; + let mut previous = index; + while previous > 0 && bytes[previous - 1] == b'\\' { + previous -= 1; + backslashes += 1; + } + if backslashes.is_multiple_of(2) { + return Some((index, byte)); + } + } + } + None +} + +fn php_call_context(content: &str, quote_start: usize) -> Option<(&str, usize, usize)> { + let search_start = quote_start.saturating_sub(2048); + let open = content[search_start..quote_start].rfind('(')? + search_start; + let bytes = content.as_bytes(); + let mut name_end = open; + while name_end > 0 && bytes[name_end - 1].is_ascii_whitespace() { + name_end -= 1; + } + let mut name_start = name_end; + while name_start > 0 && is_identifier_char(bytes[name_start - 1]) { + name_start -= 1; + } + if name_start == name_end { + return None; + } + + let mut argument_index = 0usize; + let mut depth = 0u32; + for byte in bytes[open + 1..quote_start].iter().copied() { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => depth = depth.saturating_sub(1), + b',' if depth == 0 => argument_index += 1, + _ => {} + } + } + Some((&content[name_start..name_end], argument_index, open + 1)) +} + +fn named_argument_before(content: &str, args_start: usize, quote_start: usize) -> Option<&str> { + let segment = content[args_start..quote_start] + .rsplit_once(',') + .map_or(&content[args_start..quote_start], |(_, tail)| tail) + .trim(); + let colon = segment.rfind(':')?; + let name = segment[..colon].trim(); + (!name.is_empty() && name.bytes().all(is_identifier_char)).then_some(name) +} + +fn looks_like_container_call(content: &str, quote_start: usize) -> bool { + let start = quote_start.saturating_sub(160); + let prefix = &content[start..quote_start]; + if prefix.contains("$container->") + || prefix.contains("$serviceLocator->") + || prefix.contains("$locator->") + || prefix.contains("container->") + { + return true; + } + + let Some(arrow) = prefix.rfind("->") else { + return false; + }; + let receiver_prefix = prefix[..arrow].trim_end(); + let receiver_start = receiver_prefix + .rfind(|character: char| { + !(character == '$' || character == '_' || character.is_ascii_alphanumeric()) + }) + .map_or(0, |index| index + 1); + let receiver = &receiver_prefix[receiver_start..]; + !receiver.is_empty() + && [ + format!("ContainerInterface {receiver}"), + format!("ServiceLocator {receiver}"), + format!("ContainerBagInterface {receiver}"), + ] + .iter() + .any(|typed| content.contains(typed)) +} + +fn looks_like_route_generator_call(content: &str, quote_start: usize) -> bool { + let start = quote_start.saturating_sub(192); + let prefix = &content[start..quote_start]; + prefix.contains("$router->generate(") + || prefix.contains("$urlGenerator->generate(") + || content.contains("UrlGeneratorInterface") + || content.contains("RouterInterface") +} + +fn is_route_reference_call(call_name: &str, content: &str, quote_start: usize) -> bool { + matches!(call_name, "generateurl" | "redirecttoroute") + || (call_name == "generate" && looks_like_route_generator_call(content, quote_start)) +} + +fn first_string_argument(content: &str, args_start: usize, before: usize) -> Option { + let bytes = content.as_bytes(); + let mut cursor = args_start; + while cursor < before && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + let quote @ (b'\'' | b'"') = bytes.get(cursor).copied()? else { + return None; + }; + cursor += 1; + let start = cursor; + while cursor < before { + if bytes[cursor] == b'\\' { + cursor = (cursor + 2).min(before); + continue; + } + if bytes[cursor] == quote { + return Some(content[start..cursor].replace("\\\\", "\\")); + } + cursor += 1; + } + None +} + +fn is_identifier_char(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() +} + +fn is_symbol_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b':' | b'/' | b'\\') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_container_service_call() { + let php = "get('app.'); }\n"; + let offset = php.find("app.").unwrap() + 4; + let position = offset_to_position(php, offset); + let context = detect_php_context(php, position).unwrap(); + assert_eq!(context.kind, SymfonySymbolKind::Service); + assert_eq!(context.prefix, "app."); + } + + #[test] + fn detects_autowire_parameter() { + let php = ", + /// Cache environment used by automatic discovery. Defaults to `dev`. + pub environment: Option, + /// Optional workspace-relative compiled-container paths or glob patterns. + pub paths: Vec, +} + +impl SymfonyContainerConfig { + pub fn enabled(&self) -> bool { + self.enabled.unwrap_or(true) + } + + pub fn environment(&self) -> &str { + self.environment.as_deref().unwrap_or("dev") + } +} + +/// `[symfony.events]` section. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct SymfonyEventsConfig { + /// Attribute-driven publisher rules. + pub publishers: Vec, + /// Attribute-driven subscriber rules. + pub subscribers: Vec, + /// Prefixes ignored when comparing event names. + #[serde(rename = "ignored-prefixes")] + pub ignored_prefixes: Vec, + /// Suffixes ignored when comparing event names. + #[serde(rename = "ignored-suffixes")] + pub ignored_suffixes: Vec, +} + +/// One `[[symfony.events.publishers]]` attribute rule. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SymfonyEventPublisherConfig { + /// Fully-qualified PHP attribute class. + pub attribute: String, + /// Named argument containing an explicit event name. + #[serde(rename = "name-argument")] + pub name_argument: Option, + /// Zero-based positional fallback for the event-name argument. + #[serde(rename = "name-position")] + pub name_position: Option, + /// Named argument containing dispatch enum cases. + #[serde(rename = "dispatch-argument")] + pub dispatch_argument: Option, + /// Zero-based positional fallback for the dispatch argument. + #[serde(rename = "dispatch-position")] + pub dispatch_position: Option, + /// Dispatch names used when the attribute omits the dispatch argument. + #[serde(rename = "default-dispatch")] + pub default_dispatch: Vec, + /// Enum case to event-name segment mapping. + #[serde(rename = "dispatch-cases")] + pub dispatch_cases: std::collections::HashMap, + /// Template used for derived names. + #[serde(rename = "name-template")] + pub name_template: String, + /// Template used when an explicit name is present. Defaults to `{name}`. + #[serde(rename = "explicit-name-template")] + pub explicit_name_template: Option, + /// Method names that do not add a method suffix. + #[serde(rename = "default-methods")] + pub default_methods: Vec, + /// Conditional dispatch omissions. + pub skip: Vec, +} + +/// One conditional omission inside a publisher rule. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SymfonyEventSkipConfig { + /// Dispatch name to omit. + pub dispatch: String, + /// Named argument whose non-null value activates the omission. + pub argument: String, + /// Zero-based positional fallback for the argument. + pub position: Option, +} + +/// One `[[symfony.events.subscribers]]` attribute rule. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SymfonyEventSubscriberConfig { + /// Fully-qualified PHP attribute class. + pub attribute: String, + /// Named argument containing the event name. + #[serde(rename = "name-argument")] + pub name_argument: Option, + /// Zero-based positional fallback for the event-name argument. + #[serde(rename = "name-position")] + pub name_position: Option, + /// Optional named transport argument. + #[serde(rename = "transport-argument")] + pub transport_argument: Option, + /// Zero-based positional fallback for the transport argument. + #[serde(rename = "transport-position")] + pub transport_position: Option, + /// Enum case to event-name suffix mapping. + #[serde(rename = "transport-cases")] + pub transport_cases: std::collections::HashMap, +} + +/// `[symfony.expression-language]` section. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct SymfonyExpressionLanguageConfig { + /// Attribute arguments that contain ExpressionLanguage strings. + pub attributes: Vec, + /// Expression object constructors nested inside PHP attributes. + pub constructors: Vec, +} + +/// One `[[symfony.expression-language.attributes]]` argument rule. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SymfonyExpressionAttributeConfig { + /// Fully-qualified PHP attribute class. + pub attribute: String, + /// Named argument containing the expression string or string array. + pub argument: Option, + /// Zero-based positional fallback for the argument. + pub position: Option, + /// Bind expression roots to method parameters with the same name. + #[serde(rename = "method-parameters")] + pub method_parameters: bool, + /// Expression root to PHP type-source mapping. + pub bindings: std::collections::HashMap, +} + +/// One `[[symfony.expression-language.constructors]]` object rule. +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SymfonyExpressionConstructorConfig { + /// Fully-qualified PHP class instantiated for the expression object. + pub class: String, + /// Named constructor argument containing the expression string. + pub argument: Option, + /// Zero-based positional fallback for the constructor argument. + pub position: Option, + /// Only match constructors nested in these attribute FQN prefixes. + #[serde(rename = "inside-attribute-prefixes")] + pub inside_attribute_prefixes: Vec, + /// Bind expression roots to method parameters with the same name. + #[serde(rename = "method-parameters")] + pub method_parameters: bool, + /// Expression root to PHP type-source mapping. + pub bindings: std::collections::HashMap, } /// `[semantic_tokens]` section β€” controls LSP semantic highlighting. @@ -146,6 +318,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. @@ -810,6 +999,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()); @@ -847,6 +1037,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()); @@ -870,6 +1061,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()); @@ -894,6 +1086,126 @@ mod tests { assert_eq!(config.php.version.as_deref(), Some("8.3")); } + #[test] + fn parses_symfony_container_and_event_rules() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write( + &path, + r#" +[symfony.container] +environment = "dev" + +[symfony.events] +ignored-prefixes = ["use_case."] +ignored-suffixes = [".async"] + +[[symfony.events.publishers]] +attribute = 'Acme\Event\Publish' +name-argument = "name" +name-position = 2 +dispatch-argument = "dispatch" +dispatch-position = 4 +default-dispatch = ["post"] +dispatch-cases = { PRE = "pre", POST = "post" } +name-template = "{dispatch}.{class_snake}{method_suffix_snake}" +explicit-name-template = "{name}" +default-methods = ["execute", "__invoke"] + +[[symfony.events.publishers.skip]] +dispatch = "post" +argument = "messageClass" +position = 5 + +[[symfony.events.subscribers]] +attribute = 'Acme\Event\Listen' +name-argument = "name" +name-position = 0 +transport-argument = "transport" +transport-position = 2 +transport-cases = { ASYNC = ".async" } +"#, + ) + .unwrap(); + + let config = load_config(dir.path()).unwrap(); + assert!(config.symfony.container.enabled()); + assert_eq!(config.symfony.container.environment(), "dev"); + assert_eq!(config.symfony.events.publishers.len(), 1); + assert_eq!( + config.symfony.events.publishers[0].dispatch_cases["POST"], + "post" + ); + assert_eq!(config.symfony.events.publishers[0].skip.len(), 1); + assert_eq!(config.symfony.events.subscribers.len(), 1); + assert_eq!( + config.symfony.events.subscribers[0].transport_cases["ASYNC"], + ".async" + ); + } + + #[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_symfony_expression_language_rules() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CONFIG_FILE_NAME); + std::fs::write( + &path, + r#" +[[symfony.expression-language.attributes]] +attribute = 'Acme\Attribute\Cache' +argument = "tags" +position = 3 +method-parameters = true + +[[symfony.expression-language.constructors]] +class = 'Symfony\Component\ExpressionLanguage\Expression' +position = 0 +inside-attribute-prefixes = ['Acme\Attribute\'] +bindings = { request = "parameter:0", response = "return", subject = 'class:App\Model\Subject' } +"#, + ) + .unwrap(); + + let config = load_config(dir.path()).unwrap(); + let expression = config.symfony.expression_language; + assert_eq!(expression.attributes.len(), 1); + assert!(expression.attributes[0].method_parameters); + assert_eq!(expression.attributes[0].argument.as_deref(), Some("tags")); + assert_eq!(expression.constructors.len(), 1); + assert_eq!( + expression.constructors[0].bindings["request"], + "parameter:0" + ); + assert_eq!( + expression.constructors[0].bindings["subject"], + "class:App\\Model\\Subject" + ); + } + #[test] fn parses_diagnostics_section() { let dir = tempfile::tempdir().unwrap(); 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/definition/resolve.rs b/src/definition/resolve.rs index 2ade50c39..caac7f9c9 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, MessengerHandlerRole}; use crate::symbol_map::{SelfStaticParentKind, SymbolKind}; use crate::text_position::position_to_offset; use crate::types::{AccessKind, ClassInfo, MAX_INHERITANCE_DEPTH}; @@ -75,9 +76,146 @@ 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) - .into_iter() - .collect() + if let Some(loc) = laravel::resolve_path_helper_definition(self, content, position) { + return vec![loc]; + } + + self.resolve_framework_resource_definition(uri, content, position) + } + + fn resolve_framework_resource_definition( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Vec { + let Some(reference) = self.framework_reference_at_position(uri, content, position) else { + return Vec::new(); + }; + match reference.kind { + FrameworkReferenceKind::Class { fqn } => self + .resolve_class_reference(uri, content, &fqn, true, reference.start) + .into_iter() + .collect(), + FrameworkReferenceKind::Method { + class_fqn, + member_name, + } => self + .resolve_framework_member_definition(uri, content, &class_fqn, &member_name) + .into_iter() + .collect(), + FrameworkReferenceKind::Property { + class_fqn, + member_name, + } => self + .resolve_framework_property_definition(uri, content, &class_fqn, &member_name) + .into_iter() + .collect(), + FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration: false, + } => self.framework_symfony_symbol_locations(kind, &name, true, false), + FrameworkReferenceKind::RouteParameter { + route_name, + name, + declaration: false, + } => self.framework_route_parameter_locations(&route_name, &name, true, false), + FrameworkReferenceKind::Translation { + domain, + name, + declaration: false, + } => self.framework_translation_locations(&domain, &name, true, false), + FrameworkReferenceKind::MessengerHandler { + message_fqn, + handler_fqn, + role, + } => { + let target = match role { + MessengerHandlerRole::Message => handler_fqn, + MessengerHandlerRole::Handler => message_fqn, + }; + self.resolve_class_reference(uri, content, &target, true, reference.start) + .into_iter() + .collect() + } + FrameworkReferenceKind::ConfigKey { + path, + declaration: false, + } => self.framework_config_key_locations(&path, true, false), + FrameworkReferenceKind::Namespace { .. } + | FrameworkReferenceKind::Path { .. } + | FrameworkReferenceKind::SymfonySymbol { + declaration: true, .. + } + | FrameworkReferenceKind::RouteParameter { + declaration: true, .. + } + | FrameworkReferenceKind::Translation { + declaration: true, .. + } + | FrameworkReferenceKind::ConfigKey { + declaration: true, .. + } => Vec::new(), + } + } + + pub(crate) 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)) + } + + pub(crate) fn resolve_framework_property_definition( + &self, + uri: &str, + content: &str, + class_fqn: &str, + property_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, property_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, + property_name, + MemberKind::Property, + declaring_class.member_name_offset(property_name, "property"), + )?; + Some(point_location(Url::parse(&class_uri).ok()?, position)) } /// Look up the symbol at the given byte offset in the precomputed diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index c3a8b983e..74546e5a0 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -111,6 +111,9 @@ //! - **Invalid class kind diagnostics** β€” report a class-like name used //! in a syntactic position (`new`, `implements`, `instanceof`, …) that //! its kind (class/interface/trait/enum) cannot satisfy. +//! - **Symfony ExpressionLanguage member diagnostics** β€” report members +//! missing from PHP types supplied by configured attribute and constructor +//! contracts. //! - **Laravel string key / command parameter diagnostics** (Laravel //! projects only) β€” report route/config/view/translation/command //! names and morph aliases that don't resolve to a known declaration, @@ -238,6 +241,8 @@ mod stale; pub(crate) mod state; mod subject_cache; pub(crate) mod suppression; +mod symfony; +mod symfony_expressions; mod syntax_errors; mod type_errors; pub(crate) mod undefined_variables; @@ -307,6 +312,9 @@ impl Backend { content: &str, out: &mut Vec, ) { + if crate::framework::is_framework_resource_uri(uri_str) { + return; + } self.collect_syntax_error_diagnostics(uri_str, content, out); self.collect_unused_import_diagnostics(uri_str, content, out); self.collect_unused_variable_diagnostics(uri_str, content, out); @@ -408,6 +416,10 @@ impl Backend { out: &mut Vec, mut observe: Option>, ) { + if crate::framework::is_framework_resource_uri(uri_str) { + self.collect_unknown_symfony_resource_diagnostics(uri_str, content, out); + return; + } // Activate the chain resolution cache so that all slow // diagnostic collectors share cached intermediate chain // prefix results (e.g. `$model->where(...)` resolved once @@ -493,6 +505,10 @@ impl Backend { "unknown_member", self.collect_unknown_member_diagnostics_with_context(ctx, uri_str, content, out) ); + step!( + "symfony_expression", + self.collect_symfony_expression_diagnostics(uri_str, content, out) + ); step!( "unknown_function", self.collect_unknown_function_diagnostics_with_context(ctx, uri_str, content, out) @@ -583,6 +599,7 @@ impl Backend { self.collect_blade_section_diagnostics(uri_str, out) ); } + self.collect_unknown_symfony_resource_diagnostics(uri_str, content, out); } /// Emit a warning for each `$this->argument('x')` / `$this->option('x')` diff --git a/src/diagnostics/symfony.rs b/src/diagnostics/symfony.rs new file mode 100644 index 000000000..79a744698 --- /dev/null +++ b/src/diagnostics/symfony.rs @@ -0,0 +1,184 @@ +//! Conservative diagnostics for project-local Symfony named resources. + +use std::collections::HashSet; + +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Range}; + +use crate::Backend; +use crate::framework::{FrameworkReferenceKind, SymfonySymbolKind}; +use crate::text_position::offset_to_position; + +impl Backend { + pub(crate) fn collect_unknown_symfony_resource_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + let Some(references) = self.framework_references.read().get(uri).cloned() else { + return; + }; + let known_services = self + .framework_symfony_symbol_names(SymfonySymbolKind::Service) + .into_iter() + .collect::>(); + let known_parameters = self + .framework_symfony_symbol_names(SymfonySymbolKind::Parameter) + .into_iter() + .collect::>(); + let known_routes = self + .framework_symfony_symbol_names(SymfonySymbolKind::Route) + .into_iter() + .collect::>(); + let known_templates = self + .framework_symfony_symbol_names(SymfonySymbolKind::Template) + .into_iter() + .collect::>(); + let known_events = self + .framework_symfony_symbol_names(SymfonySymbolKind::Event) + .into_iter() + .collect::>(); + let known_buses = self + .framework_symfony_symbol_names(SymfonySymbolKind::MessengerBus) + .into_iter() + .collect::>(); + let mut translation_domains = HashSet::new(); + let mut known_translations = HashSet::new(); + let mut config_roots = HashSet::new(); + let mut known_config_keys = HashSet::new(); + for refs in self.framework_references.read().values() { + for reference in refs.iter() { + if let FrameworkReferenceKind::ConfigKey { + path, + declaration: true, + } = &reference.kind + { + config_roots.insert(path.split('.').next().unwrap_or_default().to_string()); + known_config_keys.insert(path.clone()); + continue; + } + let FrameworkReferenceKind::Translation { + domain, + name, + declaration: true, + } = &reference.kind + else { + continue; + }; + translation_domains.insert(domain.clone()); + known_translations.insert((domain.clone(), name.clone())); + } + } + + for reference in references.iter() { + if let FrameworkReferenceKind::ConfigKey { + path, + declaration: false, + } = &reference.kind + { + let root = path.split('.').next().unwrap_or_default(); + if config_roots.contains(root) && !known_config_keys.contains(path) { + out.push(Diagnostic { + range: Range { + start: offset_to_position(content, reference.start as usize), + end: offset_to_position(content, reference.end as usize), + }, + severity: Some(DiagnosticSeverity::WARNING), + code: Some(NumberOrString::String( + "unknown_symfony_config_key".to_string(), + )), + source: Some("PHPantom".to_string()), + message: format!("Symfony configuration key '{}' is not declared", path), + ..Default::default() + }); + } + continue; + } + if let FrameworkReferenceKind::Translation { + domain, + name, + declaration: false, + } = &reference.kind + { + if translation_domains.contains(domain) + && !known_translations.contains(&(domain.clone(), name.clone())) + { + out.push(Diagnostic { + range: Range { + start: offset_to_position(content, reference.start as usize), + end: offset_to_position(content, reference.end as usize), + }, + severity: Some(DiagnosticSeverity::WARNING), + code: Some(NumberOrString::String( + "unknown_symfony_translation".to_string(), + )), + source: Some("PHPantom".to_string()), + message: format!( + "Symfony translation '{}' is not declared in the '{}' domain", + name, domain + ), + ..Default::default() + }); + } + continue; + } + let FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration: false, + } = &reference.kind + else { + continue; + }; + + let known = match kind { + SymfonySymbolKind::Service => { + known_services.contains(name) + || (name.starts_with("App\\") && self.find_or_load_class(name).is_some()) + } + SymfonySymbolKind::Parameter => known_parameters.contains(name), + SymfonySymbolKind::Route => known_routes.contains(name), + SymfonySymbolKind::RouteParameter => true, + SymfonySymbolKind::Template => known_templates.contains(name), + SymfonySymbolKind::Translation => true, + SymfonySymbolKind::Event => known_events.contains(name), + SymfonySymbolKind::MessengerBus => known_buses.contains(name), + }; + if known || !is_project_local_name(*kind, name) { + continue; + } + + let label = kind.label(); + out.push(Diagnostic { + range: Range { + start: offset_to_position(content, reference.start as usize), + end: offset_to_position(content, reference.end as usize), + }, + severity: Some(DiagnosticSeverity::WARNING), + code: Some(NumberOrString::String(format!( + "unknown_symfony_{}", + kind.diagnostic_name() + ))), + source: Some("PHPantom".to_string()), + message: format!("Symfony {label} '{}' is not declared", name), + ..Default::default() + }); + } + } +} + +fn is_project_local_name(kind: SymfonySymbolKind, name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("app.") + || (kind == SymfonySymbolKind::Service && name.starts_with("App\\")) + || (kind == SymfonySymbolKind::Route && lower.starts_with("app_")) + || (kind == SymfonySymbolKind::Template + && name.to_ascii_lowercase().ends_with(".twig") + && !name.starts_with(['@', '/', '\\']) + && !name.starts_with("./") + && !name.starts_with("../")) + || (kind == SymfonySymbolKind::Event + && (lower.starts_with("app.") || lower.starts_with("app_"))) + || (kind == SymfonySymbolKind::MessengerBus + && (lower.starts_with("app.") || lower.starts_with("app_"))) +} diff --git a/src/diagnostics/symfony_expressions.rs b/src/diagnostics/symfony_expressions.rs new file mode 100644 index 000000000..8075d1280 --- /dev/null +++ b/src/diagnostics/symfony_expressions.rs @@ -0,0 +1,47 @@ +//! Diagnostics for configured Symfony ExpressionLanguage strings. + +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Range}; + +use super::helpers::make_diagnostic; +use super::unknown_members::UNKNOWN_MEMBER_CODE; +use crate::Backend; + +impl Backend { + pub(super) fn collect_symfony_expression_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + for problem in self.symfony_expression_problems(uri, content) { + let kind = if problem.is_method { + "Method" + } else { + "Property" + }; + let message = if problem.classes.len() == 1 { + format!( + "{} '{}' not found on class '{}'", + kind, problem.member, problem.classes[0] + ) + } else { + format!( + "{} '{}' not found on any of the {} possible types ({})", + kind, + problem.member, + problem.classes.len(), + problem.classes.join(", ") + ) + }; + out.push(make_diagnostic( + Range::new( + crate::text_position::offset_to_position(content, problem.start), + crate::text_position::offset_to_position(content, problem.end), + ), + DiagnosticSeverity::WARNING, + UNKNOWN_MEMBER_CODE, + message, + )); + } + } +} diff --git a/src/framework.rs b/src/framework.rs new file mode 100644 index 000000000..6f8f96316 --- /dev/null +++ b/src/framework.rs @@ -0,0 +1,5170 @@ +//! Symfony and Doctrine configuration reference indexing. +//! +//! PHPantom's normal [`SymbolMap`](crate::symbol_map::SymbolMap) is built from +//! PHP ASTs, but framework configuration also encodes symbols in YAML/XML and +//! PHP string literals. A parallel lightweight index lets those references +//! participate in go-to-definition, find-references, rename, code lenses, 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, Copy, PartialEq, Eq, Hash)] +pub(crate) enum SymfonySymbolKind { + Service, + Parameter, + Route, + RouteParameter, + Template, + Translation, + Event, + MessengerBus, +} + +impl SymfonySymbolKind { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Service => "service", + Self::Parameter => "parameter", + Self::Route => "route", + Self::RouteParameter => "route parameter", + Self::Template => "template", + Self::Translation => "translation", + Self::Event => "event", + Self::MessengerBus => "Messenger bus", + } + } + + pub(crate) fn diagnostic_name(self) -> &'static str { + match self { + Self::Service => "service", + Self::Parameter => "parameter", + Self::Route => "route", + Self::RouteParameter => "route_parameter", + Self::Template => "template", + Self::Translation => "translation", + Self::Event => "event", + Self::MessengerBus => "messenger_bus", + } + } +} + +#[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 property name encoded in forms or validation configuration. + Property { + 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 }, + /// A named Symfony resource such as a service ID or parameter name. + SymfonySymbol { + kind: SymfonySymbolKind, + name: String, + declaration: bool, + }, + /// A named placeholder scoped to one Symfony route. + RouteParameter { + route_name: String, + name: String, + declaration: bool, + }, + /// A translation key scoped to one Symfony catalogue domain. + Translation { + domain: String, + name: String, + declaration: bool, + }, + /// One side of a Symfony Messenger message-to-handler relationship. + MessengerHandler { + message_fqn: String, + handler_fqn: String, + role: MessengerHandlerRole, + }, + /// A dot-qualified key from a local Symfony `TreeBuilder` schema. + ConfigKey { path: String, declaration: bool }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MessengerHandlerRole { + Message, + Handler, +} + +#[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) 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, Clone)] +struct IndexedMessengerMapping { + uri: Arc, + message_fqn: String, + handler_fqn: String, +} + +#[derive(Debug, Default)] +struct FrameworkLookupUriKeys { + classes: HashSet, + methods: HashSet, + properties: HashSet, + messenger_classes: HashSet, +} + +/// Inverted declaration and relation data from 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>, + properties: HashMap>, + messenger_by_class: HashMap>, + uri_keys: HashMap, FrameworkLookupUriKeys>, +} + +pub(crate) type FrameworkReferenceLookupIndex = 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 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://") + .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") + || path_lower.ends_with(".xlf") + || path_lower.ends_with(".xliff") + || path_lower.ends_with(".twig") +} + +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" | "xlf" | "xliff" | "twig") + ) +} + +fn is_php_uri(uri: &str) -> bool { + let path = uri + .strip_prefix("file://") + .unwrap_or(uri) + .split('?') + .next() + .unwrap_or(uri); + path.get(path.len().saturating_sub(4)..) + .is_some_and(|extension| extension.eq_ignore_ascii_case(".php")) +} + +pub(crate) fn is_framework_php_config_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("php")) + && path + .components() + .any(|component| matches!(component, Component::Normal(name) if name == "config")) +} + +fn is_symfony_translation_php_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("php")) + && path + .components() + .any(|component| matches!(component, Component::Normal(name) if name == "translations")) +} + +fn is_symfony_translation_php_uri(uri: &str) -> bool { + is_php_uri(uri) + && uri + .split('?') + .next() + .unwrap_or(uri) + .split('/') + .any(|component| component == "translations") +} + +pub(crate) fn is_framework_php_config_uri(uri: &str) -> bool { + if !is_php_uri(uri) { + return false; + } + uri.split('?') + .next() + .unwrap_or(uri) + .split('/') + .any(|component| component == "config") +} + +pub(crate) fn should_index_framework_php_content(uri: &str, content: &str) -> bool { + is_php_uri(uri) + && (is_framework_php_config_uri(uri) + || is_symfony_translation_php_uri(uri) + || content.contains("Autowire") + || content.contains("ContainerInterface") + || content.contains("ContainerBagInterface") + || content.contains("ServiceLocator") + || content.contains("getParameter(") + || content.contains("hasParameter(") + || content.contains("service(") + || content.contains("param(") + || content.contains("$container->get(") + || content.contains("generateUrl(") + || content.contains("redirectToRoute(") + || content.contains("UrlGeneratorInterface") + || content.contains("RouterInterface") + || content.contains("RoutingConfigurator") + || content.contains("Routing\\Attribute\\Route") + || content.contains("Routing\\Annotation\\Route") + || content.contains("#[Route(") + || content.contains("#[\\Route(") + || content.contains("render(") + || content.contains("renderView(") + || content.contains("renderBlock(") + || content.contains("htmlTemplate(") + || content.contains("textTemplate(") + || content.contains("#[Template(") + || content.contains("#[\\Template(") + || content.contains("TranslatorInterface") + || content.contains("TranslatableMessage") + || content.contains("->trans(") + || content.contains("EventDispatcherInterface") + || content.contains("AsEventListener") + || content.contains("->dispatch(") + || content.contains("AsMessageHandler") + || content.contains("MessageBusInterface") + || content.contains("Messenger\\") + || content.contains("TreeBuilder") + || content.contains("FormBuilderInterface") + || content.contains("AbstractType")) +} + +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 { + 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::Property { + class_fqn, + member_name, + } => { + lookup + .properties + .entry(member_name.clone()) + .or_default() + .push(IndexedFrameworkMemberLocation { + class_fqn: framework_fqn_lookup_key(class_fqn), + location, + }); + keys.properties.insert(member_name.clone()); + } + FrameworkReferenceKind::MessengerHandler { + message_fqn, + handler_fqn, + .. + } => { + let mapping = IndexedMessengerMapping { + uri: Arc::clone(&uri), + message_fqn: normalize_framework_fqn(message_fqn), + handler_fqn: normalize_framework_fqn(handler_fqn), + }; + for key in [ + framework_fqn_lookup_key(message_fqn), + framework_fqn_lookup_key(handler_fqn), + ] { + lookup + .messenger_by_class + .entry(key.clone()) + .or_default() + .push(mapping.clone()); + keys.messenger_classes.insert(key); + } + } + _ => {} + } + } + + if !keys.classes.is_empty() + || !keys.methods.is_empty() + || !keys.properties.is_empty() + || !keys.messenger_classes.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); + } + } + for key in keys.properties { + let remove_key = lookup.properties.get_mut(&key).is_some_and(|locations| { + locations.retain(|entry| entry.location.uri.as_ref() != uri); + locations.is_empty() + }); + if remove_key { + lookup.properties.remove(&key); + } + } + for key in keys.messenger_classes { + let remove_key = lookup + .messenger_by_class + .get_mut(&key) + .is_some_and(|mappings| { + mappings.retain(|mapping| mapping.uri.as_ref() != uri); + mappings.is_empty() + }); + if remove_key { + lookup.messenger_by_class.remove(&key); + } + } + } + + /// Scan framework configuration 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(); + let mut doctrine_repositories = HashMap::new(); + let mut lookup = FrameworkReferenceLookupIndexInner::default(); + 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_framework_php_config_path(path) + && !is_symfony_translation_php_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 mappings = if is_framework_resource_uri(&uri) { + scan_doctrine_repository_mappings(&uri, &content) + } else { + Vec::new() + }; + if !mappings.is_empty() { + doctrine_repositories.insert(uri.clone(), Arc::new(mappings)); + } + if let Some(refs) = self.scan_framework_uri_references(&uri, &content) + && !refs.is_empty() + { + Self::replace_framework_lookup_uri(&mut lookup, &uri, &content, &refs); + indexed.insert(uri, Arc::new(refs)); + } + } + + let count = indexed.len(); + *self.framework_references.write() = indexed; + *self.framework_doctrine_repositories.write() = doctrine_repositories; + *self.framework_reference_lookup.write() = lookup; + count + } + + pub(crate) fn index_framework_uri_content(&self, uri: &str, content: &str) { + let refs = self.scan_framework_uri_references(uri, content); + if refs.is_none() && !self.framework_references.read().contains_key(uri) { + return; + } + let mappings = if is_framework_resource_uri(uri) { + scan_doctrine_repository_mappings(uri, content) + } else { + Vec::new() + }; + let mut index = self.framework_references.write(); + let mut lookup = self.framework_reference_lookup.write(); + match refs { + Some(refs) if !refs.is_empty() => { + Self::replace_framework_lookup_uri(&mut lookup, uri, content, &refs); + index.insert(uri.to_string(), Arc::new(refs)); + } + Some(_) | None => { + index.remove(uri); + Self::remove_framework_lookup_uri(&mut lookup, uri); + } + } + 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) { + if !is_framework_resource_uri(uri) + && !is_framework_php_config_uri(uri) + && !is_symfony_translation_php_uri(uri) + && !self.framework_references.read().contains_key(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.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); + Self::remove_framework_lookup_uri(&mut self.framework_reference_lookup.write(), uri); + } + + pub(crate) fn apply_framework_file_change( + &self, + uri: &str, + path: &Path, + change_type: tower_lsp::lsp_types::FileChangeType, + ) -> bool { + let is_php = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("php")); + if (!is_framework_resource_path(path) && !is_php) || 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 { + let offset = position_to_offset(content, position); + let refs = self + .framework_references + .read() + .get(uri) + .cloned() + .or_else(|| { + self.scan_framework_uri_references(uri, content) + .map(Arc::new) + })?; + + refs.iter() + .filter(|reference| { + offset >= reference.start + && (offset < reference.end + || (offset == reference.end && offset > reference.start)) + }) + .min_by_key(|reference| reference.end.saturating_sub(reference.start)) + .cloned() + .or_else(|| { + offset.checked_sub(1).and_then(|prev| { + refs.iter() + .filter(|reference| prev >= reference.start && prev < reference.end) + .min_by_key(|reference| reference.end.saturating_sub(reference.start)) + .cloned() + }) + }) + } + + pub(crate) fn framework_class_reference_locations(&self, target_fqn: &str) -> Vec { + 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 + } + + pub(crate) fn framework_member_reference_locations( + &self, + target_member: &str, + hierarchy: Option<&HashSet>, + ) -> Vec { + 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 + } + + pub(crate) fn framework_property_reference_locations( + &self, + target_property: &str, + hierarchy: Option<&HashSet>, + ) -> Vec { + let hierarchy = hierarchy.map(normalized_framework_hierarchy); + let lookup = self.framework_reference_lookup.read(); + let mut locations = lookup + .properties + .get(target_property) + .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 + } + + pub(crate) fn framework_symfony_symbol_names( + &self, + target_kind: SymfonySymbolKind, + ) -> Vec { + let mut names = Vec::new(); + for refs in self.framework_references.read().values() { + for reference in refs.iter() { + let FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration: true, + } = &reference.kind + else { + continue; + }; + if *kind == target_kind { + push_unique_string(&mut names, name.clone()); + } + } + } + names.sort_unstable(); + names + } + + pub(crate) fn framework_symfony_symbol_locations( + &self, + target_kind: SymfonySymbolKind, + target_name: &str, + include_declarations: bool, + include_references: bool, + ) -> 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::SymfonySymbol { + kind, + name, + declaration, + } = &reference.kind + else { + continue; + }; + if *kind != target_kind + || name != target_name + || (*declaration && !include_declarations) + || (!*declaration && !include_references) + { + continue; + } + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&content, reference.start as usize), + offset_to_position(&content, reference.end as usize), + ); + } + } + sort_locations(&mut locations); + locations + } + + pub(crate) fn framework_route_parameter_names(&self, route_name: &str) -> Vec { + let mut names = Vec::new(); + for refs in self.framework_references.read().values() { + for reference in refs.iter() { + let FrameworkReferenceKind::RouteParameter { + route_name: candidate_route, + name, + declaration: true, + } = &reference.kind + else { + continue; + }; + if candidate_route == route_name { + push_unique_string(&mut names, name.clone()); + } + } + } + names.sort_unstable(); + names + } + + pub(crate) fn framework_route_parameter_locations( + &self, + route_name: &str, + parameter_name: &str, + include_declarations: bool, + include_references: bool, + ) -> 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::RouteParameter { + route_name: candidate_route, + name, + declaration, + } = &reference.kind + else { + continue; + }; + if candidate_route != route_name + || name != parameter_name + || (*declaration && !include_declarations) + || (!*declaration && !include_references) + { + continue; + } + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&content, reference.start as usize), + offset_to_position(&content, reference.end as usize), + ); + } + } + sort_locations(&mut locations); + locations + } + + pub(crate) fn framework_translation_names(&self, domain: &str) -> Vec { + let mut names = Vec::new(); + for refs in self.framework_references.read().values() { + for reference in refs.iter() { + let FrameworkReferenceKind::Translation { + domain: candidate_domain, + name, + declaration: true, + } = &reference.kind + else { + continue; + }; + if candidate_domain == domain { + push_unique_string(&mut names, name.clone()); + } + } + } + names.sort_unstable(); + names + } + + pub(crate) fn framework_translation_locations( + &self, + domain: &str, + name: &str, + include_declarations: bool, + include_references: bool, + ) -> 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::Translation { + domain: candidate_domain, + name: candidate_name, + declaration, + } = &reference.kind + else { + continue; + }; + if candidate_domain != domain + || candidate_name != name + || (*declaration && !include_declarations) + || (!*declaration && !include_references) + { + continue; + } + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&content, reference.start as usize), + offset_to_position(&content, reference.end as usize), + ); + } + } + sort_locations(&mut locations); + locations + } + + pub(crate) fn framework_messenger_handler_locations( + &self, + message_fqn: &str, + handler_fqn: &str, + ) -> Vec { + let message_fqn = normalize_framework_fqn(message_fqn); + let handler_fqn = normalize_framework_fqn(handler_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::MessengerHandler { + message_fqn: candidate_message, + handler_fqn: candidate_handler, + .. + } = &reference.kind + else { + continue; + }; + if normalize_framework_fqn(candidate_message).eq_ignore_ascii_case(&message_fqn) + && normalize_framework_fqn(candidate_handler).eq_ignore_ascii_case(&handler_fqn) + { + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&content, reference.start as usize), + offset_to_position(&content, reference.end as usize), + ); + } + } + } + sort_locations(&mut locations); + locations + } + + pub(crate) fn framework_messenger_mappings_for_class( + &self, + target_fqn: &str, + ) -> Vec<(String, String)> { + let lookup = self.framework_reference_lookup.read(); + let mut mappings: Vec<_> = lookup + .messenger_by_class + .get(&framework_fqn_lookup_key(target_fqn)) + .into_iter() + .flatten() + .map(|mapping| (mapping.message_fqn.clone(), mapping.handler_fqn.clone())) + .collect(); + mappings.sort_unstable(); + mappings.dedup(); + mappings + } + + pub(crate) fn framework_config_key_names(&self) -> Vec { + let mut names = Vec::new(); + for refs in self.framework_references.read().values() { + for reference in refs.iter() { + let FrameworkReferenceKind::ConfigKey { + path, + declaration: true, + } = &reference.kind + else { + continue; + }; + push_unique_string(&mut names, path.clone()); + } + } + names.sort_unstable(); + names + } + + pub(crate) fn framework_config_key_children(&self, parent: &str) -> Vec { + let prefix = (!parent.is_empty()).then(|| format!("{parent}.")); + let mut children = Vec::new(); + for path in self.framework_config_key_names() { + let remainder = match &prefix { + Some(prefix) => path.strip_prefix(prefix.as_str()), + None => Some(path.as_str()), + }; + let Some(remainder) = remainder else { + continue; + }; + let child = remainder.split('.').next().unwrap_or_default(); + if !child.is_empty() { + push_unique_string(&mut children, child.to_string()); + } + } + children.sort_unstable(); + children + } + + pub(crate) fn framework_config_key_locations( + &self, + target_path: &str, + include_declarations: bool, + include_references: bool, + ) -> 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::ConfigKey { path, declaration } = &reference.kind + else { + continue; + }; + if path != target_path + || (*declaration && !include_declarations) + || (!*declaration && !include_references) + { + continue; + } + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&content, reference.start as usize), + offset_to_position(&content, reference.end as usize), + ); + } + } + 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 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 + } + + 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 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_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() + .or_else(|| { + self.scan_framework_uri_references(uri, content) + .map(Arc::new) + })?; + + 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::Property { + class_fqn: lhs_class, + member_name: lhs_name, + }, + FrameworkReferenceKind::Property { + 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, + ( + FrameworkReferenceKind::SymfonySymbol { + kind: lhs_kind, + name: lhs_name, + .. + }, + FrameworkReferenceKind::SymfonySymbol { + kind: rhs_kind, + name: rhs_name, + .. + }, + ) => lhs_kind == rhs_kind && lhs_name == rhs_name, + ( + FrameworkReferenceKind::RouteParameter { + route_name: lhs_route, + name: lhs_name, + .. + }, + FrameworkReferenceKind::RouteParameter { + route_name: rhs_route, + name: rhs_name, + .. + }, + ) => lhs_route == rhs_route && lhs_name == rhs_name, + ( + FrameworkReferenceKind::Translation { + domain: lhs_domain, + name: lhs_name, + .. + }, + FrameworkReferenceKind::Translation { + domain: rhs_domain, + name: rhs_name, + .. + }, + ) => lhs_domain == rhs_domain && lhs_name == rhs_name, + ( + FrameworkReferenceKind::MessengerHandler { + message_fqn: lhs_message, + handler_fqn: lhs_handler, + .. + }, + FrameworkReferenceKind::MessengerHandler { + message_fqn: rhs_message, + handler_fqn: rhs_handler, + .. + }, + ) => { + normalize_framework_fqn(lhs_message) + .eq_ignore_ascii_case(&normalize_framework_fqn(rhs_message)) + && normalize_framework_fqn(lhs_handler) + .eq_ignore_ascii_case(&normalize_framework_fqn(rhs_handler)) + } + ( + FrameworkReferenceKind::ConfigKey { path: lhs, .. }, + FrameworkReferenceKind::ConfigKey { path: 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 scan_framework_uri_references( + &self, + uri: &str, + content: &str, + ) -> Option> { + if is_framework_resource_uri(uri) { + let mut refs = scan_framework_references(uri, content); + if is_twig_uri(uri) { + self.scan_twig_template_declarations(uri, &mut refs); + refs.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + refs.dedup(); + } + return Some(refs); + } + if is_symfony_translation_php_uri(uri) { + return Some(scan_symfony_php_translation_catalog(uri, content)); + } + if should_index_framework_php_content(uri, content) { + return Some(self.scan_symfony_php_references(uri, content)); + } + None + } + + fn scan_symfony_php_references(&self, uri: &str, content: &str) -> Vec { + let use_map = self.parse_use_statements(content); + let namespace = self.parse_namespace(content); + let mut refs = Vec::new(); + let include_config_resources = + is_framework_php_config_uri(uri) && is_symfony_php_config_content(content); + let literals = scan_php_string_literals_and_class_constants( + uri, + content, + &use_map, + &namespace, + include_config_resources, + &mut refs, + ); + + for (idx, literal) in literals.iter().enumerate() { + if include_config_resources { + scan_php_config_literal(uri, literal, &mut refs); + } + scan_php_symfony_literal( + uri, + content, + &literals, + idx, + include_config_resources, + &mut refs, + ); + + let value = literal.value.trim(); + if include_config_resources && valid_framework_segment(value) { + let class_fqn = + php_callable_class_before(content, literal.quote_start, &use_map, &namespace) + .or_else(|| php_callable_string_class_before(content, &literals, idx)); + if let Some(class_fqn) = class_fqn { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: literal.start as u32, + end: literal.end as u32, + kind: FrameworkReferenceKind::Method { + class_fqn, + member_name: value.to_string(), + }, + }); + } + } + + if include_config_resources + && looks_like_path_value(value) + && php_literal_has_path_context(content, &literals, idx) + { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: literal.start as u32, + end: literal.end as u32, + kind: FrameworkReferenceKind::Path { + value: value.to_string(), + }, + }); + } + } + scan_php_route_parameters(uri, content, &literals, &mut refs); + scan_php_event_listener_methods(uri, content, &literals, &namespace, &mut refs); + scan_php_messenger_handlers(uri, content, &use_map, &namespace, &mut refs); + scan_php_form_fields(uri, content, &literals, &use_map, &namespace, &mut refs); + scan_php_config_schema(uri, content, &literals, &mut refs); + + if include_config_resources { + let class_service_declarations: Vec<(u32, u32, String)> = refs + .iter() + .filter_map(|reference| { + let FrameworkReferenceKind::Class { fqn } = &reference.kind else { + return None; + }; + let call = php_call_context(content, reference.start as usize)?; + (call.name == "set" && call.argument_index == 0) + .then(|| (reference.start, reference.end, normalize_framework_fqn(fqn))) + }) + .collect(); + for (start, end, name) in class_service_declarations { + refs.push(FrameworkReference { + uri: uri.to_string(), + start, + end, + kind: FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Service, + name, + declaration: true, + }, + }); + } + } + + refs.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + refs.dedup(); + refs + } + + fn scan_twig_template_declarations(&self, uri: &str, refs: &mut Vec) { + let Some(root) = self.workspace.workspace_root.read().clone() else { + return; + }; + let Some(path) = Url::parse(uri).ok().and_then(|url| url.to_file_path().ok()) else { + return; + }; + for name in twig_template_names(&root, &path) { + push_symfony_symbol(refs, uri, SymfonySymbolKind::Template, name, 0, 0, true); + } + } + + pub(crate) fn symfony_template_uri(&self, name: &str) -> Option { + if !is_safe_project_template_name(name) { + return None; + } + let root = self.workspace.workspace_root.read().clone()?; + Url::from_file_path(root.join("templates").join(name)).ok() + } +} + +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::Property { .. } + | FrameworkReferenceKind::Path { .. } + | FrameworkReferenceKind::SymfonySymbol { .. } + | FrameworkReferenceKind::RouteParameter { .. } + | FrameworkReferenceKind::Translation { .. } + | FrameworkReferenceKind::MessengerHandler { .. } + | FrameworkReferenceKind::ConfigKey { .. } => None, + } +} + +#[derive(Debug, Clone, Copy)] +struct PhpStringLiteral<'a> { + value: &'a str, + quote_start: usize, + quote_end: usize, + start: usize, + end: usize, +} + +fn is_symfony_php_config_content(content: &str) -> bool { + let has_configurator = content.contains("Configurator"); + if !has_configurator && !content.contains("Symfony\\Config\\") { + return false; + } + + content.contains(r"Symfony\Component\DependencyInjection\Loader\Configurator") + || content.contains(r"Symfony\Component\Routing\Loader\Configurator") + || content.contains("Symfony\\Config\\") + || (has_configurator + && (content.contains("ContainerConfigurator") + || content.contains("RoutingConfigurator")) + && [ + "->services(", + "->set(", + "->load(", + "->controller(", + "->import(", + "::config(", + ] + .iter() + .any(|needle| content.contains(needle))) +} + +fn scan_php_string_literals_and_class_constants<'a>( + uri: &str, + content: &'a str, + use_map: &HashMap, + namespace: &Option, + capture_class_references: bool, + refs: &mut Vec, +) -> Vec> { + let bytes = content.as_bytes(); + let mut literals = Vec::new(); + let mut i = 0usize; + + while i < bytes.len() { + if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'/') { + i += 2; + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if bytes[i] == b'#' && bytes.get(i + 1) != Some(&b'[') { + i += 1; + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/')) { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + + if matches!(bytes[i], b'\'' | b'"') { + let quote = bytes[i]; + let quote_start = i; + let start = i + 1; + i = start; + while i < bytes.len() { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + i += 2; + continue; + } + if bytes[i] == quote { + literals.push(PhpStringLiteral { + value: &content[start..i], + quote_start, + quote_end: i, + start, + end: i, + }); + i += 1; + break; + } + i += 1; + } + continue; + } + + if is_php_name_start(bytes[i]) && (i == 0 || !is_php_name_char(bytes[i.saturating_sub(1)])) + { + let start = i; + i += 1; + while i < bytes.len() && is_php_name_char(bytes[i]) { + i += 1; + } + let end = i; + let mut cursor = end; + skip_ascii_whitespace(bytes, &mut cursor); + if bytes.get(cursor..cursor + 2) != Some(b"::") { + continue; + } + cursor += 2; + skip_ascii_whitespace(bytes, &mut cursor); + if !content + .get(cursor..cursor + 5) + .is_some_and(|keyword| keyword.eq_ignore_ascii_case("class")) + || bytes + .get(cursor + 5) + .is_some_and(|byte| is_php_identifier_char(*byte)) + { + continue; + } + + let raw_name = &content[start..end]; + if matches!( + raw_name.to_ascii_lowercase().as_str(), + "self" | "static" | "parent" + ) { + continue; + } + let fqn = + normalize_framework_fqn(&crate::util::resolve_to_fqn(raw_name, use_map, namespace)); + if capture_class_references && valid_framework_name(&fqn) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::Class { fqn }, + }); + } + continue; + } + + i += 1; + } + + literals +} + +#[derive(Clone, Copy)] +struct PhpCallContext<'a> { + name: &'a str, + argument_index: usize, + args_start: usize, +} + +fn scan_php_symfony_literal( + uri: &str, + content: &str, + literals: &[PhpStringLiteral<'_>], + literal_idx: usize, + in_configurator: bool, + refs: &mut Vec, +) { + let literal = &literals[literal_idx]; + scan_parameter_placeholders(uri, literal.value, literal.start, refs); + + let leading = literal.value.len() - literal.value.trim_start().len(); + let trailing = literal.value.len() - literal.value.trim_end().len(); + let raw = literal.value.trim(); + if raw.is_empty() { + return; + } + + if in_configurator { + let service_prefix = raw + .bytes() + .take_while(|byte| matches!(byte, b'@' | b'?' | b'!')) + .count(); + if service_prefix > 0 { + let name = php_semantic_string(&raw[service_prefix..]); + if valid_symfony_symbol_name(&name) { + push_symfony_symbol( + refs, + uri, + SymfonySymbolKind::Service, + name, + literal.start + leading + service_prefix, + literal.end - trailing, + false, + ); + } + } + } + + let Some(call) = php_call_context(content, literal.quote_start) else { + return; + }; + let call_name = call.name.to_ascii_lowercase(); + let named_argument = php_named_argument_before(content, call.args_start, literal.quote_start); + let semantic_value = php_semantic_string(raw); + let translation_reference = + call.argument_index == 0 && matches!(call_name.as_str(), "trans" | "translatablemessage"); + let event_listener_attribute = call_name.ends_with("eventlistener"); + let event_declaration = (event_listener_attribute + && (call.argument_index == 0 + || named_argument.is_some_and(|name| name.eq_ignore_ascii_case("event")))) + || (call_name == "addlistener" && call.argument_index == 0); + let event_reference = call_name == "dispatch" && call.argument_index == 1; + let messenger_bus_reference = call_name.ends_with("messagehandler") + && named_argument.is_some_and(|name| name.eq_ignore_ascii_case("bus")); + let messenger_bus_declaration = in_configurator + && call_name == "bus" + && call.argument_index == 0 + && content.contains("Messenger"); + let template_reference = call.argument_index == 0 + && (matches!( + call_name.as_str(), + "render" | "renderview" | "renderblock" | "htmltemplate" | "texttemplate" + ) || (call_name == "template" + && named_argument.is_none_or(|name| name.eq_ignore_ascii_case("template")))); + if !valid_symfony_symbol_name(&semantic_value) + && !(template_reference && valid_template_name(&semantic_value)) + && !(translation_reference && valid_translation_key(&semantic_value)) + { + return; + } + if translation_reference { + push_translation( + refs, + uri, + php_translation_domain(content, literals, call), + semantic_value, + literal.start + leading, + literal.end - trailing, + false, + ); + return; + } + + let service_reference = (call_name == "alias" && call.argument_index == 1) + || (matches!(call_name.as_str(), "service" | "decorate" | "target") + && call.argument_index == 0) + || (matches!(call_name.as_str(), "get" | "has") + && call.argument_index == 0 + && looks_like_container_call(content, call)) + || (call_name == "autowire" + && named_argument.is_some_and(|name| name.eq_ignore_ascii_case("service"))); + let parameter_reference = (matches!( + call_name.as_str(), + "param" | "getparameter" | "hasparameter" + ) && call.argument_index == 0) + || (call_name == "autowire" + && named_argument.is_some_and(|name| name.eq_ignore_ascii_case("param"))); + let route_reference = (matches!(call_name.as_str(), "generateurl" | "redirecttoroute") + && call.argument_index == 0) + || (call_name == "generate" + && call.argument_index == 0 + && looks_like_route_generator_call(content, call)); + let route_declaration = (in_configurator && call.argument_index == 0 && call_name == "add") + || ((call_name == "route" + || (call_name.ends_with("route") + && (content.contains("Routing\\Attribute\\Route") + || content.contains("Routing\\Annotation\\Route")))) + && named_argument.is_some_and(|name| name.eq_ignore_ascii_case("name"))); + let (kind, declaration) = if route_declaration { + (SymfonySymbolKind::Route, true) + } else if in_configurator + && call.argument_index == 0 + && call_name == "set" + && looks_like_parameter_set(content, call) + { + (SymfonySymbolKind::Parameter, true) + } else if in_configurator + && call.argument_index == 0 + && matches!(call_name.as_str(), "set" | "alias") + { + (SymfonySymbolKind::Service, true) + } else if in_configurator && call_name == "setparameter" && call.argument_index == 0 { + (SymfonySymbolKind::Parameter, true) + } else if service_reference { + (SymfonySymbolKind::Service, false) + } else if parameter_reference { + (SymfonySymbolKind::Parameter, false) + } else if route_reference { + (SymfonySymbolKind::Route, false) + } else if template_reference { + (SymfonySymbolKind::Template, false) + } else if event_declaration { + (SymfonySymbolKind::Event, true) + } else if event_reference { + (SymfonySymbolKind::Event, false) + } else if messenger_bus_declaration { + (SymfonySymbolKind::MessengerBus, true) + } else if messenger_bus_reference { + (SymfonySymbolKind::MessengerBus, false) + } else { + return; + }; + + push_symfony_symbol( + refs, + uri, + kind, + semantic_value, + literal.start + leading, + literal.end - trailing, + declaration, + ); +} + +fn scan_php_event_listener_methods( + uri: &str, + content: &str, + literals: &[PhpStringLiteral<'_>], + namespace: &Option, + refs: &mut Vec, +) { + for literal in literals { + let Some(call) = php_call_context(content, literal.quote_start) else { + continue; + }; + if !call.name.to_ascii_lowercase().ends_with("eventlistener") + || !php_named_argument_before(content, call.args_start, literal.quote_start) + .is_some_and(|name| name.eq_ignore_ascii_case("method")) + { + continue; + } + let method = php_semantic_string(literal.value.trim()); + if !valid_framework_segment(&method) { + continue; + } + let Some(class_fqn) = + php_enclosing_class_fqn(content, literal.quote_start, namespace.as_deref()) + .or_else(|| php_class_fqn_after(content, literal.quote_end, namespace.as_deref())) + else { + continue; + }; + refs.push(FrameworkReference { + uri: uri.to_string(), + start: literal.start as u32, + end: literal.end as u32, + kind: FrameworkReferenceKind::Method { + class_fqn, + member_name: method, + }, + }); + } +} + +fn php_class_fqn_after(content: &str, offset: usize, namespace: Option<&str>) -> Option { + let class_start = php_keyword_after(content, offset, "class", 1024)?; + let mut name_start = class_start + "class".len(); + skip_ascii_whitespace(content.as_bytes(), &mut name_start); + let mut name_end = name_start; + while content + .as_bytes() + .get(name_end) + .is_some_and(|byte| is_php_identifier_char(*byte)) + { + name_end += 1; + } + let name = content.get(name_start..name_end)?; + if name.is_empty() { + return None; + } + Some(namespace.map_or_else( + || name.to_string(), + |namespace| format!("{namespace}\\{name}"), + )) +} + +fn php_enclosing_class_fqn( + content: &str, + offset: usize, + namespace: Option<&str>, +) -> Option { + let prefix = content.get(..offset)?; + let (class_start, keyword) = ["class", "trait", "enum"] + .iter() + .filter_map(|keyword| { + prefix + .rmatch_indices(keyword) + .find(|(start, _)| { + let before = start + .checked_sub(1) + .and_then(|idx| prefix.as_bytes().get(idx)); + let after = prefix.as_bytes().get(start + keyword.len()); + before.is_none_or(|byte| !is_php_identifier_char(*byte)) + && after.is_some_and(u8::is_ascii_whitespace) + }) + .map(|(start, _)| (start, *keyword)) + }) + .max_by_key(|(start, _)| *start)?; + let mut name_start = class_start + keyword.len(); + skip_ascii_whitespace(content.as_bytes(), &mut name_start); + let mut name_end = name_start; + while content + .as_bytes() + .get(name_end) + .is_some_and(|byte| is_php_identifier_char(*byte)) + { + name_end += 1; + } + let name = content.get(name_start..name_end)?; + if name.is_empty() { + return None; + } + Some(namespace.map_or_else( + || name.to_string(), + |namespace| format!("{namespace}\\{name}"), + )) +} + +fn scan_php_messenger_handlers( + uri: &str, + content: &str, + use_map: &HashMap, + namespace: &Option, + refs: &mut Vec, +) { + let mut search = 0usize; + while let Some(rel_attribute) = content[search..].find("AsMessageHandler") { + let attribute_name = search + rel_attribute; + let Some(attribute_end_rel) = content[attribute_name..].find(']') else { + break; + }; + let attribute_end = attribute_name + attribute_end_rel + 1; + let Some(class_start) = php_keyword_after(content, attribute_end, "class", 512) else { + search = attribute_end; + continue; + }; + if php_keyword_after( + content, + attribute_end, + "function", + class_start - attribute_end, + ) + .is_some() + { + search = attribute_end; + continue; + } + let mut handler_start = class_start + "class".len(); + skip_ascii_whitespace(content.as_bytes(), &mut handler_start); + let mut handler_end = handler_start; + while content + .as_bytes() + .get(handler_end) + .is_some_and(|byte| is_php_identifier_char(*byte)) + { + handler_end += 1; + } + let handler_name = &content[handler_start..handler_end]; + if handler_name.is_empty() { + search = attribute_end; + continue; + } + let handler_fqn = namespace.as_ref().map_or_else( + || handler_name.to_string(), + |namespace| format!("{namespace}\\{handler_name}"), + ); + + let Some(body_open_rel) = content[handler_end..].find('{') else { + search = handler_end; + continue; + }; + let body_open = handler_end + body_open_rel; + let body_end = matching_delimiter(content, body_open, b'{', b'}').unwrap_or(content.len()); + let explicit_message = messenger_attribute_message_type( + content, + attribute_name, + attribute_end, + use_map, + namespace, + ); + let inferred_message = content[body_open + 1..body_end] + .find("__invoke") + .map(|invoke| body_open + 1 + invoke) + .and_then(|invoke| { + let function_start = content[body_open + 1..invoke] + .rfind("function") + .map(|start| body_open + 1 + start)?; + let signature_end = content[function_start..body_end] + .find('{') + .map_or(body_end, |end| function_start + end); + php_first_parameter_type(content, function_start, signature_end, use_map, namespace) + }); + let Some((message_fqn, message_start, message_end)) = explicit_message.or(inferred_message) + else { + search = body_end; + continue; + }; + refs.push(FrameworkReference { + uri: uri.to_string(), + start: message_start as u32, + end: message_end as u32, + kind: FrameworkReferenceKind::MessengerHandler { + message_fqn: message_fqn.clone(), + handler_fqn: handler_fqn.clone(), + role: MessengerHandlerRole::Message, + }, + }); + refs.push(FrameworkReference { + uri: uri.to_string(), + start: handler_start as u32, + end: handler_end as u32, + kind: FrameworkReferenceKind::MessengerHandler { + message_fqn, + handler_fqn, + role: MessengerHandlerRole::Handler, + }, + }); + search = body_end; + } +} + +fn php_keyword_after( + content: &str, + start: usize, + keyword: &str, + max_distance: usize, +) -> Option { + let end = (start + max_distance).min(content.len()); + content[start..end] + .match_indices(keyword) + .find_map(|(relative, _)| { + let absolute = start + relative; + let before = absolute + .checked_sub(1) + .and_then(|idx| content.as_bytes().get(idx)); + let after = content.as_bytes().get(absolute + keyword.len()); + (before.is_none_or(|byte| !is_php_identifier_char(*byte)) + && after.is_none_or(|byte| !is_php_identifier_char(*byte))) + .then_some(absolute) + }) +} + +fn messenger_attribute_message_type( + content: &str, + attribute_start: usize, + attribute_end: usize, + use_map: &HashMap, + namespace: &Option, +) -> Option<(String, usize, usize)> { + let attribute = &content[attribute_start..attribute_end]; + let handles = attribute.find("handles")?; + let class_suffix = attribute[handles..].find("::class")? + handles; + let bytes = attribute.as_bytes(); + let mut name_end = class_suffix; + skip_ascii_whitespace_backwards(bytes, &mut name_end); + let mut name_start = name_end; + while name_start > 0 && is_php_name_char(bytes[name_start - 1]) { + name_start -= 1; + } + let raw = &attribute[name_start..name_end]; + let fqn = normalize_framework_fqn(&crate::util::resolve_to_fqn(raw, use_map, namespace)); + valid_framework_name(&fqn).then_some(( + fqn, + attribute_start + name_start, + attribute_start + name_end, + )) +} + +fn php_first_parameter_type( + content: &str, + function_start: usize, + signature_end: usize, + use_map: &HashMap, + namespace: &Option, +) -> Option<(String, usize, usize)> { + let open = content[function_start..signature_end].find('(')? + function_start; + let parameter_end = content[open + 1..signature_end] + .find([',', ')']) + .map(|end| open + 1 + end)?; + let parameter = &content[open + 1..parameter_end]; + let variable = parameter.find('$')?; + let type_part = parameter[..variable].trim(); + let raw = type_part + .trim_start_matches(['?', '&']) + .split_whitespace() + .last()?; + if raw.contains('|') || raw.contains('&') || raw.is_empty() { + return None; + } + let relative_start = parameter[..variable].find(raw)?; + let start = open + 1 + relative_start; + let end = start + raw.len(); + let fqn = normalize_framework_fqn(&crate::util::resolve_to_fqn(raw, use_map, namespace)); + valid_framework_name(&fqn).then_some((fqn, start, end)) +} + +fn scan_php_form_fields( + uri: &str, + content: &str, + literals: &[PhpStringLiteral<'_>], + use_map: &HashMap, + namespace: &Option, + refs: &mut Vec, +) { + let Some(data_class) = php_form_data_class(content, use_map, namespace) else { + return; + }; + for literal in literals { + let Some(call) = php_call_context(content, literal.quote_start) else { + continue; + }; + if call.argument_index != 0 + || !matches!( + call.name.to_ascii_lowercase().as_str(), + "add" | "get" | "has" | "remove" + ) + { + continue; + } + let name = php_semantic_string(literal.value.trim()); + if !valid_framework_segment(&name) { + continue; + } + refs.push(FrameworkReference { + uri: uri.to_string(), + start: literal.start as u32, + end: literal.end as u32, + kind: FrameworkReferenceKind::Property { + class_fqn: data_class.clone(), + member_name: name, + }, + }); + } +} + +fn php_form_data_class( + content: &str, + use_map: &HashMap, + namespace: &Option, +) -> Option { + let marker = content.find("data_class")?; + let suffix = &content[marker + "data_class".len()..]; + let class_suffix = suffix.find("::class")?; + let bytes = suffix.as_bytes(); + let mut name_end = class_suffix; + skip_ascii_whitespace_backwards(bytes, &mut name_end); + let mut name_start = name_end; + while name_start > 0 && is_php_name_char(bytes[name_start - 1]) { + name_start -= 1; + } + let raw = &suffix[name_start..name_end]; + let fqn = normalize_framework_fqn(&crate::util::resolve_to_fqn(raw, use_map, namespace)); + valid_framework_name(&fqn).then_some(fqn) +} + +fn scan_php_config_schema( + uri: &str, + content: &str, + literals: &[PhpStringLiteral<'_>], + refs: &mut Vec, +) { + if !content.contains("TreeBuilder") { + return; + } + let Some(root_literal) = literals.iter().find(|literal| { + php_call_context(content, literal.quote_start).is_some_and(|call| { + call.argument_index == 0 && call.name.eq_ignore_ascii_case("TreeBuilder") + }) + }) else { + return; + }; + let root = php_semantic_string(root_literal.value.trim()); + if !valid_config_key_segment(&root) { + return; + } + push_config_key( + refs, + uri, + root.clone(), + root_literal.start, + root_literal.end, + true, + ); + + let mut parents: Vec<(usize, String)> = Vec::new(); + for literal in literals { + let Some(call) = php_call_context(content, literal.quote_start) else { + continue; + }; + let call_name = call.name.to_ascii_lowercase(); + if call.argument_index != 0 || !is_config_tree_node_call(&call_name) { + continue; + } + let name = php_semantic_string(literal.value.trim()); + if !valid_config_key_segment(&name) { + continue; + } + let line_start = content[..literal.quote_start] + .rfind('\n') + .map_or(0, |start| start + 1); + let indent = leading_spaces(&content[line_start..literal.quote_start]); + while parents + .last() + .is_some_and(|(parent_indent, _)| *parent_indent >= indent) + { + parents.pop(); + } + let path = std::iter::once(root.as_str()) + .chain(parents.iter().map(|(_, parent)| parent.as_str())) + .chain(std::iter::once(name.as_str())) + .collect::>() + .join("."); + push_config_key(refs, uri, path, literal.start, literal.end, true); + if call_name == "arraynode" { + parents.push((indent, name)); + } + } +} + +fn is_config_tree_node_call(call_name: &str) -> bool { + matches!( + call_name, + "arraynode" + | "booleannode" + | "enumnode" + | "floatnode" + | "integernode" + | "scalarnode" + | "variablenode" + ) +} + +fn php_call_context(content: &str, offset: usize) -> Option> { + let prefix = content.get(..offset)?; + let search_start = offset.saturating_sub(2048); + let open = prefix.as_bytes()[search_start..] + .iter() + .rposition(|byte| *byte == b'(')? + + search_start; + let bytes = content.as_bytes(); + let mut name_end = open; + skip_ascii_whitespace_backwards(bytes, &mut name_end); + let mut name_start = name_end; + while name_start > 0 && is_php_identifier_char(bytes[name_start - 1]) { + name_start -= 1; + } + if name_start == name_end { + return None; + } + + let mut argument_index = 0usize; + let mut paren_depth = 0u32; + let mut bracket_depth = 0u32; + let mut brace_depth = 0u32; + let mut quote = None; + let mut escaped = false; + for byte in bytes[open + 1..offset].iter().copied() { + if escaped { + escaped = false; + continue; + } + if byte == b'\\' && quote.is_some() { + escaped = true; + continue; + } + if matches!(byte, b'\'' | b'"') { + if quote == Some(byte) { + quote = None; + } else if quote.is_none() { + quote = Some(byte); + } + continue; + } + if quote.is_some() { + continue; + } + match 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'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + b',' if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => { + argument_index += 1; + } + _ => {} + } + } + + Some(PhpCallContext { + name: &content[name_start..name_end], + argument_index, + args_start: open + 1, + }) +} + +fn php_named_argument_before(content: &str, args_start: usize, quote_start: usize) -> Option<&str> { + let before = content.get(args_start..quote_start)?; + let segment = before + .rsplit_once(',') + .map_or(before, |(_, tail)| tail) + .trim(); + let colon = segment.rfind(':')?; + let name = segment[..colon].trim(); + (!name.is_empty() && name.bytes().all(is_php_identifier_char)).then_some(name) +} + +fn looks_like_container_call(content: &str, call: PhpCallContext<'_>) -> bool { + let name_offset = call.name.as_ptr() as usize - content.as_ptr() as usize; + let before = content[..name_offset].trim_end(); + let receiver_end = before.strip_suffix("->").map(str::trim_end); + let Some(receiver_end) = receiver_end else { + return false; + }; + let receiver_start = receiver_end + .rfind(|character: char| { + !(character == '$' || character == '_' || character.is_ascii_alphanumeric()) + }) + .map_or(0, |index| index + 1); + let receiver = &receiver_end[receiver_start..]; + matches!( + receiver, + "$container" | "$serviceLocator" | "$locator" | "container" + ) || (!receiver.is_empty() + && [ + format!("ContainerInterface {receiver}"), + format!("ServiceLocator {receiver}"), + format!("ContainerBagInterface {receiver}"), + ] + .iter() + .any(|typed| content.contains(typed))) +} + +fn looks_like_parameter_set(content: &str, call: PhpCallContext<'_>) -> bool { + let name_offset = call.name.as_ptr() as usize - content.as_ptr() as usize; + let start = name_offset.saturating_sub(160); + let prefix = &content[start..name_offset]; + prefix.contains("->parameters()->") + || prefix.trim_end().ends_with("$parameters->") + || prefix.trim_end().ends_with("$params->") +} + +fn looks_like_route_generator_call(content: &str, call: PhpCallContext<'_>) -> bool { + let name_offset = call.name.as_ptr() as usize - content.as_ptr() as usize; + let start = name_offset.saturating_sub(128); + let prefix = &content[start..name_offset]; + prefix.trim_end().ends_with("$router->") + || prefix.trim_end().ends_with("$urlGenerator->") + || content.contains("UrlGeneratorInterface") + || content.contains("RouterInterface") +} + +fn php_semantic_string(raw: &str) -> String { + if raw.contains('\\') { + raw.replace("\\\\", "\\") + } else { + raw.to_string() + } +} + +fn php_translation_domain( + content: &str, + literals: &[PhpStringLiteral<'_>], + target_call: PhpCallContext<'_>, +) -> String { + literals + .iter() + .find_map(|literal| { + let call = php_call_context(content, literal.quote_start)?; + if call.args_start != target_call.args_start { + return None; + } + let named = php_named_argument_before(content, call.args_start, literal.quote_start); + if call.argument_index == 2 + || named.is_some_and(|name| name.eq_ignore_ascii_case("domain")) + { + let domain = php_semantic_string(literal.value.trim()); + valid_translation_domain(&domain).then_some(domain) + } else { + None + } + }) + .unwrap_or_else(|| "messages".to_string()) +} + +fn scan_symfony_php_translation_catalog(uri: &str, content: &str) -> Vec { + let Some(domain) = translation_catalog_domain(uri) else { + return Vec::new(); + }; + let mut refs = Vec::new(); + let mut ignored = Vec::new(); + let literals = scan_php_string_literals_and_class_constants( + uri, + content, + &HashMap::new(), + &None, + false, + &mut ignored, + ); + let containers = literals + .iter() + .filter_map(|literal| { + if !php_literal_is_array_key(content, literal) { + return None; + } + let name = php_semantic_string(literal.value.trim()); + let (start, end) = php_array_value_range(content, literal)?; + Some((literal.quote_start, start, end, name)) + }) + .collect::>(); + for literal in &literals { + if !php_literal_is_array_key(content, literal) { + continue; + } + if containers + .iter() + .any(|(key_start, _, _, _)| *key_start == literal.quote_start) + { + continue; + } + let leaf = php_semantic_string(literal.value.trim()); + let name = containers + .iter() + .filter(|(_, start, end, _)| *start < literal.quote_start && literal.quote_end < *end) + .map(|(_, _, _, parent)| parent.as_str()) + .chain(std::iter::once(leaf.as_str())) + .collect::>() + .join("."); + if valid_translation_key(&name) { + push_translation( + &mut refs, + uri, + domain.clone(), + name, + literal.start, + literal.end, + true, + ); + } + } + refs +} + +fn php_array_value_range(content: &str, literal: &PhpStringLiteral<'_>) -> Option<(usize, usize)> { + let bytes = content.as_bytes(); + let mut cursor = literal.quote_end + 1; + skip_ascii_whitespace(bytes, &mut cursor); + if bytes.get(cursor..cursor + 2) != Some(b"=>") { + return None; + } + cursor += 2; + skip_ascii_whitespace(bytes, &mut cursor); + let (open, close) = if bytes.get(cursor) == Some(&b'[') { + (b'[', b']') + } else if content + .get(cursor..cursor + 5) + .is_some_and(|value| value.eq_ignore_ascii_case("array")) + { + cursor += 5; + skip_ascii_whitespace(bytes, &mut cursor); + if bytes.get(cursor) != Some(&b'(') { + return None; + } + (b'(', b')') + } else { + return None; + }; + matching_delimiter(content, cursor, open, close).map(|end| (cursor, end)) +} + +fn matching_delimiter(content: &str, start: usize, open: u8, close: u8) -> Option { + let bytes = content.as_bytes(); + let mut cursor = start; + let mut depth = 0u32; + let mut quote = None; + while cursor < bytes.len() { + let byte = bytes[cursor]; + if let Some(active_quote) = quote { + if byte == b'\\' { + cursor = (cursor + 2).min(bytes.len()); + continue; + } + if byte == active_quote { + quote = None; + } + } else if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + } else if byte == open { + depth += 1; + } else if byte == close { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(cursor); + } + } + cursor += 1; + } + None +} + +fn scan_php_route_parameters( + uri: &str, + content: &str, + literals: &[PhpStringLiteral<'_>], + refs: &mut Vec, +) { + for literal in literals { + let Some(call) = php_call_context(content, literal.quote_start) else { + continue; + }; + let call_name = call.name.to_ascii_lowercase(); + let named_argument = + php_named_argument_before(content, call.args_start, literal.quote_start); + let route_attribute = call_name == "route" + || (call_name.ends_with("route") + && (content.contains("Routing\\Attribute\\Route") + || content.contains("Routing\\Annotation\\Route"))); + let is_path = (call_name == "add" && call.argument_index == 1) + || (route_attribute + && (call.argument_index == 0 + || named_argument.is_some_and(|name| name.eq_ignore_ascii_case("path")))); + + if is_path && literal.value.contains('{') { + let route_name = refs.iter().find_map(|reference| { + let FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Route, + name, + declaration: true, + } = &reference.kind + else { + return None; + }; + let declaration_call = php_call_context(content, reference.start as usize)?; + (declaration_call.args_start == call.args_start).then(|| name.clone()) + }); + if let Some(route_name) = route_name { + scan_route_path_parameters(uri, &route_name, literal.value, literal.start, refs); + } + } + + if call.argument_index == 0 || !php_literal_is_array_key(content, literal) { + continue; + } + let route_name = refs.iter().find_map(|reference| { + let FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Route, + name, + declaration: false, + } = &reference.kind + else { + return None; + }; + let route_call = php_call_context(content, reference.start as usize)?; + (route_call.args_start == call.args_start).then(|| name.clone()) + }); + let parameter_name = php_semantic_string(literal.value.trim()); + if let Some(route_name) = route_name + && valid_symfony_symbol_name(¶meter_name) + { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: literal.start as u32, + end: literal.end as u32, + kind: FrameworkReferenceKind::RouteParameter { + route_name: route_name.to_string(), + name: parameter_name, + declaration: false, + }, + }); + } + } +} + +fn php_literal_is_array_key(content: &str, literal: &PhpStringLiteral<'_>) -> bool { + content + .get(literal.quote_end + 1..) + .is_some_and(|suffix| suffix.trim_start().starts_with("=>")) +} + +fn scan_php_config_literal( + uri: &str, + literal: &PhpStringLiteral<'_>, + refs: &mut Vec, +) { + let leading_whitespace = literal.value.len() - literal.value.trim_start().len(); + let trimmed = literal.value.trim(); + if trimmed.is_empty() { + return; + } + + let service_prefix = trimmed + .bytes() + .take_while(|byte| matches!(byte, b'@' | b'?')) + .count(); + let source = &trimmed[service_prefix..]; + if source.is_empty() { + return; + } + let start = literal.start + leading_whitespace + service_prefix; + + if let Some(separator) = source.find("::") { + let class_source = &source[..separator]; + let method_name = &source[separator + 2..]; + let class_fqn = normalize_framework_fqn(class_source); + if valid_framework_name(&class_fqn) && valid_framework_segment(method_name) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: (start + class_source.len()) as u32, + kind: FrameworkReferenceKind::Class { + fqn: class_fqn.clone(), + }, + }); + refs.push(FrameworkReference { + uri: uri.to_string(), + start: (start + separator + 2) as u32, + end: (start + source.len()) as u32, + kind: FrameworkReferenceKind::Method { + class_fqn, + member_name: method_name.to_string(), + }, + }); + } + return; + } + + let normalized = normalize_framework_fqn(source); + if !source.contains('\\') || !valid_framework_name(&normalized) { + return; + } + + let kind = if source.ends_with('\\') { + FrameworkReferenceKind::Namespace { prefix: normalized } + } else { + FrameworkReferenceKind::Class { fqn: normalized } + }; + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: (start + source.len()) as u32, + kind, + }); +} + +fn php_callable_class_before( + content: &str, + quote_start: usize, + use_map: &HashMap, + namespace: &Option, +) -> Option { + let bytes = content.as_bytes(); + let mut cursor = quote_start; + skip_ascii_whitespace_backwards(bytes, &mut cursor); + if cursor == 0 || bytes[cursor - 1] != b',' { + return None; + } + cursor -= 1; + skip_ascii_whitespace_backwards(bytes, &mut cursor); + let keyword_start = cursor.checked_sub(5)?; + if !content[keyword_start..cursor].eq_ignore_ascii_case("class") { + return None; + } + cursor = keyword_start; + skip_ascii_whitespace_backwards(bytes, &mut cursor); + if cursor < 2 || &bytes[cursor - 2..cursor] != b"::" { + return None; + } + cursor -= 2; + skip_ascii_whitespace_backwards(bytes, &mut cursor); + let end = cursor; + while cursor > 0 && is_php_name_char(bytes[cursor - 1]) { + cursor -= 1; + } + if cursor == end { + return None; + } + let raw_name = &content[cursor..end]; + let fqn = normalize_framework_fqn(&crate::util::resolve_to_fqn(raw_name, use_map, namespace)); + valid_framework_name(&fqn).then_some(fqn) +} + +fn php_callable_string_class_before( + content: &str, + literals: &[PhpStringLiteral<'_>], + current_idx: usize, +) -> Option { + let previous = literals.get(current_idx.checked_sub(1)?)?; + let current = literals.get(current_idx)?; + if content[previous.quote_end + 1..current.quote_start].trim() != "," { + return None; + } + if !content[..previous.quote_start].trim_end().ends_with('[') { + return None; + } + let class_fqn = normalize_framework_fqn(previous.value.trim()); + valid_framework_name(&class_fqn).then_some(class_fqn) +} + +fn php_literal_has_path_context( + content: &str, + literals: &[PhpStringLiteral<'_>], + current_idx: usize, +) -> bool { + let current = &literals[current_idx]; + let prefix = &content[..current.quote_start]; + if let Some(open_paren) = prefix.rfind('(') { + let mut name_end = open_paren; + skip_ascii_whitespace_backwards(content.as_bytes(), &mut name_end); + let mut name_start = name_end; + while name_start > 0 && is_php_identifier_char(content.as_bytes()[name_start - 1]) { + name_start -= 1; + } + let call_name = &content[name_start..name_end]; + let argument_index = content[open_paren + 1..current.quote_start] + .bytes() + .filter(|byte| *byte == b',') + .count(); + if (call_name == "import" && argument_index == 0) + || (call_name == "load" && argument_index == 1) + { + return true; + } + } + + for previous in literals[..current_idx].iter().rev() { + if current.quote_start.saturating_sub(previous.quote_end) > 512 { + break; + } + if !matches!( + previous.value.trim(), + "resource" | "exclude" | "path" | "paths" | "dir" | "directory" + ) { + continue; + } + let between = content[previous.quote_end + 1..current.quote_start].trim(); + let Some(after_arrow) = between.strip_prefix("=>") else { + continue; + }; + let after_arrow = after_arrow.trim(); + if after_arrow.is_empty() { + return true; + } + if after_arrow.starts_with('[') + && after_arrow.bytes().filter(|byte| *byte == b'[').count() + > after_arrow.bytes().filter(|byte| *byte == b']').count() + { + return true; + } + } + + false +} + +fn is_php_name_start(byte: u8) -> bool { + byte == b'\\' || byte == b'_' || byte.is_ascii_alphabetic() +} + +fn is_php_name_char(byte: u8) -> bool { + byte == b'\\' || is_php_identifier_char(byte) +} + +fn is_php_identifier_char(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() +} + +fn skip_ascii_whitespace(bytes: &[u8], cursor: &mut usize) { + while bytes.get(*cursor).is_some_and(u8::is_ascii_whitespace) { + *cursor += 1; + } +} + +fn skip_ascii_whitespace_backwards(bytes: &[u8], cursor: &mut usize) { + while *cursor > 0 && bytes[*cursor - 1].is_ascii_whitespace() { + *cursor -= 1; + } +} + +fn scan_framework_references(uri: &str, content: &str) -> Vec { + let mut refs = Vec::new(); + if uri + .split('?') + .next() + .is_some_and(|path| path.ends_with(".twig")) + { + scan_twig_route_references(uri, content, &mut refs); + scan_twig_template_references(uri, content, &mut refs); + scan_twig_translation_references(uri, content, &mut refs); + refs.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + refs.dedup(); + return refs; + } + + scan_class_like_tokens(uri, content, &mut refs); + scan_path_scalars(uri, content, &mut refs); + if let Some(domain) = translation_catalog_domain(uri) { + if uri + .split('?') + .next() + .is_some_and(|path| path.ends_with(".yaml") || path.ends_with(".yml")) + { + scan_symfony_yaml_translation_catalog(uri, content, &domain, &mut refs); + } else { + scan_symfony_xliff_translation_catalog(uri, content, &domain, &mut refs); + } + } + if uri + .split('?') + .next() + .is_some_and(|path| path.ends_with(".yaml") || path.ends_with(".yml")) + { + scan_symfony_yaml_container_symbols(uri, content, &mut refs); + scan_symfony_yaml_routes(uri, content, &mut refs); + scan_symfony_yaml_events_and_buses(uri, content, &mut refs); + scan_symfony_validation_yaml(uri, content, &mut refs); + scan_yaml_config_key_references(uri, content, &mut refs); + } else if uri + .split('?') + .next() + .is_some_and(|path| path.ends_with(".xml")) + { + scan_symfony_xml_container_symbols(uri, content, &mut refs); + scan_symfony_xml_routes(uri, content, &mut refs); + scan_symfony_xml_events_and_buses(uri, content, &mut refs); + scan_symfony_validation_xml(uri, content, &mut refs); + } + refs.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + refs.dedup(); + refs +} + +fn translation_catalog_domain(uri: &str) -> Option { + let url = Url::parse(uri).ok()?; + let path = url.to_file_path().ok()?; + if !path + .components() + .any(|component| matches!(component, Component::Normal(name) if name == "translations")) + { + return None; + } + let filename = path.file_name()?.to_str()?; + let mut parts = filename.split('.').collect::>(); + if parts.len() < 3 { + return None; + } + parts.pop(); + parts.pop(); + let domain = parts.join("."); + let domain = domain.strip_suffix("+intl-icu").unwrap_or(&domain); + valid_translation_domain(domain).then(|| domain.to_string()) +} + +fn scan_symfony_yaml_translation_catalog( + uri: &str, + content: &str, + domain: &str, + refs: &mut Vec, +) { + let mut parents: Vec<(usize, String)> = Vec::new(); + for (line_start, line) in line_offsets(content) { + let semantic = yaml_content_before_comment(line); + if semantic.trim().is_empty() || semantic.trim_start().starts_with('-') { + continue; + } + let Some((raw_key, key_start, key_end, value_start)) = + yaml_mapping_entry(semantic, line_start) + else { + continue; + }; + let indent = leading_spaces(semantic); + while parents + .last() + .is_some_and(|(parent_indent, _)| *parent_indent >= indent) + { + parents.pop(); + } + let (key, quote_adjust) = strip_yaml_quotes(raw_key); + if !valid_translation_key(key) { + continue; + } + let name = parents + .iter() + .map(|(_, parent)| parent.as_str()) + .chain(std::iter::once(key)) + .collect::>() + .join("."); + let value = semantic.get(value_start..).unwrap_or_default().trim(); + if value.is_empty() { + parents.push((indent, key.to_string())); + } else { + push_translation( + refs, + uri, + domain.to_string(), + name, + key_start + quote_adjust.0, + key_end.saturating_sub(quote_adjust.1), + true, + ); + } + } +} + +fn scan_symfony_xliff_translation_catalog( + uri: &str, + content: &str, + domain: &str, + refs: &mut Vec, +) { + let lower = content.to_ascii_lowercase(); + let mut search = 0usize; + while let Some(rel_start) = lower[search..].find('<') { + let tag_start = search + rel_start; + let Some(rel_end) = content[tag_start..].find('>') else { + break; + }; + let tag_end = tag_start + rel_end + 1; + let tag = &content[tag_start..tag_end]; + let tag_lower = tag.to_ascii_lowercase(); + let unit_tag = tag_lower.starts_with(" Option<(String, usize, usize)> { + let lower = content.to_ascii_lowercase(); + let unit_end_rel = lower[unit_tag_end..] + .find("") + .or_else(|| lower[unit_tag_end..].find(""))?; + let unit_end = unit_tag_end + unit_end_rel; + let source_tag_rel = lower[unit_tag_end..unit_end].find("')? + source_tag_start + 1; + let source_end = lower[source_start..unit_end].find("")? + source_start; + let value = content[source_start..source_end].trim(); + let leading = content[source_start..source_end].len() + - content[source_start..source_end].trim_start().len(); + Some(( + value.to_string(), + source_start + leading, + source_start + leading + value.len(), + )) +} + +fn scan_twig_translation_references(uri: &str, content: &str, refs: &mut Vec) { + let default_domain = + twig_default_translation_domain(content).unwrap_or_else(|| "messages".to_string()); + let bytes = content.as_bytes(); + let mut cursor = 0usize; + while cursor < bytes.len() { + if !matches!(bytes[cursor], b'\'' | b'"') { + cursor += 1; + continue; + } + let quote = bytes[cursor]; + let start = cursor + 1; + let mut end = start; + while end < bytes.len() { + if bytes[end] == b'\\' { + end = (end + 2).min(bytes.len()); + continue; + } + if bytes[end] == quote { + break; + } + end += 1; + } + if end >= bytes.len() { + break; + } + let mut pipe = end + 1; + skip_ascii_whitespace(bytes, &mut pipe); + if bytes.get(pipe) != Some(&b'|') { + cursor = end + 1; + continue; + } + pipe += 1; + skip_ascii_whitespace(bytes, &mut pipe); + let filter_start = pipe; + while bytes + .get(pipe) + .is_some_and(|byte| is_php_identifier_char(*byte)) + { + pipe += 1; + } + if !content[filter_start..pipe].eq_ignore_ascii_case("trans") { + cursor = end + 1; + continue; + } + let name = &content[start..end]; + if valid_translation_key(name) { + let domain = twig_translation_filter_domain(content, pipe) + .unwrap_or_else(|| default_domain.clone()); + push_translation(refs, uri, domain, name.to_string(), start, end, false); + } + cursor = end + 1; + } +} + +fn twig_default_translation_domain(content: &str) -> Option { + let lower = content.to_ascii_lowercase(); + let start = lower.find("trans_default_domain")? + "trans_default_domain".len(); + let tag_end = lower[start..].find("%}").map(|end| start + end)?; + let (domain, _, _) = first_quoted_value(content, start, tag_end)?; + valid_translation_domain(domain).then(|| domain.to_string()) +} + +fn twig_translation_filter_domain(content: &str, filter_end: usize) -> Option { + let bytes = content.as_bytes(); + let mut cursor = filter_end; + skip_ascii_whitespace(bytes, &mut cursor); + if bytes.get(cursor) != Some(&b'(') { + return None; + } + let args_start = cursor + 1; + let mut depth = 0u32; + let mut argument = 0usize; + let mut quote = None; + cursor = args_start; + while cursor < bytes.len() { + let byte = bytes[cursor]; + if let Some(active_quote) = quote { + if byte == b'\\' { + cursor = (cursor + 2).min(bytes.len()); + continue; + } + if byte == active_quote { + quote = None; + } + cursor += 1; + continue; + } + if matches!(byte, b'\'' | b'"') { + if argument == 1 + || content[args_start..cursor] + .rsplit_once(',') + .map_or(&content[args_start..cursor], |(_, tail)| tail) + .trim_start() + .starts_with("domain") + { + let (domain, _, _) = first_quoted_value(content, cursor, bytes.len())?; + return valid_translation_domain(domain).then(|| domain.to_string()); + } + quote = Some(byte); + } else { + match byte { + b'(' | b'[' | b'{' => depth += 1, + b')' if depth == 0 => break, + b')' | b']' | b'}' => depth = depth.saturating_sub(1), + b',' if depth == 0 => argument += 1, + _ => {} + } + } + cursor += 1; + } + None +} + +fn is_twig_uri(uri: &str) -> bool { + uri.split('?') + .next() + .is_some_and(|path| path.to_ascii_lowercase().ends_with(".twig")) +} + +fn scan_twig_template_references(uri: &str, content: &str, refs: &mut Vec) { + scan_twig_template_calls(uri, content, refs); + + let bytes = content.as_bytes(); + let lower = content.to_ascii_lowercase(); + let mut cursor = 0usize; + while let Some(tag_rel) = lower[cursor..].find("{%") { + let tag_start = cursor + tag_rel + 2; + let Some(tag_end_rel) = lower[tag_start..].find("%}") else { + break; + }; + let tag_end = tag_start + tag_end_rel; + let mut keyword_start = tag_start; + skip_ascii_whitespace(bytes, &mut keyword_start); + let mut keyword_end = keyword_start; + while bytes + .get(keyword_end) + .is_some_and(|byte| byte.is_ascii_alphabetic()) + { + keyword_end += 1; + } + let keyword = &lower[keyword_start..keyword_end]; + if matches!( + keyword, + "extends" | "include" | "embed" | "use" | "import" | "from" + ) && let Some((name, start, end)) = first_quoted_value(content, keyword_end, tag_end) + && valid_template_name(name) + { + push_symfony_symbol( + refs, + uri, + SymfonySymbolKind::Template, + name.to_string(), + start, + end, + false, + ); + } + cursor = tag_end + 2; + } +} + +fn scan_twig_template_calls(uri: &str, content: &str, refs: &mut Vec) { + let bytes = content.as_bytes(); + let lower = content.to_ascii_lowercase(); + let mut cursor = 0usize; + while cursor < bytes.len() { + let Some(name) = ["include", "source"].iter().find(|name| { + let name = name.as_bytes(); + lower.as_bytes().get(cursor..cursor + name.len()) == Some(name) + && (cursor == 0 || !is_php_identifier_char(bytes[cursor - 1])) + && bytes + .get(cursor + name.len()) + .is_none_or(|byte| !is_php_identifier_char(*byte)) + }) else { + cursor += 1; + continue; + }; + let mut open = cursor + name.len(); + skip_ascii_whitespace(bytes, &mut open); + if bytes.get(open) != Some(&b'(') { + cursor += name.len(); + continue; + } + if let Some((template, start, end)) = first_quoted_value(content, open + 1, content.len()) + && valid_template_name(template) + { + push_symfony_symbol( + refs, + uri, + SymfonySymbolKind::Template, + template.to_string(), + start, + end, + false, + ); + cursor = end.saturating_add(1); + } else { + cursor += name.len(); + } + } +} + +fn first_quoted_value(content: &str, start: usize, end: usize) -> Option<(&str, usize, usize)> { + let bytes = content.as_bytes(); + let mut quote_start = start; + while quote_start < end && !matches!(bytes[quote_start], b'\'' | b'"') { + quote_start += 1; + } + let quote = *bytes.get(quote_start)?; + let value_start = quote_start + 1; + let mut value_end = value_start; + while value_end < end { + if bytes[value_end] == b'\\' { + value_end = (value_end + 2).min(end); + continue; + } + if bytes[value_end] == quote { + return Some((&content[value_start..value_end], value_start, value_end)); + } + value_end += 1; + } + None +} + +fn twig_template_names(root: &Path, path: &Path) -> Vec { + let Ok(relative) = path.strip_prefix(root) else { + return Vec::new(); + }; + let mut names = Vec::new(); + + if let Ok(template_path) = relative.strip_prefix("templates") { + if let Some(name) = normalized_template_path(template_path) { + names.push(name); + } + if let Ok(bundle_path) = template_path.strip_prefix("bundles") { + let mut components = bundle_path.components(); + if let (Some(Component::Normal(bundle)), Some(rest)) = ( + components.next(), + normalized_template_path(components.as_path()), + ) { + let bundle = bundle.to_string_lossy(); + let namespace = bundle.strip_suffix("Bundle").unwrap_or(&bundle); + names.push(format!("@{namespace}/{rest}")); + } + } + } + + let components = relative.components().collect::>(); + if let Some(template_idx) = components + .iter() + .position(|component| matches!(component, Component::Normal(name) if *name == "templates")) + && template_idx > 0 + && let Component::Normal(bundle) = components[template_idx - 1] + && let Some(bundle) = bundle.to_string_lossy().strip_suffix("Bundle") + { + let rest = components[template_idx + 1..].iter().collect::(); + if let Some(rest) = normalized_template_path(&rest) { + names.push(format!("@{bundle}/{rest}")); + } + } + + names.sort_unstable(); + names.dedup(); + names +} + +fn normalized_template_path(path: &Path) -> Option { + let value = path.to_string_lossy().replace('\\', "/"); + (!value.is_empty() && value.to_ascii_lowercase().ends_with(".twig")).then_some(value) +} + +fn valid_template_name(name: &str) -> bool { + !name.is_empty() + && name.to_ascii_lowercase().ends_with(".twig") + && !name.bytes().any(|byte| byte.is_ascii_whitespace()) +} + +pub(crate) fn is_safe_project_template_name(name: &str) -> bool { + valid_template_name(name) + && !name.starts_with(['@', '/', '\\']) + && !Path::new(name) + .components() + .any(|component| !matches!(component, Component::Normal(_))) +} + +fn scan_twig_route_references(uri: &str, content: &str, refs: &mut Vec) { + scan_string_call_symbols( + uri, + content, + &["path", "url"], + SymfonySymbolKind::Route, + false, + refs, + ); + scan_twig_route_parameters(uri, content, refs); +} + +fn scan_twig_route_parameters(uri: &str, content: &str, refs: &mut Vec) { + let route_refs = refs + .iter() + .filter_map(|reference| { + let FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Route, + name, + declaration: false, + } = &reference.kind + else { + return None; + }; + Some((name.clone(), reference.end as usize)) + }) + .collect::>(); + let bytes = content.as_bytes(); + for (route_name, route_end) in route_refs { + let Some(call_end_rel) = content[route_end..].find(')') else { + continue; + }; + let call_end = route_end + call_end_rel; + let Some(object_start_rel) = content[route_end..call_end].find('{') else { + continue; + }; + let mut cursor = route_end + object_start_rel + 1; + while cursor < call_end { + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b',') + { + cursor += 1; + } + if cursor >= call_end || bytes[cursor] == b'}' { + break; + } + + let (start, end) = if matches!(bytes[cursor], b'\'' | b'"') { + let quote = bytes[cursor]; + let start = cursor + 1; + let mut end = start; + while end < call_end && bytes[end] != quote { + end += 1; + } + cursor = end.saturating_add(1); + (start, end) + } else { + let start = cursor; + while cursor < call_end && is_php_identifier_char(bytes[cursor]) { + cursor += 1; + } + (start, cursor) + }; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + if bytes.get(cursor) != Some(&b':') { + cursor += 1; + continue; + } + let name = &content[start..end]; + if !name.is_empty() { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::RouteParameter { + route_name: route_name.clone(), + name: name.to_string(), + declaration: false, + }, + }); + } + cursor += 1; + while cursor < call_end && !matches!(bytes[cursor], b',' | b'}') { + cursor += 1; + } + } + } +} + +fn scan_string_call_symbols( + uri: &str, + content: &str, + call_names: &[&str], + kind: SymfonySymbolKind, + declaration: bool, + refs: &mut Vec, +) { + let bytes = content.as_bytes(); + let mut cursor = 0usize; + while cursor < bytes.len() { + let Some(name) = call_names.iter().find(|name| { + let name = name.as_bytes(); + bytes.get(cursor..cursor + name.len()) == Some(name) + && (cursor == 0 || !is_php_identifier_char(bytes[cursor - 1])) + && bytes + .get(cursor + name.len()) + .is_none_or(|byte| !is_php_identifier_char(*byte)) + }) else { + cursor += 1; + continue; + }; + let mut open = cursor + name.len(); + skip_ascii_whitespace(bytes, &mut open); + if bytes.get(open) != Some(&b'(') { + cursor += name.len(); + continue; + } + open += 1; + skip_ascii_whitespace(bytes, &mut open); + let Some(quote @ (b'\'' | b'"')) = bytes.get(open).copied() else { + cursor += name.len(); + continue; + }; + let start = open + 1; + let mut end = start; + while end < bytes.len() { + if bytes[end] == b'\\' { + end = (end + 2).min(bytes.len()); + continue; + } + if bytes[end] == quote { + break; + } + end += 1; + } + let value = &content[start..end]; + if valid_symfony_symbol_name(value) { + push_symfony_symbol(refs, uri, kind, value.to_string(), start, end, declaration); + } + cursor = end.saturating_add(1); + } +} + +fn scan_symfony_yaml_events_and_buses( + uri: &str, + content: &str, + refs: &mut Vec, +) { + let lines = line_offsets(content); + for (idx, (line_start, line)) in lines.iter().enumerate() { + if let Some((event, start, end)) = yaml_named_field_value(line, *line_start, "event") { + let window_start = idx.saturating_sub(4); + let window_end = (idx + 5).min(lines.len()); + if lines[window_start..window_end] + .iter() + .any(|(_, candidate)| candidate.contains("kernel.event_listener")) + && valid_symfony_symbol_name(&event) + { + push_symfony_symbol(refs, uri, SymfonySymbolKind::Event, event, start, end, true); + } + } + } + + let mut buses_indent = None; + let mut bus_child_indent = None; + for (line_start, line) in lines { + let semantic = yaml_content_before_comment(line); + let trimmed = semantic.trim(); + let indent = leading_spaces(semantic); + if matches!(trimmed, "buses:" | "'buses':" | "\"buses\":") { + buses_indent = Some(indent); + bus_child_indent = None; + continue; + } + let Some(parent_indent) = buses_indent else { + continue; + }; + if trimmed.is_empty() { + continue; + } + if indent <= parent_indent { + buses_indent = None; + continue; + } + if bus_child_indent.is_none() { + bus_child_indent = Some(indent); + } + if bus_child_indent != Some(indent) { + continue; + } + let Some((raw_key, start, end, _)) = yaml_mapping_entry(semantic, line_start) else { + continue; + }; + let (name, quote_adjust) = strip_yaml_quotes(raw_key); + if valid_symfony_symbol_name(name) { + push_symfony_symbol( + refs, + uri, + SymfonySymbolKind::MessengerBus, + name.to_string(), + start + quote_adjust.0, + end.saturating_sub(quote_adjust.1), + true, + ); + } + } +} + +fn yaml_named_field_value( + line: &str, + line_start: usize, + field: &str, +) -> Option<(String, usize, usize)> { + let bytes = line.as_bytes(); + let mut search = 0usize; + while let Some(rel) = line[search..].find(field) { + let field_start = search + rel; + let field_end = field_start + field.len(); + if field_start > 0 && is_php_identifier_char(bytes[field_start - 1]) { + search = field_end; + continue; + } + let mut colon = field_end; + skip_ascii_whitespace(bytes, &mut colon); + if bytes.get(colon) != Some(&b':') { + search = field_end; + continue; + } + colon += 1; + skip_ascii_whitespace(bytes, &mut colon); + let raw = &line[colon..]; + let raw = raw + .split([',', '}', '#']) + .next() + .unwrap_or_default() + .trim_end(); + let (value, adjustment) = strip_yaml_quotes(raw); + if value.is_empty() { + return None; + } + return Some(( + value.to_string(), + line_start + colon + adjustment.0, + line_start + colon + raw.len().saturating_sub(adjustment.1), + )); + } + None +} + +fn scan_symfony_xml_events_and_buses(uri: &str, content: &str, refs: &mut Vec) { + let lower = content.to_ascii_lowercase(); + let mut search = 0usize; + 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]; + if xml_attr_value(tag, tag_start, &["name"]) + .is_some_and(|(name, _, _)| name == "kernel.event_listener") + && let Some((event, start, end)) = xml_attr_value(tag, tag_start, &["event"]) + && valid_symfony_symbol_name(&event) + { + push_symfony_symbol(refs, uri, SymfonySymbolKind::Event, event, start, end, true); + } + search = tag_end; + } + + if !lower.contains("messenger") && !lower.contains("') else { + break; + }; + let tag_end = tag_start + rel_end + 1; + let tag = &content[tag_start..tag_end]; + if let Some((name, start, end)) = xml_attr_value(tag, tag_start, &["name", "id"]) + && valid_symfony_symbol_name(&name) + { + push_symfony_symbol( + refs, + uri, + SymfonySymbolKind::MessengerBus, + name, + start, + end, + true, + ); + } + search = tag_end; + } +} + +fn scan_symfony_validation_yaml(uri: &str, content: &str, refs: &mut Vec) { + if !uri.to_ascii_lowercase().contains("validat") + && !content.lines().any(|line| line.trim() == "properties:") + { + return; + } + let mut class: Option<(String, usize)> = None; + let mut properties_indent = None; + let mut property_indent = None; + for (line_start, line) in line_offsets(content) { + let semantic = yaml_content_before_comment(line); + let trimmed = semantic.trim(); + if trimmed.is_empty() { + continue; + } + let indent = leading_spaces(semantic); + if let Some((raw_key, key_start, key_end, _)) = yaml_mapping_entry(semantic, line_start) { + let (key, quote_adjust) = strip_yaml_quotes(raw_key); + let normalized = normalize_framework_fqn(key); + if normalized.contains('\\') && valid_framework_name(&normalized) { + class = Some((normalized, indent)); + properties_indent = None; + property_indent = None; + continue; + } + let Some((class_fqn, class_indent)) = &class else { + continue; + }; + if indent <= *class_indent { + class = None; + properties_indent = None; + property_indent = None; + continue; + } + if key == "properties" { + properties_indent = Some(indent); + property_indent = None; + continue; + } + if let Some(parent_indent) = properties_indent { + if indent <= parent_indent { + properties_indent = None; + property_indent = None; + } else { + if property_indent.is_none() { + property_indent = Some(indent); + } + if property_indent == Some(indent) && valid_framework_segment(key) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: (key_start + quote_adjust.0) as u32, + end: key_end.saturating_sub(quote_adjust.1) as u32, + kind: FrameworkReferenceKind::Property { + class_fqn: class_fqn.clone(), + member_name: key.to_string(), + }, + }); + } + } + } + } + + if let Some((constraint, start, end)) = yaml_constraint_name(semantic, line_start) { + let fqn = if constraint.contains('\\') { + normalize_framework_fqn(&constraint) + } else { + format!("Symfony\\Component\\Validator\\Constraints\\{constraint}") + }; + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::Class { fqn }, + }); + } + } +} + +fn yaml_constraint_name(line: &str, line_start: usize) -> Option<(String, usize, usize)> { + let trimmed_start = line.len() - line.trim_start().len(); + let trimmed = line.trim_start(); + let candidate = trimmed.strip_prefix("- ")?.trim_start(); + let adjustment = trimmed.len() - candidate.len(); + let raw = candidate + .split_once(':') + .map_or(candidate, |(name, _)| name) + .trim(); + let (name, quote_adjust) = strip_yaml_quotes(raw); + if name.is_empty() + || !name + .bytes() + .all(|byte| is_php_name_char(byte) || byte == b'-') + { + return None; + } + let start = line_start + trimmed_start + adjustment + quote_adjust.0; + Some((name.to_string(), start, start + name.len())) +} + +fn scan_symfony_validation_xml(uri: &str, content: &str, refs: &mut Vec) { + if !uri.to_ascii_lowercase().contains("validat") + && !content.to_ascii_lowercase().contains("') else { + break; + }; + let class_tag_end = class_start + class_tag_end_rel + 1; + let class_tag = &content[class_start..class_tag_end]; + let Some((class_fqn, _, _)) = xml_attr_value(class_tag, class_start, &["name", "class"]) + else { + search = class_tag_end; + continue; + }; + let class_fqn = normalize_framework_fqn(&class_fqn); + let class_end = lower[class_tag_end..] + .find("") + .map_or(content.len(), |end| class_tag_end + end); + let mut child_search = class_tag_end; + while let Some(tag_rel) = lower[child_search..class_end].find('<') { + let tag_start = child_search + tag_rel; + let Some(tag_end_rel) = content[tag_start..class_end].find('>') else { + break; + }; + let tag_end = tag_start + tag_end_rel + 1; + let tag = &content[tag_start..tag_end]; + let tag_lower = tag.to_ascii_lowercase(); + if tag_lower.starts_with("".len()); + } +} + +fn scan_yaml_config_key_references(uri: &str, content: &str, refs: &mut Vec) { + if translation_catalog_domain(uri).is_some() { + return; + } + let mut parents: Vec<(usize, String)> = Vec::new(); + for (line_start, line) in line_offsets(content) { + let semantic = yaml_content_before_comment(line); + if semantic.trim().is_empty() || semantic.trim_start().starts_with('-') { + continue; + } + let Some((raw_key, key_start, key_end, value_start)) = + yaml_mapping_entry(semantic, line_start) + else { + continue; + }; + let indent = leading_spaces(semantic); + while parents + .last() + .is_some_and(|(parent_indent, _)| *parent_indent >= indent) + { + parents.pop(); + } + let (key, quote_adjust) = strip_yaml_quotes(raw_key); + if !valid_config_key_segment(key) { + continue; + } + let path = parents + .iter() + .map(|(_, parent)| parent.as_str()) + .chain(std::iter::once(key)) + .collect::>() + .join("."); + push_config_key( + refs, + uri, + path, + key_start + quote_adjust.0, + key_end.saturating_sub(quote_adjust.1), + false, + ); + if semantic + .get(value_start..) + .unwrap_or_default() + .trim() + .is_empty() + { + parents.push((indent, key.to_string())); + } + } +} + +fn scan_symfony_yaml_routes(uri: &str, content: &str, refs: &mut Vec) { + if !uri.to_ascii_lowercase().contains("route") && !content.contains("controller:") { + return; + } + let lines = line_offsets(content); + for (idx, (line_start, line)) in lines.iter().enumerate() { + let semantic = yaml_content_before_comment(line); + let Some((raw_key, key_start, key_end, value_start)) = + yaml_mapping_entry(semantic, *line_start) + else { + continue; + }; + let indent = leading_spaces(semantic); + let (key, quote_adjust) = strip_yaml_quotes(raw_key); + if key.starts_with('_') + || matches!( + key, + "path" + | "controller" + | "methods" + | "defaults" + | "requirements" + | "options" + | "host" + | "schemes" + | "condition" + | "resource" + | "type" + | "prefix" + | "name_prefix" + ) + || !valid_symfony_symbol_name(key) + { + continue; + } + + let inline = semantic + .get(value_start..) + .is_some_and(|value| value.contains("path:") || value.contains("\"path\"")); + let mut has_path = inline; + let mut route_path = None; + if !has_path { + for (child_start, child_line) in lines.iter().skip(idx + 1) { + let child_semantic = yaml_content_before_comment(child_line); + let child_trimmed = child_semantic.trim(); + if child_trimmed.is_empty() { + continue; + } + if leading_spaces(child_semantic) <= indent { + break; + } + let child_key = child_trimmed + .split_once(':') + .map(|(candidate, _)| candidate.trim().trim_matches(['\'', '"'])); + if child_key == Some("path") { + has_path = true; + if let Some(colon) = child_semantic.find(':') { + let raw = child_semantic[colon + 1..].trim_start(); + let adjustment = child_semantic[colon + 1..].len() - raw.len(); + route_path = scalar_value(raw, child_start + colon + 1 + adjustment) + .map(|(value, start, _)| (value.to_string(), start)); + } + break; + } + } + } + if has_path { + push_symfony_symbol( + refs, + uri, + SymfonySymbolKind::Route, + key.to_string(), + key_start + quote_adjust.0, + key_end.saturating_sub(quote_adjust.1), + true, + ); + if let Some((path, path_start)) = route_path { + scan_route_path_parameters(uri, key, &path, path_start, refs); + } + } + } +} + +fn scan_symfony_xml_routes(uri: &str, content: &str, refs: &mut Vec) { + if !content.contains("') else { + break; + }; + let tag_end = tag_start + rel_end + 1; + let tag = &content[tag_start..tag_end]; + if let Some((route_name, start, end)) = xml_attr_value(tag, tag_start, &["id", "name"]) + && valid_symfony_symbol_name(&route_name) + { + push_symfony_symbol( + refs, + uri, + SymfonySymbolKind::Route, + route_name.clone(), + start, + end, + true, + ); + if let Some((path, path_start, _)) = xml_attr_value(tag, tag_start, &["path"]) { + scan_route_path_parameters(uri, &route_name, &path, path_start, refs); + } + } + search = tag_end; + } +} + +fn scan_route_path_parameters( + uri: &str, + route_name: &str, + path: &str, + path_start: usize, + refs: &mut Vec, +) { + let bytes = path.as_bytes(); + let mut cursor = 0usize; + while cursor < bytes.len() { + let Some(open_rel) = path[cursor..].find('{') else { + break; + }; + let open = cursor + open_rel; + let Some(close_rel) = path[open + 1..].find('}') else { + break; + }; + let close = open + 1 + close_rel; + let inner = &path[open + 1..close]; + let name_len = inner + .bytes() + .take_while(|byte| *byte == b'_' || byte.is_ascii_alphanumeric()) + .count(); + let name = &inner[..name_len]; + if !name.is_empty() && !name.starts_with(|character: char| character.is_ascii_digit()) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: (path_start + open + 1) as u32, + end: (path_start + open + 1 + name_len) as u32, + kind: FrameworkReferenceKind::RouteParameter { + route_name: route_name.to_string(), + name: name.to_string(), + declaration: true, + }, + }); + } + cursor = close + 1; + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum YamlContainerSectionKind { + Services, + Parameters, +} + +struct YamlContainerSection { + kind: YamlContainerSectionKind, + indent: usize, + child_indent: Option, +} + +fn scan_symfony_yaml_container_symbols( + uri: &str, + content: &str, + refs: &mut Vec, +) { + let mut section: Option = None; + let has_container_section = content.lines().any(|line| { + matches!( + line.trim(), + "services:" + | "\"services\":" + | "'services':" + | "parameters:" + | "\"parameters\":" + | "'parameters':" + ) + }); + if !has_container_section { + return; + } + + for (line_start, line) in line_offsets(content) { + let semantic = yaml_content_before_comment(line); + let trimmed = semantic.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') { + continue; + } + scan_parameter_placeholders(uri, semantic, line_start, refs); + + let indent = leading_spaces(semantic); + let section_kind = match trimmed { + "services:" | "\"services\":" | "'services':" => { + Some(YamlContainerSectionKind::Services) + } + "parameters:" | "\"parameters\":" | "'parameters':" => { + Some(YamlContainerSectionKind::Parameters) + } + _ => None, + }; + if let Some(kind) = section_kind { + section = Some(YamlContainerSection { + kind, + indent, + child_indent: None, + }); + continue; + } + + if section + .as_ref() + .is_some_and(|current| indent <= current.indent) + { + section = None; + } + + let Some(current) = section.as_mut() else { + continue; + }; + if current.child_indent.is_none() { + current.child_indent = Some(indent); + } + + if current.child_indent == Some(indent) + && let Some((raw_key, key_start, key_end, value_start)) = + yaml_mapping_entry(semantic, line_start) + { + let (key, quote_adjust) = strip_yaml_quotes(raw_key); + let key_start = key_start + quote_adjust.0; + let key_end = key_end.saturating_sub(quote_adjust.1); + let is_declaration = match current.kind { + YamlContainerSectionKind::Services => !key.starts_with('_') && !key.ends_with('\\'), + YamlContainerSectionKind::Parameters => !key.starts_with('_'), + }; + if is_declaration && valid_symfony_symbol_name(key) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: key_start as u32, + end: key_end as u32, + kind: FrameworkReferenceKind::SymfonySymbol { + kind: match current.kind { + YamlContainerSectionKind::Services => SymfonySymbolKind::Service, + YamlContainerSectionKind::Parameters => SymfonySymbolKind::Parameter, + }, + name: key.to_string(), + declaration: true, + }, + }); + } + + if matches!(current.kind, YamlContainerSectionKind::Services) { + scan_service_references_in_text(uri, semantic, line_start, value_start, refs); + } + } + + if matches!(current.kind, YamlContainerSectionKind::Services) { + scan_service_references_in_text(uri, semantic, line_start, indent, refs); + } + } +} + +fn yaml_mapping_entry(line: &str, line_start: usize) -> Option<(&str, usize, usize, usize)> { + let indent = leading_spaces(line); + let trimmed = &line[indent..]; + let colon = trimmed.find(':')?; + let raw_key = trimmed[..colon].trim(); + if raw_key.is_empty() { + return None; + } + let raw_offset = trimmed[..colon].find(raw_key)?; + let key_start = line_start + indent + raw_offset; + let key_end = key_start + raw_key.len(); + Some((raw_key, key_start, key_end, indent + colon + 1)) +} + +fn yaml_content_before_comment(line: &str) -> &str { + let bytes = line.as_bytes(); + let mut quote = None; + let mut escaped = false; + for (idx, byte) in bytes.iter().copied().enumerate() { + if escaped { + escaped = false; + continue; + } + if byte == b'\\' && quote.is_some() { + escaped = true; + continue; + } + if matches!(byte, b'\'' | b'"') { + if quote == Some(byte) { + quote = None; + } else if quote.is_none() { + quote = Some(byte); + } + continue; + } + if byte == b'#' && quote.is_none() { + return &line[..idx]; + } + } + line +} + +fn scan_service_references_in_text( + uri: &str, + text: &str, + absolute_start: usize, + from: usize, + refs: &mut Vec, +) { + let bytes = text.as_bytes(); + let mut cursor = from.min(bytes.len()); + while cursor < bytes.len() { + if bytes[cursor] != b'@' { + cursor += 1; + continue; + } + let mut start = cursor + 1; + while bytes + .get(start) + .is_some_and(|byte| matches!(*byte, b'?' | b'!')) + { + start += 1; + } + let mut end = start; + while bytes + .get(end) + .is_some_and(|byte| is_symfony_symbol_char(*byte)) + { + end += 1; + } + let name = &text[start..end]; + if valid_symfony_symbol_name(name) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: (absolute_start + start) as u32, + end: (absolute_start + end) as u32, + kind: FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Service, + name: name.to_string(), + declaration: false, + }, + }); + } + cursor = end.max(cursor + 1); + } +} + +fn scan_parameter_placeholders( + uri: &str, + text: &str, + absolute_start: usize, + refs: &mut Vec, +) { + let bytes = text.as_bytes(); + let mut cursor = 0usize; + while cursor < bytes.len() { + let Some(open_rel) = text[cursor..].find('%') else { + break; + }; + let open = cursor + open_rel; + let Some(close_rel) = text[open + 1..].find('%') else { + break; + }; + let close = open + 1 + close_rel; + let name = &text[open + 1..close]; + if valid_symfony_symbol_name(name) + && !name.starts_with("env(") + && !name.starts_with("resolve:") + { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: (absolute_start + open + 1) as u32, + end: (absolute_start + close) as u32, + kind: FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Parameter, + name: name.to_string(), + declaration: false, + }, + }); + } + cursor = close + 1; + } +} + +fn scan_symfony_xml_container_symbols( + uri: &str, + content: &str, + refs: &mut Vec, +) { + if !content.contains("') else { + break; + }; + let tag_end = tag_start + rel_end + 1; + let tag = &content[tag_start..tag_end]; + let tag_lower = tag.to_ascii_lowercase(); + + if tag_lower.starts_with(", + uri: &str, + kind: SymfonySymbolKind, + name: String, + start: usize, + end: usize, + declaration: bool, +) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration, + }, + }); +} + +fn push_translation( + refs: &mut Vec, + uri: &str, + domain: String, + name: String, + start: usize, + end: usize, + declaration: bool, +) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::Translation { + domain, + name, + declaration, + }, + }); +} + +fn push_config_key( + refs: &mut Vec, + uri: &str, + path: String, + start: usize, + end: usize, + declaration: bool, +) { + refs.push(FrameworkReference { + uri: uri.to_string(), + start: start as u32, + end: end as u32, + kind: FrameworkReferenceKind::ConfigKey { path, declaration }, + }); +} + +fn valid_symfony_symbol_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| is_symfony_symbol_char(byte) || byte == b'\\') +} + +fn valid_translation_key(name: &str) -> bool { + !name.is_empty() && !name.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) +} + +fn valid_translation_domain(domain: &str) -> bool { + !domain.is_empty() + && domain + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) +} + +fn valid_config_key_segment(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn is_symfony_symbol_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b':' | b'/' | b'\\') +} + +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 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() { + 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 bytes = source.as_bytes(); + let mut source_offset = 0usize; + let mut segment_idx = 0usize; + while source_offset < bytes.len() { + while source_offset < bytes.len() && bytes[source_offset] == b'\\' { + source_offset += 1; + } + if source_offset >= bytes.len() { + break; + } + let segment_start = source_offset; + while source_offset < bytes.len() && bytes[source_offset] != b'\\' { + source_offset += 1; + } + let start = absolute_start + segment_start as u32; + let end = absolute_start + source_offset as u32; + if cursor >= start && cursor <= end { + return Some((segment_idx, start, end)); + } + segment_idx += 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 +} + +#[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() + ); + } + + #[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() + ); + } + + #[test] + fn messenger_lookup_updates_and_removes_one_handler() { + let backend = Backend::new_test(); + let uri = "file:///project/src/MessageHandler/PlaceOrderHandler.php"; + backend.index_framework_uri_content( + uri, + r#" 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/preload.rs b/src/indexing/preload.rs index 5b8d422ff..8fd1fd8c6 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,28 +275,37 @@ 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() { + 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(); - 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| { + 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 @@ -250,12 +313,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)) @@ -264,7 +335,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, @@ -321,6 +399,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..04c024a8b 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 @@ -49,17 +49,27 @@ impl Backend { ) -> bool { let mut composer_changed = false; let mut config_changed = false; + let mut proxy_index_rebuild = false; + let mut symfony_metadata_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 current_config = self.config(); + let proxy_rules = current_config.php.proxies.clone(); + let symfony_container = current_config.symfony.container; + let has_symfony_event_rules = !current_config.symfony.events.publishers.is_empty() + || !current_config.symfony.events.subscribers.is_empty(); 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(); - let laravel_config = self.config().laravel; + let indexed = self.symbol_maps.read(); + let laravel_config = current_config.laravel; for change in ¶ms.changes { let path_str = change.uri.path(); if path_str.ends_with("/composer.json") || path_str.ends_with("/composer.lock") { @@ -103,12 +113,43 @@ 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; + } + } + 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; } // Open files are already tracked via did_open/did_change. - let uri_str = change.uri.to_string(); if open.contains_key(&uri_str) { continue; } @@ -116,6 +157,28 @@ impl Backend { continue; }; + // Generated proxies are opt-in metadata inputs, not ordinary + // project classes. 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; + } + + // Compiled containers are metadata inputs. Never parse them + // into the project symbol index, and never execute them. + if crate::symfony::container::path_may_be_compiled_container( + root, + &file_path, + &symfony_container, + ) { + symfony_metadata_rebuild |= has_symfony_event_rules; + continue; + } + + if crate::framework::is_framework_php_config_path(&file_path) { + framework_changes.push((uri_str.clone(), file_path.clone(), change.typ)); + } if change.typ == FileChangeType::CHANGED { // `parsed_uris` records the editor URI for open files and // the canonical `file://` URI for lazily loaded ones; @@ -126,6 +189,14 @@ impl Backend { if !loaded { continue; } + if !crate::framework::is_framework_php_config_path(&file_path) { + framework_changes.push((uri_str.clone(), file_path.clone(), change.typ)); + } + } else if change.typ == FileChangeType::DELETED + && self.framework_references.read().contains_key(&uri_str) + && !crate::framework::is_framework_php_config_path(&file_path) + { + framework_changes.push((uri_str.clone(), file_path.clone(), change.typ)); } php_changes.push((uri_str, file_path, change.typ)); @@ -133,10 +204,14 @@ impl Backend { } if php_changes.is_empty() + && resource_changes.is_empty() && !composer_changed && !config_changed + && !proxy_index_rebuild + && !symfony_metadata_rebuild && !schema_full_rebuild && migration_changes.is_empty() + && framework_changes.is_empty() { return false; } @@ -144,6 +219,8 @@ impl Backend { if config_changed { tracing::info!("PHPantom: .phpantom.toml changed, reloading configuration"); self.reload_config(root); + proxy_index_rebuild = true; + symfony_metadata_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 @@ -159,6 +236,15 @@ impl Backend { php_changes.len() ); self.reindex_files_batch(&php_changes); + if has_symfony_event_rules { + for (uri, path, change_type) in &php_changes { + if *change_type == FileChangeType::DELETED { + self.remove_symfony_event_sites(uri); + } else if let Ok(content) = std::fs::read_to_string(path) { + self.refresh_symfony_event_sites(uri, &content); + } + } + } // A class that was previously "not found" may now exist, and // resolved class info / member completions may be stale for a // class whose file changed. @@ -175,6 +261,30 @@ 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", + 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 symfony_metadata_rebuild { + let count = self.rebuild_symfony_metadata(root); + tracing::info!("PHPantom: indexed {} Symfony event links", count); + } + if schema_full_rebuild { tracing::info!("PHPantom: Laravel schema files changed, reloading schema index"); self.reload_laravel_schema_index(root); @@ -186,6 +296,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 } @@ -260,6 +380,15 @@ impl Backend { last_modified = modified; tracing::info!("PHPantom: global config changed, reloading configuration"); self.reload_config(&root); + let metadata_backend = self.clone_for_blocking(); + let metadata_root = root.clone(); + crate::server::run_blocking_cancel_safe("reload_project_metadata", move || { + let proxy_count = metadata_backend.rebuild_configured_proxy_index(&metadata_root); + metadata_backend.refresh_indexed_resource_symbols(); + let event_count = metadata_backend.rebuild_symfony_metadata(&metadata_root); + (proxy_count, event_count) + }) + .await; } } } diff --git a/src/lib.rs b/src/lib.rs index 3c6bbf1a6..70668bbd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -222,6 +222,7 @@ mod backend; pub mod benevolent_builtins; pub mod blade; pub(crate) mod call_args; +mod call_hierarchy; pub mod ci_map; pub(crate) mod class_loader_memo; pub(crate) mod class_lookup; @@ -239,6 +240,7 @@ mod document_symbols; pub mod fix; mod folding; mod formatting; +mod framework; mod highlight; mod hover; mod indexing; @@ -261,11 +263,13 @@ 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; mod rename; mod resolution; +mod resource_navigation; pub(crate) mod return_collection; pub(crate) mod scope_collector; mod selection_range; @@ -278,6 +282,7 @@ pub mod stub_patches; pub mod stubs; mod symbol_index; pub(crate) mod symbol_map; +mod symfony; pub(crate) mod text_position; pub(crate) mod text_scan; pub(crate) mod toposort; @@ -560,6 +565,20 @@ 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 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. + pub(crate) framework_doctrine_repositories: framework::DoctrineRepositoryIndex, /// Cross-file candidate index for find-references. /// /// Maintained from each file's [`symbol_maps`] entry during parsing. @@ -567,6 +586,15 @@ 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>, + /// Symfony event wiring recovered from compiled containers and configured + /// PHP attributes. + pub(crate) symfony_events: Arc>, /// Skip building [`reference_index`] from `update_ast`. /// /// Set by [`Backend::new_headless`] for the `analyze`/`fix` CLI @@ -879,6 +907,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 +921,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 @@ -922,12 +956,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. @@ -1074,7 +1109,12 @@ 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(), + framework_reference_lookup: framework::new_framework_reference_lookup_index(), + framework_doctrine_repositories: framework::new_doctrine_repository_index(), reference_index: reference_index::new_reference_index(), + proxy_index: Arc::new(RwLock::new(proxy_metadata::ProxyIndex::default())), + symfony_events: Arc::new(RwLock::new(symfony::SymfonyEventIndex::default())), skip_reference_index: false, symbols: SymbolIndex::new(), workspace: WorkspaceEnv::new(), @@ -1147,6 +1187,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)), @@ -1184,7 +1225,12 @@ 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(), + framework_reference_lookup: framework::new_framework_reference_lookup_index(), + framework_doctrine_repositories: framework::new_doctrine_repository_index(), reference_index: reference_index::new_reference_index(), + proxy_index: Arc::new(RwLock::new(proxy_metadata::ProxyIndex::default())), + symfony_events: Arc::new(RwLock::new(symfony::SymfonyEventIndex::default())), skip_reference_index: false, symbols: SymbolIndex::new(), workspace: WorkspaceEnv::new_isolated(), @@ -1254,6 +1300,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)), @@ -1837,7 +1884,12 @@ 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), + 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), + proxy_index: Arc::clone(&self.proxy_index), + symfony_events: Arc::clone(&self.symfony_events), skip_reference_index: self.skip_reference_index, symbols: self.symbols.clone(), parse_errors: Arc::clone(&self.parse_errors), @@ -1893,6 +1945,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..bd305ce85 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); } @@ -1341,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(), @@ -1368,16 +1377,41 @@ 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, ); + 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 +1661,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/parser/ast_update.rs b/src/parser/ast_update.rs index 3d21d3a79..315106ae2 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -215,6 +215,16 @@ impl Backend { // served after a file changes. crate::virtual_members::phpdoc::bump_mixin_generation(); + // Symfony's PHP configurators contain semantic class and callable + // strings that the normal PHP symbol map deliberately treats as + // plain strings. Keep their lightweight framework index in step with + // every parse, including incomplete edits where the main parse fails. + if crate::framework::should_index_framework_php_content(uri, content) + || self.framework_references.read().contains_key(uri) + { + self.index_framework_uri_content(uri, content); + } + let content_to_parse = if self.is_blade_file(uri) { // Seed the template scope with the set cached by the refresh // passes (post-index refresh, Blade did_open, caller save): @@ -277,6 +287,13 @@ impl Backend { self.update_ast_inner(&uri_owned, &content_owned) }); + // Attribute rules are project configuration, while listener wiring + // comes from Symfony's compiled container. Refresh the source side + // only after the class/import indexes above have been published. + if result.is_some() { + self.refresh_symfony_event_sites(uri, content); + } + // Keep the Laravel macro index coherent with edits to files that // register macros. Cheap no-op for files without a `macro(` call. self.refresh_laravel_macros(uri, content); @@ -1322,6 +1339,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/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#">, /// 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,130 @@ 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> { + 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 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); + } + 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 +492,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 @@ -233,26 +511,31 @@ 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 count = self.member_declaration_reference_count( - &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, item.is_static, - count as u32, + locations, ); } @@ -278,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 @@ -295,21 +579,35 @@ 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; 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 +682,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(); + $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 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(); + 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(); @@ -403,10 +859,37 @@ function persist(Order $order): void { count_on_line(&hints(&backend, ONE_CALL), 2).as_deref(), Some(" 1 reference") ); + let declaration_offset = ONE_CALL.find("save").unwrap() as u32; + assert_eq!( + backend + .member_ref_locations_cached( + URI, + declaration_offset, + crate::atom::atom("Order"), + crate::atom::atom("save"), + false, + ) + .expect("exact reference locations should be cached") + .len(), + 1 + ); let edited = ONE_CALL.replace("$order->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 +901,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..55e93ca5a 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(); @@ -154,6 +285,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 +312,35 @@ 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 { + 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( + keys.iter() + .filter_map(|key| index.get(key)) + .flat_map(HashMap::values) + .map(|&count| count as usize) + .sum(), + ) + } + /// 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 +414,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: @@ -402,6 +569,17 @@ impl Backend { ) -> Vec<(ReferenceIndexKey, bool)> { match &span.kind { SymbolKind::ClassReference { name, is_fqn, .. } => { + if *is_fqn && crate::resource_navigation::is_resource_document(uri) { + let mut seen = HashSet::new(); + return self + .metadata_class_family(name) + .into_iter() + .filter_map(|name| { + let key = ReferenceIndexKey::class_owned(name); + seen.insert(key.clone()).then_some((key, true)) + }) + .collect(); + } let resolved = if *is_fqn { normalize_symbol_name(name) } else if let Some(fqn) = self.resolved_name_at(uri, span.start) { @@ -618,6 +796,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/classes.rs b/src/references/classes.rs index 21ef8c6fb..ef8cdf19f 100644 --- a/src/references/classes.rs +++ b/src/references/classes.rs @@ -45,6 +45,15 @@ 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| { + if crate::resource_navigation::is_resource_document(file_uri) { + self.metadata_class_family(resolved) + .iter() + .any(|name| name.eq_ignore_ascii_case(target)) + } else { + 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 +77,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 +118,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) { @@ -152,15 +161,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 277fedcae..a2fdb817c 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, @@ -116,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() @@ -123,6 +159,97 @@ 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::Property { + class_fqn, + member_name, + } => { + let hierarchy = self.collect_hierarchy_for_fqns(&[class_fqn]); + self.find_member_references( + &member_name, + false, + include_declaration, + Some(&hierarchy), + Some(&hierarchy), + ) + } + FrameworkReferenceKind::SymfonySymbol { kind, name, .. } => { + self.framework_symfony_symbol_locations(kind, &name, include_declaration, true) + } + FrameworkReferenceKind::RouteParameter { + route_name, name, .. + } => self.framework_route_parameter_locations( + &route_name, + &name, + include_declaration, + true, + ), + FrameworkReferenceKind::Translation { domain, name, .. } => { + self.framework_translation_locations(&domain, &name, include_declaration, true) + } + FrameworkReferenceKind::MessengerHandler { + message_fqn, + handler_fqn, + .. + } => self.framework_messenger_handler_locations(&message_fqn, &handler_fqn), + FrameworkReferenceKind::ConfigKey { path, .. } => { + self.framework_config_key_locations(&path, include_declaration, true) + } + 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 4667f0a24..e953316e0 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, @@ -168,31 +177,295 @@ 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 { - 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, + ) -> Vec { + 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(), - ) - .len() + }]) + .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 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() { + return Vec::new(); + } + span_indices.sort_unstable(); + span_indices.dedup(); + + let Ok(parsed_uri) = Url::parse(file_uri) else { + return Vec::new(); + }; + let Some(content) = self.reference_file_content_arc(file_uri) else { + return Vec::new(); + }; + 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, + is_static, + .. + } = &span.kind + else { + continue; + }; + let Some(query_indices) = by_member.get(member_name) else { + continue; + }; + + 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), + ); + + for &query_index in query_indices { + let query = &prepared[query_index]; + if let Some(hierarchy) = &query.hierarchy { + if !subject_fqns + .iter() + .any(|fqn| hierarchy.contains(fqn.as_str())) + { + continue; + } + } else if query.is_static != *is_static { + continue; + } + + 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); + } + } + + for query_locations in &mut locations { + sort_locations_for_references(query_locations); + } + + locations } /// Find all references to a member (method, property, or constant) @@ -276,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 = @@ -308,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( @@ -441,13 +721,13 @@ 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); + } + for loc in self.framework_property_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 } @@ -594,6 +874,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, @@ -627,6 +918,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, @@ -672,7 +1087,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) }; @@ -773,7 +1188,7 @@ impl Backend { hierarchy } - fn collect_member_receiver_scope( + pub(super) fn collect_member_receiver_scope( &self, seed_fqns: &[String], member_name: &str, @@ -1106,3 +1521,112 @@ 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") +} + +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, +) -> 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 +} + +pub(crate) 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 6c173db94..2ca29c5d0 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -36,6 +36,11 @@ mod functions; mod members; mod variables; +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; @@ -43,6 +48,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; @@ -67,7 +73,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 +91,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()) } @@ -215,6 +221,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 @@ -315,9 +332,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) @@ -345,12 +393,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/references/tests.rs b/src/references/tests.rs index 5400087ac..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 ); } @@ -2429,7 +2435,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 +2516,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 +2592,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 +2616,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 +2629,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] 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..a3480f0fa 100644 --- a/src/rename/prepare.rs +++ b/src/rename/prepare.rs @@ -11,8 +11,12 @@ use std::collections::HashMap; use tower_lsp::lsp_types::*; use crate::Backend; +use crate::framework::{ + FrameworkReferenceKind, SymfonySymbolKind, 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 +86,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 +148,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 +327,134 @@ 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::Property { 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::SymfonySymbol { + kind: SymfonySymbolKind::Template, + .. + } => return None, + FrameworkReferenceKind::SymfonySymbol { name, .. } => { + (reference.start, reference.end, name) + } + FrameworkReferenceKind::RouteParameter { name, .. } => { + (reference.start, reference.end, name) + } + FrameworkReferenceKind::Translation { .. } => return None, + FrameworkReferenceKind::MessengerHandler { .. } => return None, + FrameworkReferenceKind::ConfigKey { .. } => return None, + 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, false) + } + FrameworkReferenceKind::Property { .. } => { + let locations = + self.find_framework_references_for_rename(uri, content, position, true)?; + build_simple_rename_edit(self, uri, content, &locations, new_name, false) + } + 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::SymfonySymbol { + kind: SymfonySymbolKind::Template, + .. + } => None, + FrameworkReferenceKind::SymfonySymbol { .. } => { + let locations = + self.find_framework_references_for_rename(uri, content, position, true)?; + build_simple_rename_edit(self, uri, content, &locations, new_name, true) + } + FrameworkReferenceKind::RouteParameter { .. } => { + let locations = + self.find_framework_references_for_rename(uri, content, position, true)?; + build_simple_rename_edit(self, uri, content, &locations, new_name, false) + } + FrameworkReferenceKind::Translation { .. } => None, + FrameworkReferenceKind::MessengerHandler { .. } => None, + FrameworkReferenceKind::ConfigKey { .. } => None, + 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 +562,62 @@ impl Backend { } } } + +fn build_simple_rename_edit( + backend: &Backend, + current_uri: &str, + current_content: &str, + locations: &[Location], + new_name: &str, + preserve_php_escaping: bool, +) -> 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) + }; + let Some(loc_content) = loc_content else { + continue; + }; + let replacement = if preserve_php_escaping && loc_uri_str.ends_with(".php") { + let start = + crate::text_position::position_to_offset(&loc_content, location.range.start); + let end = crate::text_position::position_to_offset(&loc_content, location.range.end); + let source = loc_content + .get(start as usize..end as usize) + .unwrap_or_default(); + if source.contains("\\\\") { + new_name.replace('\\', "\\\\") + } else { + new_name.to_string() + } + } else { + new_name.to_string() + }; + + changes + .entry(location.uri.clone()) + .or_default() + .push(TextEdit { + range: location.range, + new_text: replacement, + }); + } + + if changes.is_empty() { + None + } else { + Some(WorkspaceEdit { + changes: Some(changes), + document_changes: None, + change_annotations: None, + }) + } +} diff --git a/src/rename/validate.rs b/src/rename/validate.rs index 408baa309..b1669d2a5 100644 --- a/src/rename/validate.rs +++ b/src/rename/validate.rs @@ -215,13 +215,15 @@ fn range_matches(content: &str, range: Range, expected: &Expected) -> bool { /// `\Ns\Foo`. fn is_name_token(text: &str) -> bool { let body = text.strip_prefix('$').unwrap_or(text); - let body = body.strip_prefix('\\').unwrap_or(body); + let body = body.trim_start_matches('\\'); !body.is_empty() - && body.split('\\').all(|segment| { - !segment.is_empty() - && !segment.starts_with(|c: char| c.is_ascii_digit()) - && segment.chars().all(is_name_char) - }) + && body + .split('\\') + .filter(|segment| !segment.is_empty()) + .all(|segment| { + !segment.starts_with(|c: char| c.is_ascii_digit()) + && segment.chars().all(is_name_char) + }) } /// Whether `c` can appear inside a PHP identifier. PHP allows every byte diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs new file mode 100644 index 000000000..5da0ef98c --- /dev/null +++ b/src/resource_navigation.rs @@ -0,0 +1,401 @@ +//! 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 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 { + Class(String), + Member { + class_fqn: String, + member_name: String, + }, +} + +#[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 + .split(['?', '#']) + .next() + .unwrap_or(uri) + .to_ascii_lowercase(); + + [ + ".yaml", + ".yml", + ".xml", + ".yaml.dist", + ".yml.dist", + ".xml.dist", + ] + .iter() + .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. + pub(crate) fn resolve_resource_definition( + &self, + content: &str, + position: Position, + ) -> Option { + match symbol_at(content, position)? { + ResourceSymbol::Class(fqn) => self + .metadata_class_family(&fqn) + .iter() + .find_map(|target| self.class_declaration_location(target)), + ResourceSymbol::Member { + class_fqn, + member_name, + } => self + .metadata_class_family(&class_fqn) + .iter() + .find_map(|target| self.class_member_declaration_location(target, &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); + } + + /// 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) { + 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 { + 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(canonical_class), + 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])) { + 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) { + 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 { + 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; + } + + symbols +} + +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 828d2fdb1..9d9938040 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. @@ -249,6 +259,7 @@ impl LanguageServer for Backend { type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)), implementation_provider: Some(ImplementationProviderCapability::Simple(true)), references_provider: Some(OneOf::Left(true)), + call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)), document_highlight_provider: Some(OneOf::Left(true)), code_action_provider: Some(CodeActionProviderCapability::Options( CodeActionOptions { @@ -274,7 +285,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)), @@ -407,6 +418,35 @@ impl LanguageServer for Backend { } } + // 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); + } + + // Symfony's generated container records the final event-listener + // wiring after compiler passes have run. Read it statically; the + // container PHP is never loaded or executed. + let symfony_backend = self.clone_for_blocking(); + let symfony_root = root.clone(); + let event_count = run_blocking_cancel_safe("index_symfony_metadata", move || { + symfony_backend.rebuild_symfony_metadata(&symfony_root) + }) + .await + .unwrap_or(0); + if event_count > 0 { + tracing::info!("PHPantom: indexed {} Symfony event links", event_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 @@ -471,6 +511,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; } @@ -564,6 +612,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), @@ -589,6 +645,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(), @@ -730,6 +800,24 @@ impl LanguageServer for Backend { .write() .insert(uri.clone(), Arc::clone(&text)); + // 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. + 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.schedule_diagnostics(uri.clone()); + } + 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); @@ -809,6 +897,24 @@ impl LanguageServer for Backend { .write() .insert(uri.clone(), Arc::clone(&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.schedule_diagnostics(uri.clone()); + } + if self.supports_code_lens_refresh.load(Ordering::Acquire) + && let Some(ref client) = self.client + { + let _ = client.code_lens_refresh().await; + } + return; + } + // Re-parse in a blocking background task so typing does not // monopolize the LSP service loop and delay completion requests. // @@ -878,6 +984,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; + } } }); } @@ -908,6 +1020,30 @@ impl LanguageServer for Backend { self.blade_injected_vars.write().remove(&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); + } + } + if is_framework_resource { + self.reindex_framework_uri_from_disk(&uri); + } + self.log(MessageType::INFO, format!("Closed resource file: {}", uri)) + .await; + return; + } + + if crate::framework::is_framework_php_config_uri(&uri) + || self.framework_references.read().contains_key(&uri) + { + self.reindex_framework_uri_from_disk(&uri); + } + self.clear_file_maps(&uri); // Clear diagnostics so stale warnings don't linger after the file is closed @@ -919,13 +1055,22 @@ 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_resource_symbol_index(&uri, &text); + } else { + self.update_ast(&uri, &text); + } + } + + if is_resource { + return; } // A save is a reliable sync point: re-diagnose the saved file @@ -979,6 +1124,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; + } } } @@ -996,6 +1146,44 @@ 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() + }); + if let Some(location) = location { + return Ok(Some(GotoDefinitionResponse::Scalar(location))); + } + } + + if let Some(locations) = backend.get_file_content(&uri_clone).and_then(|content| { + backend.symfony_expression_definitions_at(&uri_clone, &content, position) + }) { + return Ok(match locations.as_slice() { + [] => None, + [location] => Some(GotoDefinitionResponse::Scalar(location.clone())), + _ => Some(GotoDefinitionResponse::Array(locations)), + }); + } + + if let Some(locations) = backend.get_file_content(&uri_clone).and_then(|content| { + backend.symfony_event_definitions_at(&uri_clone, &content, position) + }) { + return Ok(match locations.as_slice() { + [] => None, + [location] => Some(GotoDefinitionResponse::Scalar(location.clone())), + _ => Some(GotoDefinitionResponse::Array(locations)), + }); + } + // 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. @@ -1240,6 +1428,16 @@ impl LanguageServer for Backend { }); let uri_clone = uri.clone(); let result = run_blocking_cancel_safe("references", move || { + if let Some(locations) = backend.get_file_content(&uri_clone).and_then(|content| { + backend.symfony_event_references_at( + &uri_clone, + &content, + position, + include_declaration, + ) + }) { + return Ok(Some(locations)); + } backend.handle_with_position("references", &uri_clone, position, |content, pos| { backend .find_references(&uri_clone, content, pos, include_declaration) @@ -1471,12 +1669,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( @@ -1559,6 +1770,56 @@ impl LanguageServer for Backend { self.inlay_hint_request(params).await } + async fn prepare_call_hierarchy( + &self, + params: CallHierarchyPrepareParams, + ) -> Result>> { + let uri = params + .text_document_position_params + .text_document + .uri + .to_string(); + let position = params.text_document_position_params.position; + let backend = self.clone_for_blocking(); + let request_uri = uri.clone(); + run_blocking_cancel_safe("prepare_call_hierarchy", move || { + backend.handle_with_position( + "prepare_call_hierarchy", + &request_uri, + position, + |content, translated_position| { + backend.prepare_call_hierarchy_impl(&request_uri, content, translated_position) + }, + ) + }) + .await + .unwrap_or(Ok(None)) + } + + async fn incoming_calls( + &self, + params: CallHierarchyIncomingCallsParams, + ) -> Result>> { + let backend = self.clone_for_blocking(); + Ok(run_blocking_cancel_safe("incoming_calls", move || { + backend.incoming_calls_impl(¶ms.item) + }) + .await + .flatten()) + } + + async fn outgoing_calls( + &self, + params: CallHierarchyOutgoingCallsParams, + ) -> Result>> { + let backend = self.clone_for_blocking(); + Ok(run_blocking_cancel_safe("outgoing_calls", move || { + backend.outgoing_calls_impl(¶ms.item) + }) + .await + .flatten()) + } + async fn prepare_type_hierarchy( &self, params: TypeHierarchyPrepareParams, @@ -1909,6 +2170,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 @@ -1975,7 +2243,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/src/symfony/container.rs b/src/symfony/container.rs new file mode 100644 index 000000000..f9f223371 --- /dev/null +++ b/src/symfony/container.rs @@ -0,0 +1,524 @@ +//! Static Symfony compiled-container discovery. +//! +//! Compiled containers are PHP source, but loading one would execute project +//! code. This adapter only reads text and recovers the small pieces of runtime +//! wiring PHPantom needs. + +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; +use std::time::SystemTime; + +use globset::Glob; +use ignore::WalkBuilder; + +use crate::config::SymfonyContainerConfig; +use crate::text_scan::{decode_php_string_literal, find_matching_forward}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EventSubscription { + pub event: String, + pub listener_fqn: String, + pub method: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CompiledContainerMetadata { + pub path: PathBuf, + pub subscriptions: Vec, + pub proxied_classes: Vec, +} + +pub(crate) fn load_compiled_container( + workspace_root: &Path, + config: &SymfonyContainerConfig, +) -> Option { + if !config.enabled() { + return None; + } + + let mut candidates = discover_container_files(workspace_root, config); + candidates.sort_by_key(|path| { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + }); + candidates.reverse(); + + let mut newest = None; + for path in candidates { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let mut metadata = scan_compiled_container(&content); + metadata.path = path; + if !metadata.subscriptions.is_empty() || !metadata.proxied_classes.is_empty() { + return Some(metadata); + } + if newest.is_none() { + newest = Some(metadata); + } + } + newest +} + +pub(crate) fn path_may_be_compiled_container( + workspace_root: &Path, + path: &Path, + config: &SymfonyContainerConfig, +) -> bool { + if !config.enabled() || !path.extension().is_some_and(|ext| ext == "php") { + return false; + } + let Ok(relative) = path.strip_prefix(workspace_root) else { + return false; + }; + + if config.paths.is_empty() { + let cache_root = Path::new("var").join("cache").join(config.environment()); + return relative.starts_with(cache_root) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("Container.php")); + } + + config + .paths + .iter() + .any(|spec| relative_matches_spec(relative, spec)) +} + +fn discover_container_files( + workspace_root: &Path, + config: &SymfonyContainerConfig, +) -> Vec { + if config.paths.is_empty() { + let root = workspace_root + .join("var") + .join("cache") + .join(config.environment()); + return walk_container_files(&root, 3, |_| true); + } + + let mut files = BTreeSet::new(); + for spec in &config.paths { + let Some(relative) = safe_relative_path(spec) else { + tracing::warn!("PHPantom: ignored unsafe Symfony container path: {}", spec); + continue; + }; + if has_glob_meta(spec) { + let Ok(glob) = Glob::new(spec) else { + tracing::warn!("PHPantom: invalid Symfony container glob: {}", spec); + continue; + }; + let matcher = glob.compile_matcher(); + let base = workspace_root.join(fixed_glob_prefix(&relative)); + for path in walk_container_files(&base, 8, |path| { + path.strip_prefix(workspace_root) + .is_ok_and(|relative| matcher.is_match(relative)) + }) { + files.insert(path); + } + continue; + } + + let absolute = workspace_root.join(relative); + if absolute.is_file() { + if is_container_php(&absolute) { + files.insert(absolute); + } + } else if absolute.is_dir() { + files.extend(walk_container_files(&absolute, 8, |_| true)); + } + } + files.into_iter().collect() +} + +fn walk_container_files( + root: &Path, + max_depth: usize, + matches: impl Fn(&Path) -> bool, +) -> Vec { + if !root.exists() { + return Vec::new(); + } + + WalkBuilder::new(root) + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .max_depth(Some(max_depth)) + .build() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file())) + .map(|entry| entry.into_path()) + .filter(|path| is_container_php(path) && matches(path)) + .collect() +} + +fn is_container_php(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("Container.php")) +} + +fn relative_matches_spec(path: &Path, spec: &str) -> bool { + let Some(relative) = safe_relative_path(spec) else { + return false; + }; + if has_glob_meta(spec) { + return Glob::new(spec).is_ok_and(|glob| glob.compile_matcher().is_match(path)); + } + path == relative || path.starts_with(relative) +} + +fn safe_relative_path(spec: &str) -> Option { + let path = Path::new(spec); + if path.as_os_str().is_empty() || path.is_absolute() { + return None; + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return None; + } + Some(path.to_path_buf()) +} + +fn has_glob_meta(spec: &str) -> bool { + spec.bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'{')) +} + +fn fixed_glob_prefix(path: &Path) -> PathBuf { + let mut prefix = PathBuf::new(); + for component in path.components() { + let text = component.as_os_str().to_string_lossy(); + if has_glob_meta(&text) { + break; + } + prefix.push(component.as_os_str()); + } + prefix +} + +pub(crate) fn scan_compiled_container(content: &str) -> CompiledContainerMetadata { + let mut subscriptions = Vec::new(); + let mut proxied_classes = Vec::new(); + + scan_method_calls(content, "addListener", |arguments| { + let args = split_top_level(arguments, 0, arguments.len()); + let Some(event_arg) = args.first().and_then(|range| trimmed(arguments, *range)) else { + return; + }; + let Some(callback_arg) = args.get(1).and_then(|range| trimmed(arguments, *range)) else { + return; + }; + let Some(event) = decode_string(event_arg) else { + return; + }; + let Some((listener_fqn, method)) = listener_callback(callback_arg) else { + return; + }; + subscriptions.push(EventSubscription { + event, + listener_fqn, + method, + }); + }); + + scan_method_calls(content, "createProxy", |arguments| { + let bytes = arguments.as_bytes(); + let mut search = 0usize; + while let Some(relative) = arguments[search..].find("new") { + let keyword = search + relative; + search = keyword + 3; + if bytes + .get(keyword.wrapping_sub(1)) + .is_some_and(|byte| is_php_identifier(*byte)) + || bytes + .get(keyword + 3) + .is_some_and(|byte| is_php_identifier(*byte)) + { + continue; + } + let mut cursor = keyword + 3; + skip_whitespace(bytes, &mut cursor); + let start = cursor; + while bytes.get(cursor).is_some_and(|byte| is_php_name(*byte)) { + cursor += 1; + } + let fqn = normalize_fqn(&arguments[start..cursor]); + if fqn.contains('\\') { + proxied_classes.push(fqn); + } + break; + } + }); + + subscriptions.sort_by(|left, right| { + left.event + .cmp(&right.event) + .then(left.listener_fqn.cmp(&right.listener_fqn)) + .then(left.method.cmp(&right.method)) + }); + subscriptions.dedup(); + proxied_classes.sort_by_key(|name| name.to_ascii_lowercase()); + proxied_classes.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); + + CompiledContainerMetadata { + path: PathBuf::new(), + subscriptions, + proxied_classes, + } +} + +fn scan_method_calls(content: &str, method: &str, mut visit: impl FnMut(&str)) { + let needle = format!("->{method}"); + let bytes = content.as_bytes(); + let mut search = 0usize; + while let Some(relative) = content[search..].find(&needle) { + let found = search + relative; + search = found + needle.len(); + if bytes + .get(search) + .is_some_and(|byte| is_php_identifier(*byte)) + { + continue; + } + let mut open = search; + skip_whitespace(bytes, &mut open); + if bytes.get(open) != Some(&b'(') { + continue; + } + let Some(close) = find_matching_forward(content, open, b'(', b')') else { + continue; + }; + visit(&content[open + 1..close]); + search = close + 1; + } +} + +fn listener_callback(callback: &str) -> Option<(String, String)> { + let trimmed_callback = callback.trim(); + let inner = trimmed_callback.strip_prefix('[')?.strip_suffix(']')?; + let parts = split_top_level(inner, 0, inner.len()); + let service = parts.first().and_then(|range| trimmed(inner, *range))?; + let method = parts + .get(1) + .and_then(|range| trimmed(inner, *range)) + .and_then(decode_string)?; + + let listener_fqn = closure_target(service) + .or_else(|| longest_fqn_string(service)) + .or_else(|| constructed_class(service))?; + Some((listener_fqn, method)) +} + +fn closure_target(service: &str) -> Option { + let marker = "Closure"; + let marker_start = service.find(marker)?; + let mut open = marker_start + marker.len(); + skip_whitespace(service.as_bytes(), &mut open); + let close = find_matching_forward(service, open, b'(', b')')?; + let arguments = &service[open + 1..close]; + let mut name = None; + for range in split_top_level(arguments, 0, arguments.len()) { + let Some(argument) = trimmed(arguments, range) else { + continue; + }; + let Some((key, raw_value)) = argument.split_once(':') else { + continue; + }; + let Some(value) = decode_string(raw_value) else { + continue; + }; + if !value.contains('\\') { + continue; + } + if key.trim() == "class" { + return Some(normalize_fqn(&value)); + } + if key.trim() == "name" { + name = Some(normalize_fqn(&value)); + } + } + name +} + +fn longest_fqn_string(service: &str) -> Option { + let mut best = None; + let mut search = 0usize; + while let Some((value, consumed)) = decode_first_string(&service[search..]) { + if value.contains('\\') + && best + .as_ref() + .is_none_or(|candidate: &String| value.len() > candidate.len()) + { + best = Some(normalize_fqn(&value)); + } + search += consumed; + } + best +} + +fn constructed_class(service: &str) -> Option { + let start = service.find("new")? + 3; + let bytes = service.as_bytes(); + let mut cursor = start; + skip_whitespace(bytes, &mut cursor); + let name_start = cursor; + while bytes.get(cursor).is_some_and(|byte| is_php_name(*byte)) { + cursor += 1; + } + let fqn = normalize_fqn(&service[name_start..cursor]); + fqn.contains('\\').then_some(fqn) +} + +fn decode_first_string(text: &str) -> Option<(String, usize)> { + let bytes = text.as_bytes(); + let quote = bytes.iter().position(|byte| matches!(byte, b'\'' | b'"'))?; + let end = crate::text_scan::skip_string_forward(bytes, quote); + let raw = text.get(quote..end)?; + let value = decode_php_string_literal(raw)?.into_owned(); + Some((value, end)) +} + +fn decode_string(text: &str) -> Option { + decode_php_string_literal(text.trim()) + .map(|value| value.into_owned()) + .filter(|value| !value.is_empty()) +} + +fn split_top_level(content: &str, start: usize, end: usize) -> Vec<(usize, usize)> { + let bytes = content.as_bytes(); + let mut ranges = Vec::new(); + let mut segment_start = start; + let mut cursor = start; + let mut paren_depth = 0u32; + let mut bracket_depth = 0u32; + let mut brace_depth = 0u32; + while cursor < end { + match bytes[cursor] { + b'\'' | b'"' => { + cursor = crate::text_scan::skip_string_forward(bytes, cursor).min(end); + continue; + } + 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'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + b',' if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => { + ranges.push((segment_start, cursor)); + segment_start = cursor + 1; + } + _ => {} + } + cursor += 1; + } + ranges.push((segment_start, end)); + ranges +} + +fn trimmed(content: &str, range: (usize, usize)) -> Option<&str> { + let value = content.get(range.0..range.1)?.trim(); + (!value.is_empty()).then_some(value) +} + +fn skip_whitespace(bytes: &[u8], cursor: &mut usize) { + while bytes + .get(*cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + *cursor += 1; + } +} + +fn is_php_identifier(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() || byte >= 0x80 +} + +fn is_php_name(byte: u8) -> bool { + is_php_identifier(byte) || byte == b'\\' +} + +fn normalize_fqn(name: &str) -> String { + name.trim().trim_start_matches('\\').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scans_listeners_and_proxy_factory_calls_without_executing_php() { + let content = r#"addListener('post.create_course', [#[\Closure(name: 'App\\Listener\\CourseListener')] fn () => ($container->privates['App\\Listener\\CourseListener'] ?? self::getCourseListenerService($container)), 'onCreated'], 10); +$instance->addListener( + "pre.update_course", + [new \App\Listener\AuditListener(), '__invoke'], +); +$proxy = $factory->createProxy(new \App\UseCase\CreateCourse($dependency)); +"#; + + let metadata = scan_compiled_container(content); + assert_eq!( + metadata.subscriptions, + vec![ + EventSubscription { + event: "post.create_course".to_string(), + listener_fqn: "App\\Listener\\CourseListener".to_string(), + method: "onCreated".to_string(), + }, + EventSubscription { + event: "pre.update_course".to_string(), + listener_fqn: "App\\Listener\\AuditListener".to_string(), + method: "__invoke".to_string(), + }, + ] + ); + assert_eq!( + metadata.proxied_classes, + vec!["App\\UseCase\\CreateCourse".to_string()] + ); + } + + #[test] + fn automatic_discovery_prefers_the_newest_useful_container() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("var/cache/dev"); + std::fs::create_dir_all(cache.join("ContainerOld")).unwrap(); + std::fs::create_dir_all(cache.join("ContainerNew")).unwrap(); + std::fs::write( + cache.join("KernelDevDebugContainer.php"), + "createProxy(new \\App\\UseCase\\Run());").unwrap(); + + let metadata = load_compiled_container(dir.path(), &SymfonyContainerConfig::default()) + .expect("compiled container should be discovered"); + assert_eq!(metadata.path, useful); + assert_eq!(metadata.proxied_classes, vec!["App\\UseCase\\Run"]); + } + + #[test] + fn closure_class_wins_when_the_service_name_is_not_a_class() { + let callback = "[#[\\Closure(name: 'app.listener', class: 'App\\\\Listener\\\\AuditListener')] fn () => null, 'audit']"; + assert_eq!( + listener_callback(callback), + Some(( + "App\\Listener\\AuditListener".to_string(), + "audit".to_string() + )) + ); + } +} diff --git a/src/symfony/events.rs b/src/symfony/events.rs new file mode 100644 index 000000000..ff8fd6775 --- /dev/null +++ b/src/symfony/events.rs @@ -0,0 +1,1462 @@ +//! Symfony event metadata, navigation, references, and lenses. + +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; + +use tower_lsp::lsp_types::{ + CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, CodeLens, Command, + Location, Position, Range, SymbolKind, Url, +}; + +use super::container::{EventSubscription, load_compiled_container}; +use super::php_attributes::{ + PhpArgument, argument_value, attribute_calls, configured_argument, is_php_identifier, + method_after_attribute, php_arguments, string_argument, +}; +use crate::Backend; +use crate::config::{ + SymfonyEventPublisherConfig, SymfonyEventSubscriberConfig, SymfonyEventsConfig, +}; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::types::ClassInfo; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EventRole { + Publisher, + Subscriber, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EventSite { + event: String, + owner_fqn: String, + method: String, + uri: String, + start: u32, + end: u32, + event_start: Option, + event_end: Option, + role: EventRole, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SymfonyEventIndex { + sources: BTreeMap>, + container_path: Option, + subscriptions: Vec, + proxied_classes: Vec, +} + +impl SymfonyEventIndex { + fn replace_source(&mut self, uri: String, sites: Vec) { + if sites.is_empty() { + self.sources.remove(&uri); + } else { + self.sources.insert(uri, sites); + } + } + + fn reset_container( + &mut self, + path: Option, + subscriptions: Vec, + proxied_classes: Vec, + ) { + self.container_path = path; + self.subscriptions = subscriptions; + self.proxied_classes = proxied_classes; + } + + fn clear_sources(&mut self) { + self.sources.clear(); + } + + fn source_sites(&self) -> Vec { + self.sources + .values() + .flat_map(|sites| sites.iter().cloned()) + .collect() + } +} + +impl Backend { + pub(crate) fn symfony_event_outgoing_calls( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let data = item.data.as_ref()?; + match data.get("kind")?.as_str()? { + "php" => self.publisher_event_outgoing_calls(item), + "symfonyEvent" => self.event_subscriber_outgoing_calls(item), + _ => None, + } + } + + pub(crate) fn symfony_event_incoming_calls( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let data = item.data.as_ref()?; + match data.get("kind")?.as_str()? { + "php" => self.subscriber_event_incoming_calls(item), + "symfonyEvent" => self.event_publisher_incoming_calls(item), + _ => None, + } + } + + fn publisher_event_outgoing_calls( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let (owner, method) = php_item_owner_method(item)?; + let owner = self.canonical_metadata_class(owner); + let (sites, subscriptions) = self.symfony_event_snapshot(); + let config = self.config().symfony.events; + let publishers: Vec<_> = sites + .iter() + .filter(|site| { + site.role == EventRole::Publisher + && same_class(&site.owner_fqn, &owner) + && site.method.eq_ignore_ascii_case(method) + }) + .collect(); + if publishers.is_empty() { + return None; + } + + let mut calls = Vec::new(); + for publisher in publishers { + let mut modes: Vec<&str> = subscriptions + .iter() + .filter(|subscription| { + event_names_match(&publisher.event, &subscription.event, &config) + }) + .map(|subscription| event_mode(&subscription.event, &config)) + .chain( + sites + .iter() + .filter(|&site| { + site.role == EventRole::Subscriber + && event_names_match(&publisher.event, &site.event, &config) + }) + .map(|site| event_mode(&site.event, &config)), + ) + .collect(); + if modes.is_empty() { + modes.push("sync"); + } + modes.sort_unstable(); + modes.dedup(); + for mode in modes { + let event_item = + self.synthetic_event_item(&publisher.event, mode, Some(publisher), item)?; + calls.push(CallHierarchyOutgoingCall { + to: event_item, + from_ranges: vec![item.selection_range], + }); + } + } + Some(dedupe_outgoing_calls(calls)) + } + + fn subscriber_event_incoming_calls( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let (owner, method) = php_item_owner_method(item)?; + let owner = self.canonical_metadata_class(owner); + let (sites, subscriptions) = self.symfony_event_snapshot(); + let mut events: Vec<(String, String, Option<&EventSite>)> = subscriptions + .iter() + .filter(|subscription| { + same_class( + &self.canonical_metadata_class(&subscription.listener_fqn), + &owner, + ) && subscription.method.eq_ignore_ascii_case(method) + }) + .map(|subscription| { + ( + subscription.event.clone(), + event_mode(&subscription.event, &self.config().symfony.events).to_string(), + None, + ) + }) + .collect(); + events.extend( + sites + .iter() + .filter(|&site| { + site.role == EventRole::Subscriber + && same_class(&site.owner_fqn, &owner) + && site.method.eq_ignore_ascii_case(method) + }) + .map(|site| { + ( + site.event.clone(), + event_mode(&site.event, &self.config().symfony.events).to_string(), + Some(site), + ) + }), + ); + if events.is_empty() { + return None; + } + + let mut calls = Vec::new(); + for (event, mode, site) in events { + let event_item = self.synthetic_event_item(&event, &mode, site, item)?; + calls.push(CallHierarchyIncomingCall { + from_ranges: vec![event_item.selection_range], + from: event_item, + }); + } + Some(dedupe_incoming_calls(calls)) + } + + fn event_subscriber_outgoing_calls( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let (event, mode) = synthetic_event_data(item)?; + let (sites, subscriptions) = self.symfony_event_snapshot(); + let config = self.config().symfony.events; + let mut targets = Vec::new(); + for subscription in subscriptions.iter().filter(|subscription| { + event_names_match(event, &subscription.event, &config) + && event_mode(&subscription.event, &config) == mode + }) { + if let Some(location) = self.symfony_class_member_declaration_location( + &self.canonical_metadata_class(&subscription.listener_fqn), + &subscription.method, + ) && let Some(target) = self.call_hierarchy_item_at_location(&location) + { + targets.push(target); + } + } + for site in sites.iter().filter(|site| { + site.role == EventRole::Subscriber + && event_names_match(event, &site.event, &config) + && event_mode(&site.event, &config) == mode + }) { + if let Some(location) = self.source_site_location(site) + && let Some(target) = self.call_hierarchy_item_at_location(&location) + { + targets.push(target); + } + } + if targets.is_empty() { + return Some(Vec::new()); + } + targets.sort_by(item_order); + targets.dedup(); + Some( + targets + .into_iter() + .map(|to| CallHierarchyOutgoingCall { + to, + from_ranges: vec![item.selection_range], + }) + .collect(), + ) + } + + fn event_publisher_incoming_calls( + &self, + item: &CallHierarchyItem, + ) -> Option> { + let (event, _) = synthetic_event_data(item)?; + let (sites, _) = self.symfony_event_snapshot(); + let config = self.config().symfony.events; + let mut calls = Vec::new(); + for site in sites.iter().filter(|site| { + site.role == EventRole::Publisher && event_names_match(event, &site.event, &config) + }) { + let Some(location) = self.source_site_location(site) else { + continue; + }; + let Some(from) = self.call_hierarchy_item_at_location(&location) else { + continue; + }; + calls.push(CallHierarchyIncomingCall { + from_ranges: vec![from.selection_range], + from, + }); + } + Some(dedupe_incoming_calls(calls)) + } + + fn synthetic_event_item( + &self, + event: &str, + mode: &str, + site: Option<&EventSite>, + fallback: &CallHierarchyItem, + ) -> Option { + let config = self.config().symfony.events; + let canonical = canonical_event_name(event, &config).to_string(); + let (uri, range) = if let Some(site) = site { + let content = self.get_file_content(&site.uri)?; + let range = site.event_start.zip(site.event_end).map_or_else( + || fallback.selection_range, + |(start, end)| { + Range::new( + offset_to_position(&content, start as usize), + offset_to_position(&content, end as usize), + ) + }, + ); + (Url::parse(&site.uri).ok()?, range) + } else { + (fallback.uri.clone(), fallback.selection_range) + }; + Some(CallHierarchyItem { + name: canonical.clone(), + kind: SymbolKind::EVENT, + tags: None, + detail: Some(format!("Symfony event Β· {mode}")), + uri, + range, + selection_range: range, + data: Some(serde_json::json!({ + "kind": "symfonyEvent", + "event": canonical, + "mode": mode, + })), + }) + } + + /// Rebuild Symfony metadata from the newest compiled container. + pub(crate) fn rebuild_symfony_metadata(&self, workspace_root: &Path) -> usize { + let config = self.config().symfony; + if config.events.publishers.is_empty() && config.events.subscribers.is_empty() { + let mut index = self.symfony_events.write(); + index.clear_sources(); + index.reset_container(None, Vec::new(), Vec::new()); + return 0; + } + let metadata = load_compiled_container(workspace_root, &config.container); + let (path, subscriptions, proxied_classes) = metadata.map_or_else( + || (None, Vec::new(), Vec::new()), + |metadata| { + ( + Some(metadata.path), + metadata.subscriptions, + metadata.proxied_classes, + ) + }, + ); + + { + let mut index = self.symfony_events.write(); + index.clear_sources(); + index.reset_container(path, subscriptions, proxied_classes.clone()); + } + + // `createProxy(new RealClass(...))` narrows attribute scanning to the + // classes the runtime actually decorates. Files opened later are kept + // fresh by `update_ast`, so no workspace-wide source walk is needed. + for class_fqn in proxied_classes { + let Some(uri) = self.resolve_class_uri(&class_fqn).or_else(|| { + self.find_or_load_class(&class_fqn); + self.resolve_class_uri(&class_fqn) + }) else { + continue; + }; + let Some(content) = self.get_file_content(&uri) else { + continue; + }; + self.find_or_load_class(&class_fqn); + self.refresh_symfony_event_sites(&uri, &content); + } + + // Initialization and config reload can race with didOpen. Re-scan the + // current buffers after clearing old rules so a container refresh + // cannot discard attribute sites the editor already published. + let open_files: Vec<(String, std::sync::Arc)> = self + .open_files + .read() + .iter() + .map(|(uri, content)| (uri.clone(), std::sync::Arc::clone(content))) + .collect(); + for (uri, content) in open_files { + self.refresh_symfony_event_sites(&uri, &content); + } + + let index = self.symfony_events.read(); + index.subscriptions.len() + + index + .sources + .values() + .map(|sites| sites.len()) + .sum::() + } + + /// Refresh configured publisher/subscriber attributes in one PHP file. + pub(crate) fn refresh_symfony_event_sites(&self, uri: &str, content: &str) { + if !uri_path(uri).ends_with(".php") { + return; + } + let event_config = self.config().symfony.events; + if event_config.publishers.is_empty() && event_config.subscribers.is_empty() { + self.symfony_events + .write() + .replace_source(uri.to_string(), Vec::new()); + return; + } + + let classes = self + .symbols + .uri_classes_index + .read() + .get(uri) + .cloned() + .unwrap_or_default(); + let use_map = self + .file_imports + .read() + .get(uri) + .cloned() + .unwrap_or_default(); + let mut sites = Vec::new(); + + for attribute in attribute_calls(content) { + let raw_name = &content[attribute.name_start..attribute.name_end]; + let namespace = crate::text_scan::namespace_at_offset(content, attribute.name_start) + .map(str::to_string); + let attribute_fqn = + normalize_fqn(&crate::util::resolve_to_fqn(raw_name, &use_map, &namespace)); + let Some((method_start, method_end)) = + method_after_attribute(content, attribute.group_end) + else { + continue; + }; + let Some(owner) = class_at_method(&classes, method_start) else { + continue; + }; + let owner_fqn = self.canonical_metadata_class(&owner.fqn()); + let method = content[method_start..method_end].to_string(); + let arguments = attribute + .args + .map_or_else(Vec::new, |(start, end)| php_arguments(content, start, end)); + + for rule in event_config + .publishers + .iter() + .filter(|rule| normalize_fqn(&rule.attribute).eq_ignore_ascii_case(&attribute_fqn)) + { + scan_publisher_attribute( + uri, + content, + &arguments, + rule, + &owner_fqn, + &method, + method_start, + method_end, + &mut sites, + ); + } + for rule in event_config + .subscribers + .iter() + .filter(|rule| normalize_fqn(&rule.attribute).eq_ignore_ascii_case(&attribute_fqn)) + { + scan_subscriber_attribute( + uri, + content, + &arguments, + rule, + &owner_fqn, + &method, + method_start, + method_end, + &mut sites, + ); + } + } + + sites.sort_by(|left, right| { + left.start + .cmp(&right.start) + .then(left.event.cmp(&right.event)) + .then((left.role as u8).cmp(&(right.role as u8))) + }); + sites.dedup(); + self.symfony_events + .write() + .replace_source(uri.to_string(), sites); + } + + pub(crate) fn remove_symfony_event_sites(&self, uri: &str) { + self.symfony_events + .write() + .replace_source(uri.to_string(), Vec::new()); + } + + pub(crate) fn symfony_event_lenses( + &self, + classes: &[std::sync::Arc], + uri: &str, + content: &str, + ) -> Vec { + let (sites, subscriptions) = self.symfony_event_snapshot(); + if sites.is_empty() { + return Vec::new(); + } + let events_config = self.config().symfony.events; + let mut lenses = Vec::new(); + + for class in classes { + let owner = self.canonical_metadata_class(&class.fqn()); + for method in &class.methods { + if method.is_virtual || method.name_offset == 0 { + continue; + } + let method_name = method.name.as_str(); + let publishers: Vec<&EventSite> = sites + .iter() + .filter(|site| { + site.role == EventRole::Publisher + && same_class(&site.owner_fqn, &owner) + && site.method.eq_ignore_ascii_case(method_name) + }) + .collect(); + let subscriber_events: Vec<&str> = subscriptions + .iter() + .filter(|subscription| { + same_class( + &self.canonical_metadata_class(&subscription.listener_fqn), + &owner, + ) && subscription.method.eq_ignore_ascii_case(method_name) + }) + .map(|subscription| subscription.event.as_str()) + .chain(sites.iter().filter_map(|site| { + (site.role == EventRole::Subscriber + && same_class(&site.owner_fqn, &owner) + && site.method.eq_ignore_ascii_case(method_name)) + .then_some(site.event.as_str()) + })) + .collect(); + + if !publishers.is_empty() { + let mut locations: Vec = subscriptions + .iter() + .filter(|subscription| { + publishers.iter().any(|publisher| { + event_names_match( + &publisher.event, + &subscription.event, + &events_config, + ) + }) + }) + .filter_map(|subscription| { + self.symfony_class_member_declaration_location( + &self.canonical_metadata_class(&subscription.listener_fqn), + &subscription.method, + ) + }) + .collect(); + locations.extend( + sites + .iter() + .filter(|site| { + site.role == EventRole::Subscriber + && publishers.iter().any(|publisher| { + event_names_match( + &publisher.event, + &site.event, + &events_config, + ) + }) + }) + .filter_map(|site| self.source_site_location(site)), + ); + let locations = dedupe_locations(locations); + if let Some(lens) = + self.event_lens(uri, content, method.name_offset, "subscriber", locations) + { + lenses.push(lens); + } + } + + if !subscriber_events.is_empty() { + let locations = dedupe_locations( + sites + .iter() + .filter(|site| { + site.role == EventRole::Publisher + && subscriber_events.iter().any(|event| { + event_names_match(event, &site.event, &events_config) + }) + }) + .filter_map(|site| self.source_site_location(site)) + .collect(), + ); + if let Some(lens) = + self.event_lens(uri, content, method.name_offset, "publisher", locations) + { + lenses.push(lens); + } + } + } + } + lenses + } + + pub(crate) fn symfony_event_definitions_at( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option> { + let subjects = self.symfony_event_subjects_at(uri, content, position); + if subjects.is_empty() { + return None; + } + let (sites, subscriptions) = self.symfony_event_snapshot(); + let config = self.config().symfony.events; + let mut locations = Vec::new(); + for (event, role) in subjects { + match role { + EventRole::Publisher => { + locations.extend( + subscriptions + .iter() + .filter(|subscription| { + event_names_match(&event, &subscription.event, &config) + }) + .filter_map(|subscription| { + self.symfony_class_member_declaration_location( + &self.canonical_metadata_class(&subscription.listener_fqn), + &subscription.method, + ) + }), + ); + locations.extend( + sites + .iter() + .filter(|site| { + site.role == EventRole::Subscriber + && event_names_match(&event, &site.event, &config) + }) + .filter_map(|site| self.source_site_location(site)), + ); + } + EventRole::Subscriber => { + locations.extend( + sites + .iter() + .filter(|site| { + site.role == EventRole::Publisher + && event_names_match(&event, &site.event, &config) + }) + .filter_map(|site| self.source_site_location(site)), + ); + } + } + } + let locations = dedupe_locations(locations); + (!locations.is_empty()).then_some(locations) + } + + pub(crate) fn symfony_event_references_at( + &self, + uri: &str, + content: &str, + position: Position, + include_declaration: bool, + ) -> Option> { + let subjects = self.symfony_event_subjects_at(uri, content, position); + if subjects.is_empty() { + return None; + } + let (sites, subscriptions) = self.symfony_event_snapshot(); + let config = self.config().symfony.events; + let mut locations = Vec::new(); + for (event, _) in subjects { + if include_declaration { + locations.extend( + sites + .iter() + .filter(|site| { + site.role == EventRole::Publisher + && event_names_match(&event, &site.event, &config) + }) + .filter_map(|site| self.source_site_location(site)), + ); + } + locations.extend( + subscriptions + .iter() + .filter(|subscription| event_names_match(&event, &subscription.event, &config)) + .filter_map(|subscription| { + self.symfony_class_member_declaration_location( + &self.canonical_metadata_class(&subscription.listener_fqn), + &subscription.method, + ) + }), + ); + locations.extend( + sites + .iter() + .filter(|site| { + site.role == EventRole::Subscriber + && event_names_match(&event, &site.event, &config) + }) + .filter_map(|site| self.source_site_location(site)), + ); + } + Some(dedupe_locations(locations)) + } + + fn symfony_event_subjects_at( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Vec<(String, EventRole)> { + let offset = position_to_offset(content, position); + let (sites, subscriptions) = self.symfony_event_snapshot(); + let mut subjects: Vec<(String, EventRole)> = sites + .iter() + .filter(|site| { + site.uri == uri + && (contains_offset(site.start, site.end, offset) + || site + .event_start + .zip(site.event_end) + .is_some_and(|(start, end)| contains_offset(start, end, offset))) + }) + .map(|site| (site.event.clone(), site.role)) + .collect(); + + if let Some((owner, method)) = method_name_at_offset(self, uri, offset) { + let owner = self.canonical_metadata_class(&owner); + subjects.extend( + subscriptions + .iter() + .filter(|subscription| { + same_class( + &self.canonical_metadata_class(&subscription.listener_fqn), + &owner, + ) && subscription.method.eq_ignore_ascii_case(&method) + }) + .map(|subscription| (subscription.event.clone(), EventRole::Subscriber)), + ); + } + + subjects.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then((left.1 as u8).cmp(&(right.1 as u8))) + }); + subjects.dedup(); + subjects + } + + fn symfony_event_snapshot(&self) -> (Vec, Vec) { + let index = self.symfony_events.read(); + (index.source_sites(), index.subscriptions.clone()) + } + + fn canonical_metadata_class(&self, fqn: &str) -> String { + self.metadata_class_family(fqn) + .into_iter() + .next() + .unwrap_or_else(|| normalize_fqn(fqn)) + } + + fn source_site_location(&self, site: &EventSite) -> Option { + let uri: Url = site.uri.parse().ok()?; + let content = self.get_file_content(&site.uri)?; + let position = offset_to_position(&content, site.start as usize); + Some(Location::new(uri, Range::new(position, position))) + } + + fn symfony_class_member_declaration_location( + &self, + class_fqn: &str, + method_name: &str, + ) -> Option { + let class = self.find_or_load_class(class_fqn)?; + let method = class + .methods + .iter() + .find(|method| method.name.eq_ignore_ascii_case(method_name))?; + let uri = self.resolve_class_uri(class_fqn)?; + let content = self.get_file_content(&uri)?; + let start = offset_to_position(&content, method.name_offset as usize); + let end = offset_to_position(&content, method.name_offset as usize + method.name.len()); + Some(Location::new( + Url::parse(&uri).ok()?, + Range::new(start, end), + )) + } + + fn event_lens( + &self, + uri: &str, + content: &str, + method_offset: u32, + target: &str, + locations: Vec, + ) -> Option { + if locations.is_empty() { + return None; + } + let position = offset_to_position(content, method_offset as usize); + let line_start = content[..method_offset as usize] + .rfind('\n') + .map_or(0, |offset| offset + 1); + let indent = content[line_start..method_offset as usize] + .chars() + .take_while(|character| matches!(character, ' ' | '\t')) + .count() as u32; + let origin = Position::new(position.line, indent); + let title = format!( + "Symfony event: {} {}{}", + locations.len(), + target, + if locations.len() == 1 { "" } else { "s" } + ); + let origin_uri: Url = uri.parse().ok()?; + let command = if locations.len() == 1 + && self + .supports_show_document + .load(std::sync::atomic::Ordering::Acquire) + { + Command { + title, + command: "phpantom.navigateToPrototype".to_string(), + arguments: Some(vec![ + serde_json::json!(locations[0].uri), + serde_json::json!(locations[0].range.start), + ]), + } + } else { + Command { + title, + command: "editor.action.showReferences".to_string(), + arguments: Some(vec![ + serde_json::json!(origin_uri), + serde_json::json!(origin), + serde_json::json!(locations), + ]), + } + }; + Some(CodeLens { + range: Range::new(origin, origin), + command: Some(command), + data: None, + }) + } +} + +fn php_item_owner_method(item: &CallHierarchyItem) -> Option<(&str, &str)> { + let data = item.data.as_ref()?; + Some((data.get("owner")?.as_str()?, data.get("method")?.as_str()?)) +} + +fn synthetic_event_data(item: &CallHierarchyItem) -> Option<(&str, &str)> { + let data = item.data.as_ref()?; + Some((data.get("event")?.as_str()?, data.get("mode")?.as_str()?)) +} + +fn event_mode<'a>(event: &str, config: &'a SymfonyEventsConfig) -> &'a str { + for rule in &config.subscribers { + for (case, suffix) in &rule.transport_cases { + if !suffix.is_empty() + && event.ends_with(suffix) + && case.to_ascii_lowercase().contains("async") + { + return "async"; + } + } + } + if config.ignored_suffixes.iter().any(|suffix| { + !suffix.is_empty() + && event.ends_with(suffix) + && suffix.to_ascii_lowercase().contains("async") + }) { + return "async"; + } + "sync" +} + +fn item_order(left: &CallHierarchyItem, right: &CallHierarchyItem) -> std::cmp::Ordering { + left.uri + .as_str() + .cmp(right.uri.as_str()) + .then( + left.selection_range + .start + .line + .cmp(&right.selection_range.start.line), + ) + .then( + left.selection_range + .start + .character + .cmp(&right.selection_range.start.character), + ) + .then(left.name.cmp(&right.name)) +} + +fn dedupe_outgoing_calls( + mut calls: Vec, +) -> Vec { + calls.sort_by(|left, right| item_order(&left.to, &right.to)); + calls.dedup_by(|left, right| left.to == right.to); + calls +} + +fn dedupe_incoming_calls( + mut calls: Vec, +) -> Vec { + calls.sort_by(|left, right| item_order(&left.from, &right.from)); + calls.dedup_by(|left, right| left.from == right.from); + calls +} + +#[allow(clippy::too_many_arguments)] +fn scan_publisher_attribute( + uri: &str, + content: &str, + arguments: &[PhpArgument<'_>], + rule: &SymfonyEventPublisherConfig, + owner_fqn: &str, + method: &str, + method_start: usize, + method_end: usize, + sites: &mut Vec, +) { + if rule.name_template.trim().is_empty() { + return; + } + let explicit = + configured_argument(arguments, rule.name_argument.as_deref(), rule.name_position).and_then( + |argument| { + let raw = argument_value(content, argument); + if raw.eq_ignore_ascii_case("null") { + None + } else { + string_argument(content, argument) + } + }, + ); + + let dispatches = configured_argument( + arguments, + rule.dispatch_argument.as_deref(), + rule.dispatch_position, + ) + .map_or_else( + || rule.default_dispatch.clone(), + |argument| dispatch_values(argument_value(content, argument), rule), + ); + + for dispatch in dispatches { + if should_skip_dispatch(content, arguments, rule, &dispatch) { + continue; + } + let (event, event_start, event_end) = if let Some((name, start, end)) = &explicit { + ( + render_event_template( + rule.explicit_name_template.as_deref().unwrap_or("{name}"), + owner_fqn, + method, + &dispatch, + Some(name), + &rule.default_methods, + ), + Some(*start as u32), + Some(*end as u32), + ) + } else { + ( + render_event_template( + &rule.name_template, + owner_fqn, + method, + &dispatch, + None, + &rule.default_methods, + ), + None, + None, + ) + }; + if event.is_empty() { + continue; + } + sites.push(EventSite { + event, + owner_fqn: owner_fqn.to_string(), + method: method.to_string(), + uri: uri.to_string(), + start: method_start as u32, + end: method_end as u32, + event_start, + event_end, + role: EventRole::Publisher, + }); + } +} + +#[allow(clippy::too_many_arguments)] +fn scan_subscriber_attribute( + uri: &str, + content: &str, + arguments: &[PhpArgument<'_>], + rule: &SymfonyEventSubscriberConfig, + owner_fqn: &str, + method: &str, + method_start: usize, + method_end: usize, + sites: &mut Vec, +) { + let Some(argument) = + configured_argument(arguments, rule.name_argument.as_deref(), rule.name_position) + else { + return; + }; + let Some((mut event, event_start, event_end)) = string_argument(content, argument) else { + return; + }; + if let Some(transport) = configured_argument( + arguments, + rule.transport_argument.as_deref(), + rule.transport_position, + ) { + let raw = argument_value(content, transport); + for (case, suffix) in &rule.transport_cases { + if enum_case_present(raw, case) { + event.push_str(suffix); + break; + } + } + } + sites.push(EventSite { + event, + owner_fqn: owner_fqn.to_string(), + method: method.to_string(), + uri: uri.to_string(), + start: method_start as u32, + end: method_end as u32, + event_start: Some(event_start as u32), + event_end: Some(event_end as u32), + role: EventRole::Subscriber, + }); +} + +fn dispatch_values(value: &str, rule: &SymfonyEventPublisherConfig) -> Vec { + let decoded = + crate::text_scan::decode_php_string_literal(value.trim()).map(|value| value.into_owned()); + let mut dispatches = Vec::new(); + for (case, dispatch) in &rule.dispatch_cases { + if enum_case_present(value, case) + || decoded + .as_deref() + .is_some_and(|value| value == case || value == dispatch) + { + dispatches.push(dispatch.clone()); + } + } + dispatches.sort(); + dispatches.dedup(); + dispatches +} + +fn should_skip_dispatch( + content: &str, + arguments: &[PhpArgument<'_>], + rule: &SymfonyEventPublisherConfig, + dispatch: &str, +) -> bool { + rule.skip.iter().any(|skip| { + skip.dispatch.eq_ignore_ascii_case(dispatch) + && configured_argument(arguments, Some(&skip.argument), skip.position).is_some_and( + |argument| !argument_value(content, argument).eq_ignore_ascii_case("null"), + ) + }) +} + +fn render_event_template( + template: &str, + owner_fqn: &str, + method: &str, + dispatch: &str, + explicit_name: Option<&str>, + default_methods: &[String], +) -> String { + let short_class = owner_fqn.rsplit('\\').next().unwrap_or(owner_fqn); + let default_method = method.is_empty() + || default_methods + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(method)); + let method_suffix = if default_method { + String::new() + } else { + format!(".{method}") + }; + let method_suffix_snake = if default_method { + String::new() + } else { + format!(".{}", snake_case(method)) + }; + template + .replace("{dispatch}", dispatch) + .replace("{class}", short_class) + .replace("{class_snake}", &snake_case(short_class)) + .replace("{method}", method) + .replace("{method_snake}", &snake_case(method)) + .replace("{method_suffix}", &method_suffix) + .replace("{method_suffix_snake}", &method_suffix_snake) + .replace("{name}", explicit_name.unwrap_or_default()) +} + +fn snake_case(value: &str) -> String { + let mut snake = String::with_capacity(value.len() + 8); + let mut previous_is_word = false; + for character in value.chars() { + if character.is_ascii_uppercase() && previous_is_word { + snake.push('_'); + } + snake.extend(character.to_lowercase()); + previous_is_word = character.is_alphanumeric() || character == '_'; + } + snake +} + +fn event_names_match(lhs: &str, rhs: &str, config: &SymfonyEventsConfig) -> bool { + canonical_event_name(lhs, config) == canonical_event_name(rhs, config) +} + +fn canonical_event_name<'a>(mut event: &'a str, config: &SymfonyEventsConfig) -> &'a str { + loop { + let mut changed = false; + if let Some(stripped) = config + .ignored_prefixes + .iter() + .find_map(|prefix| event.strip_prefix(prefix)) + { + event = stripped; + changed = true; + } + if let Some(stripped) = config + .ignored_suffixes + .iter() + .find_map(|suffix| event.strip_suffix(suffix)) + { + event = stripped; + changed = true; + } + if !changed { + return event; + } + } +} + +fn enum_case_present(value: &str, case: &str) -> bool { + let needle = format!("::{case}"); + value.match_indices(&needle).any(|(start, _)| { + value + .as_bytes() + .get(start + needle.len()) + .is_none_or(|byte| !is_php_identifier(*byte)) + }) +} + +fn class_at_method(classes: &[std::sync::Arc], offset: usize) -> Option<&ClassInfo> { + classes + .iter() + .find(|class| class.start_offset as usize <= offset && offset <= class.end_offset as usize) + .map(AsRef::as_ref) +} + +fn method_name_at_offset(backend: &Backend, uri: &str, offset: u32) -> Option<(String, String)> { + let classes = backend.symbols.uri_classes_index.read(); + for class in classes.get(uri)? { + for method in &class.methods { + if contains_offset( + method.name_offset, + method.name_offset + method.name.len() as u32, + offset, + ) { + return Some((class.fqn().to_string(), method.name.to_string())); + } + } + } + None +} + +fn dedupe_locations(locations: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut unique = Vec::new(); + for location in locations { + let key = ( + location.uri.to_string(), + location.range.start.line, + location.range.start.character, + ); + if seen.insert(key) { + unique.push(location); + } + } + unique.sort_by(|left, right| { + left.uri + .as_str() + .cmp(right.uri.as_str()) + .then(left.range.start.line.cmp(&right.range.start.line)) + .then(left.range.start.character.cmp(&right.range.start.character)) + }); + unique +} + +fn contains_offset(start: u32, end: u32, offset: u32) -> bool { + start <= offset && offset <= end +} + +fn same_class(left: &str, right: &str) -> bool { + normalize_fqn(left).eq_ignore_ascii_case(&normalize_fqn(right)) +} + +fn normalize_fqn(name: &str) -> String { + name.trim().trim_start_matches('\\').to_string() +} + +fn uri_path(uri: &str) -> &str { + uri.strip_prefix("file://") + .unwrap_or(uri) + .split('?') + .next() + .unwrap_or(uri) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn example_publisher_rule() -> SymfonyEventPublisherConfig { + SymfonyEventPublisherConfig { + attribute: "Acme\\Event\\Publish".to_string(), + name_argument: Some("name".to_string()), + name_position: Some(2), + dispatch_argument: Some("dispatch".to_string()), + dispatch_position: Some(4), + default_dispatch: vec!["post".to_string()], + dispatch_cases: [ + ("PRE".to_string(), "pre".to_string()), + ("POST".to_string(), "post".to_string()), + ("EXCEPTION".to_string(), "exception".to_string()), + ] + .into_iter() + .collect(), + name_template: "{dispatch}.{class_snake}{method_suffix_snake}".to_string(), + explicit_name_template: Some("{name}".to_string()), + default_methods: vec!["execute".to_string(), "__invoke".to_string()], + skip: vec![crate::config::SymfonyEventSkipConfig { + dispatch: "post".to_string(), + argument: "messageClass".to_string(), + position: Some(5), + }], + } + } + + #[test] + fn renders_configured_event_names() { + assert_eq!( + render_event_template( + "{dispatch}.{class_snake}{method_suffix_snake}", + "App\\UseCase\\HTTPReport", + "refreshCache", + "post", + None, + &["execute".to_string(), "__invoke".to_string()], + ), + "post.h_t_t_p_report.refresh_cache" + ); + assert_eq!( + render_event_template( + "{dispatch}.{class_snake}{method_suffix_snake}", + "App\\UseCase\\PublishCourse", + "execute", + "post", + None, + &["execute".to_string()], + ), + "post.publish_course" + ); + } + + #[test] + fn configured_aliases_match_compiled_event_names() { + let config = SymfonyEventsConfig { + ignored_prefixes: vec!["use_case.".to_string()], + ignored_suffixes: vec![".async".to_string()], + ..SymfonyEventsConfig::default() + }; + assert!(event_names_match( + "post.publish_course", + "use_case.post.publish_course.async", + &config + )); + } + + #[test] + fn publisher_rule_uses_named_arguments_and_conditional_dispatch_skips() { + let content = "dispatch: [On::PRE, On::POST], messageClass: CoursePublished::class"; + let arguments = php_arguments(content, 0, content.len()); + let mut sites = Vec::new(); + scan_publisher_attribute( + "file:///project/PublishCourse.php", + content, + &arguments, + &example_publisher_rule(), + "App\\UseCase\\PublishCourse", + "execute", + 0, + "execute".len(), + &mut sites, + ); + + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].event, "pre.publish_course"); + } + + #[test] + fn explicit_publisher_names_bypass_the_derived_template() { + let content = "name: 'course.failed'"; + let arguments = php_arguments(content, 0, content.len()); + let mut sites = Vec::new(); + scan_publisher_attribute( + "file:///project/PublishCourse.php", + content, + &arguments, + &example_publisher_rule(), + "App\\UseCase\\PublishCourse", + "execute", + 0, + "execute".len(), + &mut sites, + ); + + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].event, "course.failed"); + assert_eq!(sites[0].event_start, Some(7)); + } + + #[test] + fn proxy_publishers_flow_through_synthetic_event_nodes() { + let backend = Backend::new_test(); + *backend.workspace.config.lock() = toml::from_str( + r#" +[symfony.events] +ignored-suffixes = [".async"] + +[[symfony.events.publishers]] +attribute = 'Acme\Event\Publish' +name-argument = "name" +name-position = 0 +default-dispatch = ["post"] +name-template = "{dispatch}.{class_snake}" +explicit-name-template = "{name}" + +[[symfony.events.subscribers]] +attribute = 'Acme\Event\Listen' +name-argument = "name" +name-position = 0 +transport-argument = "transport" +transport-position = 1 +transport-cases = { ASYNC = ".async" } +"#, + ) + .unwrap(); + backend.replace_proxy_relations( + "test", + vec![crate::proxy_metadata::ProxyRelation { + proxy_fqn: "Generated\\JobProxy".to_string(), + target_fqn: "App\\Job".to_string(), + }], + ); + + let publisher_uri = "file:///generated_proxy.php"; + let publisher = r#", +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExpressionChain { + root: String, + root_start: u32, + root_end: u32, + segments: Vec, + method_offset: u32, + contract: ExpressionContract, +} + +struct ExpressionRoot { + file: FileContext, + definitions: Vec, + classes: Vec>, +} + +enum ExpressionMemberStatus { + Valid, + Missing(Vec), + Unresolved, +} + +/// A configured ExpressionLanguage member that does not exist in PHP. +pub(crate) struct ExpressionProblem { + pub(crate) start: usize, + pub(crate) end: usize, + pub(crate) member: String, + pub(crate) is_method: bool, + pub(crate) classes: Vec, +} + +impl Backend { + /// Resolve a configured ExpressionLanguage root or member under the cursor. + pub(crate) fn symfony_expression_definitions_at( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option> { + let offset = position_to_offset(content, position); + for chain in self.symfony_expression_chains(uri, content) { + if contains_offset(chain.root_start, chain.root_end, offset) { + let definitions = self + .resolve_expression_root(uri, content, &chain)? + .definitions; + return (!definitions.is_empty()).then_some(definitions); + } + + let Some(index) = chain + .segments + .iter() + .position(|segment| contains_offset(segment.start, segment.end, offset)) + else { + continue; + }; + let root = self.resolve_expression_root(uri, content, &chain)?; + let class_loader = self.class_loader(&root.file); + let path = &chain.segments[..=index]; + let classes = expression_path_target_classes( + root.classes, + path, + &class_loader, + Some(&self.resolved_class_cache), + )?; + let target = path.last()?; + let mut locations = Vec::new(); + for class in classes { + let mut location = self + .metadata_class_family(&class.fqn()) + .iter() + .find_map(|fqn| self.class_member_declaration_location(fqn, &target.name)); + if location.is_none() + && target.kind == ExpressionAccessKind::Property + && matches!(target.name.as_str(), "name" | "value") + && class.kind == ClassLikeKind::Enum + { + location = self.class_declaration_location(&class.fqn()); + } + if let Some(location) = location { + locations.push(location); + } + } + let locations = dedupe_locations(locations); + return (!locations.is_empty()).then_some(locations); + } + None + } + + /// Find missing members in configured ExpressionLanguage strings. + pub(crate) fn symfony_expression_problems( + &self, + uri: &str, + content: &str, + ) -> Vec { + let mut problems = Vec::new(); + for chain in self.symfony_expression_chains(uri, content) { + let Some(root) = self.resolve_expression_root(uri, content, &chain) else { + continue; + }; + if root.classes.is_empty() { + continue; + } + let class_loader = self.class_loader(&root.file); + let mut classes = root.classes; + for segment in &chain.segments { + match expression_member_status( + &classes, + segment, + &class_loader, + Some(&self.resolved_class_cache), + ) { + ExpressionMemberStatus::Missing(mut names) => { + names.sort(); + names.dedup(); + problems.push(ExpressionProblem { + start: segment.start as usize, + end: segment.end as usize, + member: segment.name.clone(), + is_method: segment.kind == ExpressionAccessKind::Method, + classes: names, + }); + break; + } + ExpressionMemberStatus::Unresolved => break, + ExpressionMemberStatus::Valid => {} + } + classes = next_expression_classes(&classes, segment, &class_loader); + if classes.is_empty() { + break; + } + } + } + problems + } + + fn symfony_expression_chains(&self, uri: &str, content: &str) -> Vec { + let config = self.config().symfony.expression_language; + if !is_php_document(uri) || (config.attributes.is_empty() && config.constructors.is_empty()) + { + return Vec::new(); + } + + let use_map = self + .file_imports + .read() + .get(uri) + .cloned() + .unwrap_or_default(); + let mut chains = Vec::new(); + + for attribute in attribute_calls(content) { + let namespace = crate::text_scan::namespace_at_offset(content, attribute.name_start) + .map(str::to_string); + let raw_name = &content[attribute.name_start..attribute.name_end]; + let attribute_fqn = + normalize_fqn(&crate::util::resolve_to_fqn(raw_name, &use_map, &namespace)); + let Some((method_start, _)) = method_after_attribute(content, attribute.group_end) + else { + continue; + }; + let Some((args_start, args_end)) = attribute.args else { + continue; + }; + let arguments = php_arguments(content, args_start, args_end); + + for rule in config + .attributes + .iter() + .filter(|rule| same_fqn(&rule.attribute, &attribute_fqn)) + { + let position = rule + .position + .or_else(|| rule.argument.is_none().then_some(0)); + let Some(argument) = + configured_argument(&arguments, rule.argument.as_deref(), position) + else { + continue; + }; + add_argument_expressions( + content, + argument, + method_start, + attribute_contract(rule), + &mut chains, + ); + } + + for rule in config.constructors.iter().filter(|rule| { + attribute_prefix_allowed(&attribute_fqn, &rule.inside_attribute_prefixes) + }) { + for (start, end) in constructor_argument_lists( + content, args_start, args_end, rule, &use_map, &namespace, + ) { + let arguments = php_arguments(content, start, end); + let position = rule + .position + .or_else(|| rule.argument.is_none().then_some(0)); + let Some(argument) = + configured_argument(&arguments, rule.argument.as_deref(), position) + else { + continue; + }; + add_argument_expressions( + content, + argument, + method_start, + constructor_contract(rule), + &mut chains, + ); + } + } + } + + chains.sort_by(|left, right| { + left.root_start + .cmp(&right.root_start) + .then(left.root_end.cmp(&right.root_end)) + .then(left.method_offset.cmp(&right.method_offset)) + }); + chains.dedup(); + chains + } + + fn resolve_expression_root( + &self, + uri: &str, + content: &str, + chain: &ExpressionChain, + ) -> Option { + let file = self.file_context_at(uri, chain.method_offset); + let (owner, method) = method_at_offset(&file.classes, chain.method_offset)?; + let source = chain + .contract + .bindings + .get(&chain.root) + .map(String::as_str) + .or_else(|| { + chain + .contract + .method_parameters + .then_some(chain.root.as_str()) + })?; + + let class_loader = self.class_loader(&file); + let (definitions, classes) = if source.eq_ignore_ascii_case("return") { + let definitions = current_file_location( + uri, + content, + method.name_offset as usize, + method.name_offset as usize + method.name.len(), + ) + .into_iter() + .collect(); + let classes = method + .return_type + .as_ref() + .map_or_else(Vec::new, |type_hint| { + crate::type_engine::type_resolution::type_hint_to_classes_typed( + type_hint, + &owner.fqn(), + &file.classes, + &class_loader, + ) + }); + (definitions, classes) + } else if let Some(class_fqn) = source.strip_prefix("class:") { + let class_fqn = normalize_fqn(class_fqn.trim()); + let definitions = self + .metadata_class_family(&class_fqn) + .iter() + .filter_map(|fqn| self.class_declaration_location(fqn)) + .collect(); + let classes = self + .metadata_class_family(&class_fqn) + .iter() + .filter_map(|fqn| class_loader(fqn)) + .collect(); + (definitions, classes) + } else { + let selector = source.strip_prefix("parameter:").unwrap_or(source); + let parameter = if let Ok(index) = selector.parse::() { + method.parameters.get(index) + } else { + let name = selector.trim().trim_start_matches('$'); + method + .parameters + .iter() + .find(|parameter| parameter.name.trim_start_matches('$') == name) + }?; + let parameter_name = parameter.name.trim_start_matches('$'); + let parameter_offset = self + .symbol_map_for(uri)? + .var_defs + .iter() + .filter(|site| { + site.kind == VarDefKind::Parameter + && site.name == parameter_name + && site.offset > method.name_offset + && (owner.end_offset == 0 || site.offset < owner.end_offset) + }) + .map(|site| site.offset) + .min()?; + let definitions = current_file_location( + uri, + content, + parameter_offset as usize + 1, + parameter_offset as usize + 1 + parameter_name.len(), + ) + .into_iter() + .collect(); + let classes = parameter + .type_hint + .as_ref() + .map_or_else(Vec::new, |type_hint| { + crate::type_engine::type_resolution::type_hint_to_classes_typed( + type_hint, + &owner.fqn(), + &file.classes, + &class_loader, + ) + }); + (definitions, classes) + }; + + drop(class_loader); + Some(ExpressionRoot { + file, + definitions: dedupe_locations(definitions), + classes: dedupe_classes(classes), + }) + } +} + +fn attribute_contract(rule: &SymfonyExpressionAttributeConfig) -> ExpressionContract { + ExpressionContract { + method_parameters: rule.method_parameters, + bindings: rule.bindings.clone(), + } +} + +fn constructor_contract(rule: &SymfonyExpressionConstructorConfig) -> ExpressionContract { + ExpressionContract { + method_parameters: rule.method_parameters, + bindings: rule.bindings.clone(), + } +} + +fn add_argument_expressions( + content: &str, + argument: PhpArgument<'_>, + method_offset: usize, + contract: ExpressionContract, + chains: &mut Vec, +) { + for (start, end) in php_string_literals(content, argument.value_start, argument.value_end) { + scan_expression( + content, + start, + end, + method_offset as u32, + contract.clone(), + chains, + ); + } +} + +fn constructor_argument_lists( + content: &str, + start: usize, + end: usize, + rule: &SymfonyExpressionConstructorConfig, + use_map: &HashMap, + namespace: &Option, +) -> Vec<(usize, usize)> { + let bytes = content.as_bytes(); + let mut lists = Vec::new(); + let mut cursor = start; + while cursor < end { + match bytes[cursor] { + b'\'' | b'"' => { + cursor = crate::text_scan::skip_string_forward(bytes, cursor).min(end); + continue; + } + b'/' if bytes.get(cursor + 1) == Some(&b'/') => { + cursor = crate::text_scan::skip_line_comment(bytes, cursor).min(end); + continue; + } + b'/' if bytes.get(cursor + 1) == Some(&b'*') => { + cursor = crate::text_scan::skip_block_comment(bytes, cursor).min(end); + continue; + } + _ => {} + } + if !content[cursor..].starts_with("new") + || bytes + .get(cursor.wrapping_sub(1)) + .is_some_and(|byte| is_php_identifier(*byte)) + || bytes + .get(cursor + 3) + .is_some_and(|byte| is_php_identifier(*byte)) + { + cursor += 1; + continue; + } + + let mut name_start = cursor + 3; + skip_whitespace(bytes, &mut name_start); + let mut name_end = name_start; + while name_end < end && is_php_name(bytes[name_end]) { + name_end += 1; + } + if name_end == name_start { + cursor += 3; + continue; + } + let mut open = name_end; + skip_whitespace(bytes, &mut open); + if open >= end || bytes[open] != b'(' { + cursor = name_end; + continue; + } + let Some(close) = crate::text_scan::find_matching_forward(content, open, b'(', b')') + .filter(|close| *close <= end) + else { + cursor = open + 1; + continue; + }; + let fqn = crate::util::resolve_to_fqn(&content[name_start..name_end], use_map, namespace); + if same_fqn(&fqn, &rule.class) { + lists.push((open + 1, close)); + } + cursor = close + 1; + } + lists +} + +fn php_string_literals(content: &str, start: usize, end: usize) -> Vec<(usize, usize)> { + let bytes = content.as_bytes(); + let mut literals = Vec::new(); + let mut cursor = start; + while cursor < end { + match bytes[cursor] { + b'\'' | b'"' => { + let after = crate::text_scan::skip_string_forward(bytes, cursor); + if after <= end && after > cursor + 1 { + literals.push((cursor + 1, after - 1)); + } + cursor = after.min(end); + } + b'/' if bytes.get(cursor + 1) == Some(&b'/') => { + cursor = crate::text_scan::skip_line_comment(bytes, cursor).min(end); + } + b'/' if bytes.get(cursor + 1) == Some(&b'*') => { + cursor = crate::text_scan::skip_block_comment(bytes, cursor).min(end); + } + _ => cursor += 1, + } + } + literals +} + +fn scan_expression( + content: &str, + start: usize, + end: usize, + method_offset: u32, + contract: ExpressionContract, + chains: &mut Vec, +) { + let expression = &content[start..end]; + let bytes = expression.as_bytes(); + let mut cursor = 0usize; + let mut quote = None; + while cursor < bytes.len() { + let byte = bytes[cursor]; + if let Some(active_quote) = quote { + if byte == b'\\' { + cursor = (cursor + 2).min(bytes.len()); + continue; + } + if byte == active_quote { + quote = None; + } + cursor += 1; + continue; + } + if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + cursor += 1; + continue; + } + if !is_expression_identifier_start(byte) { + cursor += 1; + continue; + } + + let root_start = cursor; + cursor += 1; + while cursor < bytes.len() && is_php_identifier(bytes[cursor]) { + cursor += 1; + } + let root_end = cursor; + if previous_non_whitespace(bytes, root_start) == Some(b'.') { + continue; + } + let mut chain = ExpressionChain { + root: expression[root_start..root_end].to_string(), + root_start: (start + root_start) as u32, + root_end: (start + root_end) as u32, + segments: Vec::new(), + method_offset, + contract: contract.clone(), + }; + + let mut chain_cursor = root_end; + loop { + skip_whitespace(bytes, &mut chain_cursor); + if bytes.get(chain_cursor..chain_cursor + 2) == Some(b"?.") { + chain_cursor += 2; + } else if bytes.get(chain_cursor) == Some(&b'.') { + chain_cursor += 1; + } else { + break; + } + skip_whitespace(bytes, &mut chain_cursor); + if !bytes + .get(chain_cursor) + .is_some_and(|byte| is_expression_identifier_start(*byte)) + { + break; + } + let member_start = chain_cursor; + chain_cursor += 1; + while chain_cursor < bytes.len() && is_php_identifier(bytes[chain_cursor]) { + chain_cursor += 1; + } + let member_end = chain_cursor; + let mut after_member = chain_cursor; + skip_whitespace(bytes, &mut after_member); + let kind = if bytes.get(after_member) == Some(&b'(') { + ExpressionAccessKind::Method + } else { + ExpressionAccessKind::Property + }; + chain.segments.push(ExpressionSegment { + name: expression[member_start..member_end].to_string(), + kind, + start: (start + member_start) as u32, + end: (start + member_end) as u32, + }); + chain_cursor = if kind == ExpressionAccessKind::Method { + crate::text_scan::find_matching_forward(expression, after_member, b'(', b')') + .map_or(member_end, |close| close + 1) + } else { + member_end + }; + } + chains.push(chain); + } +} + +fn method_at_offset( + classes: &[Arc], + offset: u32, +) -> Option<(Arc, Arc)> { + classes.iter().find_map(|class| { + class + .methods + .iter() + .find(|method| method.name_offset == offset) + .map(|method| (Arc::clone(class), Arc::clone(method))) + }) +} + +fn expression_path_target_classes( + mut classes: Vec>, + path: &[ExpressionSegment], + class_loader: &dyn Fn(&str) -> Option>, + cache: Option<&crate::virtual_members::ResolvedClassCache>, +) -> Option>> { + for (index, segment) in path.iter().enumerate() { + let (matching, _) = matching_member_classes(&classes, segment, class_loader, cache); + if matching.is_empty() { + return None; + } + if index + 1 == path.len() { + return Some(matching); + } + classes = next_expression_classes(&matching, segment, class_loader); + if classes.is_empty() { + return None; + } + } + None +} + +fn expression_member_status( + classes: &[Arc], + segment: &ExpressionSegment, + class_loader: &dyn Fn(&str) -> Option>, + cache: Option<&crate::virtual_members::ResolvedClassCache>, +) -> ExpressionMemberStatus { + let (matching, dynamic) = matching_member_classes(classes, segment, class_loader, cache); + if !matching.is_empty() { + return ExpressionMemberStatus::Valid; + } + if dynamic { + return ExpressionMemberStatus::Unresolved; + } + ExpressionMemberStatus::Missing( + classes + .iter() + .map(|class| class.fqn().to_string()) + .collect(), + ) +} + +fn matching_member_classes( + classes: &[Arc], + segment: &ExpressionSegment, + class_loader: &dyn Fn(&str) -> Option>, + cache: Option<&crate::virtual_members::ResolvedClassCache>, +) -> (Vec>, bool) { + let mut matching = Vec::new(); + let mut dynamic = false; + for class in classes { + let resolved = if class.name == "__object_shape" { + Arc::clone(class) + } else { + crate::virtual_members::resolve_class_fully_maybe_cached(class, class_loader, cache) + }; + let exists = match segment.kind { + ExpressionAccessKind::Property => resolved.has_property(&segment.name), + ExpressionAccessKind::Method => resolved.has_method(&segment.name), + }; + if exists { + matching.push(Arc::clone(class)); + continue; + } + dynamic |= match segment.kind { + ExpressionAccessKind::Property => { + resolved.name.eq_ignore_ascii_case("stdClass") || resolved.has_method("__get") + } + ExpressionAccessKind::Method => resolved.has_method("__call"), + }; + } + (matching, dynamic) +} + +fn next_expression_classes( + classes: &[Arc], + segment: &ExpressionSegment, + class_loader: &dyn Fn(&str) -> Option>, +) -> Vec> { + let mut next = Vec::new(); + for class in classes { + match segment.kind { + ExpressionAccessKind::Property => { + next.extend(crate::type_engine::type_resolution::resolve_property_types( + &segment.name, + class, + classes, + class_loader, + )); + } + ExpressionAccessKind::Method => { + if let Some(return_type) = crate::inheritance::resolve_method_return_type( + class, + &segment.name, + class_loader, + ) { + next.extend( + crate::type_engine::type_resolution::type_hint_to_classes_typed( + &return_type, + &class.fqn(), + classes, + class_loader, + ), + ); + } + } + } + } + dedupe_classes(next) +} + +fn current_file_location(uri: &str, content: &str, start: usize, end: usize) -> Option { + let uri = Url::parse(uri).ok()?; + Some(Location::new( + uri, + Range::new( + offset_to_position(content, start), + offset_to_position(content, end), + ), + )) +} + +fn dedupe_classes(classes: Vec>) -> Vec> { + let mut seen = HashSet::new(); + classes + .into_iter() + .filter(|class| seen.insert(class.fqn().to_ascii_lowercase())) + .collect() +} + +fn dedupe_locations(locations: Vec) -> Vec { + let mut seen = HashSet::new(); + locations + .into_iter() + .filter(|location| { + seen.insert(( + location.uri.to_string(), + location.range.start.line, + location.range.start.character, + )) + }) + .collect() +} + +fn attribute_prefix_allowed(attribute: &str, prefixes: &[String]) -> bool { + prefixes.is_empty() + || prefixes.iter().any(|prefix| { + let prefix = normalize_fqn(prefix); + attribute + .get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(&prefix)) + }) +} + +fn same_fqn(left: &str, right: &str) -> bool { + normalize_fqn(left).eq_ignore_ascii_case(&normalize_fqn(right)) +} + +fn normalize_fqn(name: &str) -> String { + name.trim().trim_start_matches('\\').to_string() +} + +fn is_php_document(uri: &str) -> bool { + uri.split(['?', '#']) + .next() + .unwrap_or(uri) + .to_ascii_lowercase() + .ends_with(".php") +} + +fn is_expression_identifier_start(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphabetic() +} + +fn previous_non_whitespace(bytes: &[u8], start: usize) -> Option { + bytes[..start] + .iter() + .rev() + .copied() + .find(|byte| !byte.is_ascii_whitespace()) +} + +fn contains_offset(start: u32, end: u32, offset: u32) -> bool { + start <= offset && offset <= end +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scans_nullable_member_chains_and_method_calls() { + let content = "'request?.course().owner.id'"; + let mut chains = Vec::new(); + scan_expression( + content, + 1, + content.len() - 1, + 42, + ExpressionContract { + method_parameters: true, + bindings: HashMap::new(), + }, + &mut chains, + ); + + assert_eq!(chains.len(), 1); + assert_eq!(chains[0].root, "request"); + assert_eq!(chains[0].segments.len(), 3); + assert_eq!(chains[0].segments[0].kind, ExpressionAccessKind::Method); + assert_eq!(chains[0].segments[2].name, "id"); + } + + #[test] + fn extracts_each_string_from_an_array_argument() { + let content = "['request.id', 'request.owner.id']"; + assert_eq!( + php_string_literals(content, 0, content.len()), + vec![(2, 12), (16, 32)] + ); + } +} diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs new file mode 100644 index 000000000..a33ec380e --- /dev/null +++ b/src/symfony/mod.rs @@ -0,0 +1,12 @@ +//! Symfony-specific adapters. +//! +//! The modules here recover framework runtime wiring behind small metadata +//! interfaces. Package-specific attributes and naming rules remain project +//! configuration rather than constants in the language server. + +pub(crate) mod container; +mod events; +mod expressions; +mod php_attributes; + +pub(crate) use events::SymfonyEventIndex; diff --git a/src/symfony/php_attributes.rs b/src/symfony/php_attributes.rs new file mode 100644 index 000000000..0cfecce1b --- /dev/null +++ b/src/symfony/php_attributes.rs @@ -0,0 +1,255 @@ +//! Small source scanner for PHP method attributes. + +use crate::text_scan::find_matching_forward; + +#[derive(Clone, Copy)] +pub(super) struct AttributeCall { + pub name_start: usize, + pub name_end: usize, + pub args: Option<(usize, usize)>, + pub group_end: usize, +} + +#[derive(Clone, Copy)] +pub(super) struct PhpArgument<'a> { + pub name: Option<&'a str>, + pub value_start: usize, + pub value_end: usize, +} + +pub(super) fn attribute_calls(content: &str) -> Vec { + let mut calls = Vec::new(); + let mut search = 0usize; + while let Some(relative) = content[search..].find("#[") { + let bracket = search + relative + 1; + let Some(group_close) = find_matching_forward(content, bracket, b'[', b']') else { + break; + }; + for (start, end) in split_top_level(content, bracket + 1, group_close) { + let Some((segment_start, segment_end)) = trim_range(content, start, end) else { + continue; + }; + let mut name_end = segment_start; + while content + .as_bytes() + .get(name_end) + .is_some_and(|byte| is_php_name(*byte)) + { + name_end += 1; + } + if name_end == segment_start { + continue; + } + let mut cursor = name_end; + skip_whitespace(content.as_bytes(), &mut cursor); + let args = if cursor < segment_end && content.as_bytes()[cursor] == b'(' { + find_matching_forward(content, cursor, b'(', b')') + .filter(|close| *close < segment_end) + .map(|close| (cursor + 1, close)) + } else { + None + }; + calls.push(AttributeCall { + name_start: segment_start, + name_end, + args, + group_end: group_close + 1, + }); + } + search = group_close + 1; + } + calls +} + +pub(super) fn method_after_attribute(content: &str, group_end: usize) -> Option<(usize, usize)> { + let bytes = content.as_bytes(); + let limit = (group_end + 8192).min(content.len()); + let relative = content[group_end..limit].find("function")?; + let function = group_end + relative; + if bytes + .get(function.wrapping_sub(1)) + .is_some_and(|byte| is_php_identifier(*byte)) + || bytes + .get(function + "function".len()) + .is_some_and(|byte| is_php_identifier(*byte)) + { + return None; + } + let mut start = function + "function".len(); + skip_whitespace(bytes, &mut start); + if bytes.get(start) == Some(&b'&') { + start += 1; + skip_whitespace(bytes, &mut start); + } + let mut end = start; + while bytes.get(end).is_some_and(|byte| is_php_identifier(*byte)) { + end += 1; + } + (end > start).then_some((start, end)) +} + +pub(super) fn php_arguments(content: &str, start: usize, end: usize) -> Vec> { + split_top_level(content, start, end) + .into_iter() + .filter_map(|(start, end)| { + let (start, end) = trim_range(content, start, end)?; + if let Some(colon) = top_level_colon(content, start, end) + && let Some((name_start, name_end)) = trim_range(content, start, colon) + && content[name_start..name_end] + .bytes() + .enumerate() + .all(|(index, byte)| { + if index == 0 { + byte == b'_' || byte.is_ascii_alphabetic() + } else { + is_php_identifier(byte) + } + }) + { + let (value_start, value_end) = trim_range(content, colon + 1, end)?; + return Some(PhpArgument { + name: Some(&content[name_start..name_end]), + value_start, + value_end, + }); + } + Some(PhpArgument { + name: None, + value_start: start, + value_end: end, + }) + }) + .collect() +} + +pub(super) fn configured_argument<'a>( + arguments: &'a [PhpArgument<'a>], + name: Option<&str>, + position: Option, +) -> Option> { + name.and_then(|name| { + arguments + .iter() + .copied() + .find(|argument| argument.name == Some(name)) + }) + .or_else(|| { + position.and_then(|position| { + arguments + .iter() + .filter(|argument| argument.name.is_none()) + .nth(position) + .copied() + }) + }) +} + +pub(super) fn argument_value<'a>(content: &'a str, argument: PhpArgument<'_>) -> &'a str { + &content[argument.value_start..argument.value_end] +} + +pub(super) fn string_argument( + content: &str, + argument: PhpArgument<'_>, +) -> Option<(String, usize, usize)> { + let raw = argument_value(content, argument); + let value = crate::text_scan::decode_php_string_literal(raw)?.into_owned(); + Some(( + value, + argument.value_start + 1, + argument.value_end.saturating_sub(1), + )) +} + +pub(super) fn split_top_level(content: &str, start: usize, end: usize) -> Vec<(usize, usize)> { + let bytes = content.as_bytes(); + let mut ranges = Vec::new(); + let mut segment_start = start; + let mut cursor = start; + let mut paren_depth = 0u32; + let mut bracket_depth = 0u32; + let mut brace_depth = 0u32; + while cursor < end { + match bytes[cursor] { + b'\'' | b'"' => { + cursor = crate::text_scan::skip_string_forward(bytes, cursor).min(end); + continue; + } + 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'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + b',' if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => { + ranges.push((segment_start, cursor)); + segment_start = cursor + 1; + } + _ => {} + } + cursor += 1; + } + ranges.push((segment_start, end)); + ranges +} + +fn top_level_colon(content: &str, start: usize, end: usize) -> Option { + let bytes = content.as_bytes(); + let mut cursor = start; + let mut paren_depth = 0u32; + let mut bracket_depth = 0u32; + let mut brace_depth = 0u32; + while cursor < end { + match bytes[cursor] { + b'\'' | b'"' => { + cursor = crate::text_scan::skip_string_forward(bytes, cursor).min(end); + continue; + } + 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'{' => brace_depth += 1, + b'}' => brace_depth = brace_depth.saturating_sub(1), + b':' if paren_depth == 0 + && bracket_depth == 0 + && brace_depth == 0 + && bytes.get(cursor.wrapping_sub(1)) != Some(&b':') + && bytes.get(cursor + 1) != Some(&b':') => + { + return Some(cursor); + } + _ => {} + } + cursor += 1; + } + None +} + +fn trim_range(content: &str, mut start: usize, mut end: usize) -> Option<(usize, usize)> { + let bytes = content.as_bytes(); + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + (start < end).then_some((start, end)) +} + +pub(super) fn skip_whitespace(bytes: &[u8], cursor: &mut usize) { + while bytes + .get(*cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + *cursor += 1; + } +} + +pub(super) fn is_php_identifier(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() || byte >= 0x80 +} + +pub(super) fn is_php_name(byte: u8) -> bool { + is_php_identifier(byte) || byte == b'\\' +} 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 diff --git a/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index 651fae2a4..d318d448f 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,350 @@ fn lens_titles(lenses: &[CodeLens]) -> Vec<&str> { .collect() } +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: language_id.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(), "php", 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(), "php", 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#" Url { + Url::from_file_path(dir.path().join(rel)).unwrap() +} + +const COMPOSER: &str = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#; + // ─── Basic Override Detection ─────────────────────────────────────────────── #[test] @@ -829,3 +1174,244 @@ 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.as_ref(), 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 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.as_ref(), 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_repository_convention_links_back_to_entity() { + 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.as_ref(), 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#" 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!( + ""#; + 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!( + "addListener( + 'use_case.post.publish_course.async', + [#[\Closure(name: 'Generated\\CourseListenerProxy')] fn () => ($container->privates['Generated\\CourseListenerProxy'] ?? null), 'onPublished'], + 0, +); +$factory->createProxy(new \App\UseCase\PublishCourse()); +"#; + +const LISTENER_PROXY: &str = r#" 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, + ) +} + +fn lens<'a>(lenses: &'a [CodeLens], title: &str) -> &'a CodeLens { + lenses + .iter() + .find(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == title) + }) + .unwrap_or_else(|| panic!("missing {title:?} in {lenses:#?}")) +} + +async fn definition(backend: &Backend, uri: Url, content: &str, needle: &str) -> Vec { + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: position_in(content, needle, 2), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("event should navigate"); + match response { + GotoDefinitionResponse::Scalar(location) => vec![location], + GotoDefinitionResponse::Array(locations) => locations, + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + } +} + +#[tokio::test] +async fn compiled_container_and_configured_attributes_drive_symfony_event_navigation() { + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + (".phpantom.toml", CONFIG), + ("src/UseCase/PublishCourse.php", PUBLISHER), + ("src/Listener/CourseListener.php", COMPILED_LISTENER), + ("src/Listener/AuditListener.php", CONFIGURED_LISTENER), + ( + "var/cache/dev/proxies/CourseListenerProxy.php", + LISTENER_PROXY, + ), + ( + "var/cache/dev/ContainerAbc/KernelDevDebugContainer.php", + CONTAINER, + ), + ], + ); + backend.initialized(InitializedParams {}).await; + + let publisher_uri = + Url::from_file_path(dir.path().join("src/UseCase/PublishCourse.php")).unwrap(); + let compiled_uri = + Url::from_file_path(dir.path().join("src/Listener/CourseListener.php")).unwrap(); + let configured_uri = + Url::from_file_path(dir.path().join("src/Listener/AuditListener.php")).unwrap(); + open_php(&backend, publisher_uri.clone(), PUBLISHER).await; + open_php(&backend, compiled_uri.clone(), COMPILED_LISTENER).await; + open_php(&backend, configured_uri.clone(), CONFIGURED_LISTENER).await; + + let publisher_lenses = backend + .handle_code_lens(publisher_uri.as_str(), PUBLISHER) + .unwrap_or_default(); + let publisher_lens = lens(&publisher_lenses, "Symfony event: 2 subscribers"); + let locations: Vec = serde_json::from_value( + publisher_lens + .command + .as_ref() + .unwrap() + .arguments + .as_ref() + .unwrap()[2] + .clone(), + ) + .unwrap(); + assert_eq!(locations.len(), 2); + + let compiled_lenses = backend + .handle_code_lens(compiled_uri.as_str(), COMPILED_LISTENER) + .unwrap_or_default(); + lens(&compiled_lenses, "Symfony event: 1 publisher"); + let configured_lenses = backend + .handle_code_lens(configured_uri.as_str(), CONFIGURED_LISTENER) + .unwrap_or_default(); + lens(&configured_lenses, "Symfony event: 1 publisher"); + + let publisher_targets = definition(&backend, publisher_uri.clone(), PUBLISHER, "execute").await; + assert_eq!(publisher_targets.len(), 2); + assert!( + publisher_targets + .iter() + .any(|location| location.uri == compiled_uri) + ); + assert!( + publisher_targets + .iter() + .any(|location| location.uri == configured_uri) + ); + + let subscriber_targets = definition( + &backend, + compiled_uri.clone(), + COMPILED_LISTENER, + "onPublished", + ) + .await; + assert_eq!(subscriber_targets.len(), 1); + assert_eq!(subscriber_targets[0].uri, publisher_uri); + + let references = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: compiled_uri }, + position: position_in(COMPILED_LISTENER, "onPublished", 2), + }, + context: ReferenceContext { + include_declaration: true, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("event references should resolve"); + assert_eq!(references.len(), 3); +} diff --git a/tests/integration/definition_symfony_expressions.rs b/tests/integration/definition_symfony_expressions.rs new file mode 100644 index 000000000..a30556dbd --- /dev/null +++ b/tests/integration/definition_symfony_expressions.rs @@ -0,0 +1,414 @@ +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/" } } +}"#; + +const CONFIG: &str = r#" +[indexing] +strategy = "none" + +[[symfony.expression-language.attributes]] +attribute = 'Acme\Expression\Cache' +argument = "tags" +position = 3 +method-parameters = true + +[[symfony.expression-language.attributes]] +attribute = 'Acme\Expression\Security' +argument = "expression" +position = 0 +method-parameters = true + +[[symfony.expression-language.constructors]] +class = 'Acme\Expression\Value' +position = 0 +inside-attribute-prefixes = ['Acme\Track\'] +bindings = { request = "parameter:0", response = "return", subject = 'class:App\Model\Course' } +"#; + +async fn open_php(backend: &Backend, uri: Url, content: &str) { + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "php".to_string(), + version: 1, + text: content.to_string(), + }, + }) + .await; +} + +fn uri_for(dir: &tempfile::TempDir, relative: &str) -> Url { + Url::from_file_path(dir.path().join(relative)).unwrap() +} + +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, position: Position) -> Location { + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("expression symbol should resolve"); + match response { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) if locations.len() == 1 => { + locations.pop().unwrap() + } + other => panic!("expected one definition, got {other:?}"), + } +} + +#[tokio::test] +async fn configured_attribute_arguments_navigate_and_report_the_first_missing_member() { + let request_php = r#" 0 and request.missingMethod()')] + public function show(CourseRequest $request): void {} + + #[Cache(null, [], null, ['request.positionalMissing'])] + public function other(CourseRequest $request): void {} +} +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + (".phpantom.toml", CONFIG), + ("src/Request/CourseRequest.php", request_php), + ("src/Model/Course.php", course_php), + ("src/Controller/CourseController.php", controller_php), + ], + ); + backend.initialized(InitializedParams {}).await; + + let request_uri = uri_for(&dir, "src/Request/CourseRequest.php"); + let course_uri = uri_for(&dir, "src/Model/Course.php"); + let controller_uri = uri_for(&dir, "src/Controller/CourseController.php"); + open_php(&backend, request_uri.clone(), request_php).await; + open_php(&backend, course_uri.clone(), course_php).await; + open_php(&backend, controller_uri.clone(), controller_php).await; + + let root = definition_at( + &backend, + controller_uri.clone(), + position_in(controller_php, "request.course.id", 2), + ) + .await; + assert_eq!(root.uri, controller_uri); + assert_eq!(root.range.start.line, 13); + + let course = definition_at( + &backend, + controller_uri.clone(), + position_in(controller_php, "request.course.id", "request.".len() + 2), + ) + .await; + assert_eq!(course.uri, request_uri); + + let owner = definition_at( + &backend, + controller_uri.clone(), + position_in( + controller_php, + "request.course.owner()", + "request.course.".len() + 2, + ), + ) + .await; + assert_eq!(owner.uri, course_uri); + assert_eq!(owner.range.start.line, 6); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(controller_uri.as_str(), controller_php, &mut diagnostics); + diagnostics.retain(|diagnostic| { + diagnostic.code.as_ref().is_some_and( + |code| matches!(code, NumberOrString::String(value) if value == "unknown_member"), + ) + }); + assert_eq!( + diagnostics.len(), + 3, + "unexpected diagnostics: {diagnostics:#?}" + ); + for missing in ["missing", "missingMethod", "positionalMissing"] { + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains(missing)), + "missing diagnostic for {missing}: {diagnostics:#?}" + ); + } + assert!( + diagnostics + .iter() + .all(|diagnostic| !diagnostic.message.contains("afterMissing")) + ); +} + +#[tokio::test] +async fn configured_constructor_bindings_are_scoped_to_matching_attributes() { + let request_php = 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() +} + +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, + ) +} + +#[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 = " [ + Mailer::class => [], + 'App\\Service\\Mailer' => [], + ], +]); +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Service/Mailer.php", service_php), + ("config/services.php", services_php), + ], + ); + + let service_uri = uri_for(&dir, "src/Service/Mailer.php"); + let config_uri = uri_for(&dir, "config/services.php"); + open_doc(&backend, service_uri.clone(), "php", service_php).await; + open_doc(&backend, config_uri.clone(), "php", services_php).await; + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: config_uri.clone(), + }, + position: position_in(services_php, "App\\\\Service\\\\Mailer", 5), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("PHP service string should resolve to its class"); + let GotoDefinitionResponse::Scalar(location) = definition else { + panic!("expected a single definition location"); + }; + assert_eq!(location.uri, service_uri); + + let lenses = backend + .handle_code_lens(service_uri.as_str(), service_php) + .unwrap_or_default(); + let titles: Vec<&str> = lenses + .iter() + .filter_map(|lens| lens.command.as_ref().map(|command| command.title.as_str())) + .collect(); + assert!( + titles.contains(&"Symfony/Doctrine config: 2 refs"), + "expected PHP service references in the class code lens, got {titles:?}" + ); + + let edit = backend + .rename(RenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: service_uri.clone(), + }, + position: position_in(service_php, "class Mailer", 7), + }, + new_name: "MessageMailer".to_string(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("class rename should update PHP service config"); + let config_edits = edit_texts_for_uri(&edit, &config_uri); + assert!( + config_edits.iter().any(|text| text == "MessageMailer"), + "expected imported class-constant edit, got {config_edits:?}" + ); + assert!( + config_edits + .iter() + .any(|text| text == "App\\\\Service\\\\MessageMailer"), + "expected escaped service class edit, got {config_edits:?}" + ); +} + +#[tokio::test] +async fn symfony_php_route_config_links_callable_methods() { + let controller_php = "add('home', '/')->controller([HomeController::class, 'index']); + $routes->add('other', '/other')->controller('App\\Controller\\HomeController::index'); + $routes->import('../src/Controller/', 'attribute'); +}; +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Controller/HomeController.php", controller_php), + ("config/routes.php", routes_php), + ], + ); + + let controller_uri = uri_for(&dir, "src/Controller/HomeController.php"); + let routes_uri = uri_for(&dir, "config/routes.php"); + open_doc(&backend, controller_uri.clone(), "php", controller_php).await; + open_doc(&backend, routes_uri.clone(), "php", routes_php).await; + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: routes_uri.clone(), + }, + position: position_in(routes_php, "'index'", 2), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("PHP route callable should resolve to its method"); + let GotoDefinitionResponse::Scalar(location) = definition else { + panic!("expected a single method definition"); + }; + assert_eq!(location.uri, controller_uri); + assert_eq!(location.range.start.line, 3); + + let lenses = backend + .handle_code_lens(controller_uri.as_str(), controller_php) + .unwrap_or_default(); + let titles: Vec<&str> = lenses + .iter() + .filter_map(|lens| lens.command.as_ref().map(|command| command.title.as_str())) + .collect(); + assert!( + titles.contains(&"Symfony config: 2 refs"), + "expected PHP route references in the method code lens, got {titles:?}" + ); + + let edit = backend + .rename(RenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: controller_uri, + }, + position: position_in(controller_php, "function index", 10), + }, + new_name: "dashboard".to_string(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("method rename should update PHP route config"); + let route_edits = edit_texts_for_uri(&edit, &routes_uri); + assert_eq!( + route_edits + .iter() + .filter(|text| text.as_str() == "dashboard") + .count(), + 2, + "expected both PHP route callables to be renamed, got {route_edits:?}" + ); +} + +#[tokio::test] +async fn symfony_namespace_prefix_rename_updates_yaml_and_php_namespace() { + let mailer_php = "services()->load('App\\Service\\', '../src/Service/'); +}; +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Service/Mailer.php", mailer_php), + ("config/services.yaml", services_yaml), + ("config/services.php", services_php), + ], + ); + + let mailer_uri = uri_for(&dir, "src/Service/Mailer.php"); + let yaml_uri = uri_for(&dir, "config/services.yaml"); + let php_config_uri = uri_for(&dir, "config/services.php"); + open_doc(&backend, mailer_uri.clone(), "php", mailer_php).await; + open_doc(&backend, yaml_uri.clone(), "yaml", services_yaml).await; + open_doc(&backend, php_config_uri.clone(), "php", services_php).await; + + let edit = backend + .rename(RenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: yaml_uri.clone(), + }, + position: Position::new(1, 8), + }, + new_name: "Domain".to_string(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("namespace-prefix rename should produce edits"); + + let yaml_edits = edit_texts_for_uri(&edit, &yaml_uri); + assert!( + yaml_edits.iter().any(|text| text == "App\\Domain\\"), + "expected YAML namespace-prefix edit, got {yaml_edits:?}" + ); + assert!( + yaml_edits.iter().any(|text| text == "App\\Domain\\Mailer"), + "expected YAML class-reference edit, got {yaml_edits:?}" + ); + assert!( + yaml_edits.iter().any(|text| text == "../src/Domain/"), + "expected YAML resource path edit, got {yaml_edits:?}" + ); + assert!( + edit_texts_for_uri(&edit, &mailer_uri) + .iter() + .any(|text| text == "App\\Domain"), + "expected PHP namespace declaration edit, got {:?}", + edit_texts_for_uri(&edit, &mailer_uri) + ); + let php_config_edits = edit_texts_for_uri(&edit, &php_config_uri); + assert!( + php_config_edits + .iter() + .any(|text| text == "App\\\\Domain\\\\"), + "expected PHP configurator namespace-prefix edit, got {php_config_edits:?}" + ); + assert!( + php_config_edits.iter().any(|text| text == "../src/Domain/"), + "expected PHP configurator resource path edit, got {php_config_edits:?}" + ); +} + +#[tokio::test] +async fn symfony_service_ids_and_parameters_work_across_yaml_and_php() { + let mailer_php = "get('app.mailer'); + } +} +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Service/Mailer.php", mailer_php), + ("src/Controller/MailController.php", consumer_php), + ("config/services.yaml", services_yaml), + ], + ); + let mailer_uri = uri_for(&dir, "src/Service/Mailer.php"); + let consumer_uri = uri_for(&dir, "src/Controller/MailController.php"); + let yaml_uri = uri_for(&dir, "config/services.yaml"); + open_doc(&backend, mailer_uri.clone(), "php", mailer_php).await; + open_doc(&backend, yaml_uri.clone(), "yaml", services_yaml).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, "app.mailer", 5), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("service ID usage should resolve to its declaration"); + let locations = match definition { + GotoDefinitionResponse::Scalar(location) => vec![location], + GotoDefinitionResponse::Array(locations) => locations, + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].uri, yaml_uri); + assert_eq!(locations[0].range.start.line, 3); + + let lenses = backend + .handle_code_lens(yaml_uri.as_str(), services_yaml) + .unwrap_or_default(); + let titles = lenses + .iter() + .filter_map(|lens| lens.command.as_ref().map(|command| command.title.as_str())) + .collect::>(); + assert!( + titles.contains(&"Symfony service: 2 refs"), + "expected declaration-side service reference lens, got {titles:?}" + ); + assert!( + titles.contains(&"Symfony service class: Mailer"), + "expected service declaration to link to its PHP class, got {titles:?}" + ); + + let edit = backend + .rename(RenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: yaml_uri.clone(), + }, + position: position_in(services_yaml, "app.mailer:", 5), + }, + new_name: "app.message_mailer".to_string(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("service ID rename should update its usages"); + assert!( + edit_texts_for_uri(&edit, &consumer_uri) + .iter() + .any(|text| text == "app.message_mailer"), + "expected PHP container lookup edit" + ); + assert!( + edit_texts_for_uri(&edit, &yaml_uri) + .iter() + .filter(|text| text.as_str() == "app.message_mailer") + .count() + >= 2, + "expected YAML declaration and alias edits" + ); +} + +#[tokio::test] +async fn symfony_service_and_parameter_completion_uses_workspace_declarations() { + let services_yaml = "parameters:\n app.sender_name: PHPantom\nservices:\n app.mailer: ~\n"; + let consumer_php = r#"get('app.m'); +} + +#[Autowire(param: 'app.s')] +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("config/services.yaml", services_yaml), + ("src/consumer.php", consumer_php), + ], + ); + let yaml_uri = uri_for(&dir, "config/services.yaml"); + let consumer_uri = uri_for(&dir, "src/consumer.php"); + open_doc(&backend, yaml_uri, "yaml", services_yaml).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + + for (needle, expected) in [("app.m", "app.mailer"), ("app.s", "app.sender_name")] { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, needle, needle.len()), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("Symfony completion should return candidates"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!( + items.iter().any(|item| item.label == expected), + "expected {expected} completion, got {:?}", + items + .iter() + .map(|item| item.label.as_str()) + .collect::>() + ); + } +} + +#[tokio::test] +async fn symfony_xml_service_alias_resolves_to_service_declaration() { + let services_xml = r#" + + + PHPantom + + + + + + +"#; + let (backend, dir) = create_psr4_workspace(COMPOSER, &[("config/services.xml", services_xml)]); + let xml_uri = uri_for(&dir, "config/services.xml"); + open_doc(&backend, xml_uri.clone(), "xml", services_xml).await; + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: xml_uri.clone(), + }, + position: position_in(services_xml, "alias=\"app.mailer\"", 10), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("XML service alias should resolve"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, xml_uri); + assert_eq!(location.range.start.line, 6); +} + +#[tokio::test] +async fn symfony_reports_only_missing_project_local_container_symbols() { + let services_yaml = "parameters:\n app.sender: PHPantom\nservices:\n app.mailer: ~\n"; + let consumer_php = r#"get('app.mailer'); + $container->get('app.missing'); + $container->getParameter('app.sender'); + $container->getParameter('app.missing_parameter'); + $container->get('vendor.dynamic_service'); +} +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("config/services.yaml", services_yaml), + ("src/consumer.php", consumer_php), + ], + ); + let yaml_uri = uri_for(&dir, "config/services.yaml"); + let consumer_uri = uri_for(&dir, "src/consumer.php"); + open_doc(&backend, yaml_uri, "yaml", services_yaml).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(consumer_uri.as_str(), consumer_php, &mut diagnostics); + let symfony = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code.starts_with("unknown_symfony_") + ) + }) + .collect::>(); + assert_eq!( + symfony.len(), + 2, + "expected only missing app-local symbols, got {symfony:?}" + ); + assert!( + symfony + .iter() + .any(|diagnostic| diagnostic.message.contains("app.missing'")) + ); + assert!( + symfony + .iter() + .any(|diagnostic| diagnostic.message.contains("app.missing_parameter'")) + ); +} + +#[tokio::test] +async fn symfony_php_configurator_declares_services_and_parameters() { + let mailer_php = "services(); + $parameters = $container->parameters(); + $services->set('app.php_mailer', Mailer::class); + $parameters->set('app.php_sender', 'PHPantom'); + $services->alias('app.php_mailer_alias', 'app.php_mailer'); +}; +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Service/Mailer.php", mailer_php), + ("config/services.php", services_php), + ], + ); + let mailer_uri = uri_for(&dir, "src/Service/Mailer.php"); + let config_uri = uri_for(&dir, "config/services.php"); + open_doc(&backend, mailer_uri, "php", mailer_php).await; + open_doc(&backend, config_uri.clone(), "php", services_php).await; + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: config_uri.clone(), + }, + position: position_in(services_php, "'app.php_mailer');", "'app.php_".len()), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("PHP service alias target should resolve"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, config_uri); + assert_eq!(location.range.start.line, 8); + + let lenses = backend + .handle_code_lens(config_uri.as_str(), services_php) + .unwrap_or_default(); + let titles = lenses + .iter() + .filter_map(|lens| lens.command.as_ref().map(|command| command.title.as_str())) + .collect::>(); + assert!( + titles.contains(&"Symfony service: 1 ref"), + "expected PHP declaration-side service lens, got {titles:?}" + ); + assert!( + titles.contains(&"Symfony service class: Mailer"), + "expected PHP service declaration class lens, got {titles:?}" + ); +} + +#[tokio::test] +async fn symfony_route_names_work_across_yaml_php_and_twig() { + let controller_php = "redirectToRoute('app_home', ['userId' => 1]); + $this->generateUrl('app_home'); + $this->redirectToRoute('app_missing'); + } +} +"#; + let template = "Home\n"; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/Controller/HomeController.php", controller_php), + ("src/Consumer.php", consumer_php), + ("config/routes.yaml", routes_yaml), + ("templates/home.html.twig", template), + ], + ); + let controller_uri = uri_for(&dir, "src/Controller/HomeController.php"); + let consumer_uri = uri_for(&dir, "src/Consumer.php"); + let routes_uri = uri_for(&dir, "config/routes.yaml"); + let template_uri = uri_for(&dir, "templates/home.html.twig"); + open_doc(&backend, controller_uri, "php", controller_php).await; + open_doc(&backend, routes_uri.clone(), "yaml", routes_yaml).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + open_doc(&backend, template_uri.clone(), "twig", template).await; + + for (uri, content) in [(&consumer_uri, consumer_php), (&template_uri, template)] { + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: position_in(content, "app_home", 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("route usage should resolve to YAML declaration"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, routes_uri); + assert_eq!(location.range.start.line, 0); + } + + for (uri, content) in [(&consumer_uri, consumer_php), (&template_uri, template)] { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: position_in(content, "app_home", 5), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("route completion should return candidates"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!( + items.iter().any(|item| item.label == "app_home"), + "expected app_home completion" + ); + } + + let lenses = backend + .handle_code_lens(routes_uri.as_str(), routes_yaml) + .unwrap_or_default(); + let titles = lenses + .iter() + .filter_map(|lens| lens.command.as_ref().map(|command| command.title.as_str())) + .collect::>(); + assert!( + titles.contains(&"Symfony route: 3 refs"), + "expected route reference lens, got {titles:?}" + ); + assert!( + titles.contains(&"Symfony controller: HomeController::index"), + "expected route-to-controller lens, got {titles:?}" + ); + + for (uri, content) in [(&consumer_uri, consumer_php), (&template_uri, template)] { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: position_in(content, "userId", 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("route parameter completion should return candidates"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!( + items.iter().any(|item| item.label == "userId"), + "expected userId route parameter completion" + ); + } + + let parameter_definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, "userId", 3), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("route parameter should resolve to its path placeholder"); + let parameter_location = match parameter_definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(parameter_location.uri, routes_uri); + assert_eq!(parameter_location.range.start.line, 1); + + let parameter_edit = backend + .rename(RenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: routes_uri.clone(), + }, + position: position_in(routes_yaml, "userId", 3), + }, + new_name: "accountId".to_string(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("route parameter rename should update call sites"); + assert!( + edit_texts_for_uri(¶meter_edit, &consumer_uri) + .iter() + .any(|text| text == "accountId") + ); + assert!( + edit_texts_for_uri(¶meter_edit, &template_uri) + .iter() + .any(|text| text == "accountId") + ); + + let edit = backend + .rename(RenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: routes_uri.clone(), + }, + position: position_in(routes_yaml, "app_home", 4), + }, + new_name: "app_dashboard".to_string(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap() + .expect("route rename should update PHP and Twig usages"); + assert_eq!( + edit_texts_for_uri(&edit, &consumer_uri) + .iter() + .filter(|text| text.as_str() == "app_dashboard") + .count(), + 2 + ); + assert!( + edit_texts_for_uri(&edit, &template_uri) + .iter() + .any(|text| text == "app_dashboard") + ); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(consumer_uri.as_str(), consumer_php, &mut diagnostics); + assert!( + diagnostics.iter().any(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "unknown_symfony_route" + ) && diagnostic.message.contains("app_missing") + }), + "expected unknown project-local route diagnostic" + ); +} + +#[tokio::test] +async fn symfony_routes_are_declared_by_xml_php_and_attributes() { + let routes_xml = r#" + + + +"#; + let routes_php = r#"add('app_php', '/php/{phpId}'); +}; +"#; + let controller_php = r#"generateUrl('app_xml', ['xmlId' => 1]); + $this->generateUrl('app_php', ['phpId' => 1]); + $this->generateUrl('app_attribute', ['attributeId' => 1]); + } +} + +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("config/routes.xml", routes_xml), + ("config/routes.php", routes_php), + ("src/Controller/AttributeController.php", controller_php), + ("src/Consumer.php", consumer_php), + ], + ); + let xml_uri = uri_for(&dir, "config/routes.xml"); + let php_routes_uri = uri_for(&dir, "config/routes.php"); + let controller_uri = uri_for(&dir, "src/Controller/AttributeController.php"); + let consumer_uri = uri_for(&dir, "src/Consumer.php"); + open_doc(&backend, xml_uri.clone(), "xml", routes_xml).await; + open_doc(&backend, php_routes_uri.clone(), "php", routes_php).await; + open_doc(&backend, controller_uri.clone(), "php", controller_php).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + + for (name, expected_uri) in [ + ("app_xml", &xml_uri), + ("app_php", &php_routes_uri), + ("app_attribute", &controller_uri), + ] { + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, name, 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("route reference should resolve"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(&location.uri, expected_uri, "wrong definition for {name}"); + } + + for (name, expected_uri) in [ + ("xmlId", &xml_uri), + ("phpId", &php_routes_uri), + ("attributeId", &controller_uri), + ] { + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, name, 3), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("route parameter reference should resolve"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!( + &location.uri, expected_uri, + "wrong parameter definition for {name}" + ); + } +} +#[tokio::test] +async fn symfony_twig_templates_complete_navigate_reference_and_show_lenses() { + let base_template = "
{% block body %}{% endblock %}
\n"; + let card_template = "
Card
\n"; + let page_template = r#"{% extends 'base.html.twig' %} +{% block body %} + {% include 'partials/card.html.twig' %} +{% endblock %} +"#; + let controller_php = r#"render('page.html.twig'); + (new TemplatedEmail())->htmlTemplate('partials/card.html.twig'); + } +} +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("templates/base.html.twig", base_template), + ("templates/partials/card.html.twig", card_template), + ("templates/page.html.twig", page_template), + ("src/PageController.php", controller_php), + ], + ); + let base_uri = uri_for(&dir, "templates/base.html.twig"); + let card_uri = uri_for(&dir, "templates/partials/card.html.twig"); + let page_uri = uri_for(&dir, "templates/page.html.twig"); + let controller_uri = uri_for(&dir, "src/PageController.php"); + open_doc(&backend, base_uri.clone(), "twig", base_template).await; + open_doc(&backend, card_uri.clone(), "twig", card_template).await; + open_doc(&backend, page_uri.clone(), "twig", page_template).await; + open_doc(&backend, controller_uri.clone(), "php", controller_php).await; + + for (uri, content, name, expected_uri) in [ + (&page_uri, page_template, "base.html.twig", &base_uri), + ( + &page_uri, + page_template, + "partials/card.html.twig", + &card_uri, + ), + (&controller_uri, controller_php, "page.html.twig", &page_uri), + ] { + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: position_in(content, name, 3), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("template reference should resolve"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(&location.uri, expected_uri, "wrong definition for {name}"); + assert_eq!(location.range.start, Position::new(0, 0)); + } + + for (uri, content, name) in [ + (&page_uri, page_template, "base.html.twig"), + (&controller_uri, controller_php, "page.html.twig"), + ] { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: position_in(content, name, 5), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("template completion should return candidates"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!( + items.iter().any(|item| item.label == name), + "expected {name} completion, got {:?}", + items + .iter() + .map(|item| item.label.as_str()) + .collect::>() + ); + } + + let references = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: controller_uri.clone(), + }, + position: position_in(controller_php, "page.html.twig", 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }) + .await + .unwrap() + .expect("template references should be returned"); + assert!(references.iter().any(|location| location.uri == page_uri)); + + let lenses = backend + .handle_code_lens(base_uri.as_str(), base_template) + .unwrap_or_default(); + assert!( + lenses.iter().any(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == "Symfony template: 1 ref") + }), + "expected a declaration-side Twig reference lens, got {lenses:?}" + ); +} + +#[tokio::test] +async fn symfony_missing_template_diagnostic_offers_create_template_action() { + let controller_php = r#"render('missing/page.html.twig'); + $this->render('@Vendor/external.html.twig'); + } +} +"#; + let (backend, dir) = + create_psr4_workspace(COMPOSER, &[("src/PageController.php", controller_php)]); + let controller_uri = uri_for(&dir, "src/PageController.php"); + open_doc(&backend, controller_uri.clone(), "php", controller_php).await; + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(controller_uri.as_str(), controller_php, &mut diagnostics); + let template_diagnostics = diagnostics + .into_iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "unknown_symfony_template" + ) + }) + .collect::>(); + assert_eq!( + template_diagnostics.len(), + 1, + "namespaced vendor templates should not be diagnosed" + ); + assert!( + template_diagnostics[0] + .message + .contains("missing/page.html.twig") + ); + + let actions = backend.handle_code_action( + controller_uri.as_str(), + controller_php, + &CodeActionParams { + text_document: TextDocumentIdentifier { + uri: controller_uri.clone(), + }, + range: template_diagnostics[0].range, + context: CodeActionContext { + diagnostics: template_diagnostics, + only: Some(vec![CodeActionKind::QUICKFIX]), + trigger_kind: Some(CodeActionTriggerKind::INVOKED), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }, + ); + let action = actions + .iter() + .find_map(|action| match action { + CodeActionOrCommand::CodeAction(action) + if action.title == "Create Twig template 'missing/page.html.twig'" => + { + Some(action) + } + _ => None, + }) + .expect("missing template should offer a create-file quick fix"); + let Some(DocumentChanges::Operations(operations)) = action + .edit + .as_ref() + .and_then(|edit| edit.document_changes.as_ref()) + else { + panic!("expected resource operations"); + }; + assert!(operations.iter().any(|operation| { + matches!( + operation, + DocumentChangeOperation::Op(ResourceOp::Create(create)) + if create.uri.path().ends_with("/templates/missing/page.html.twig") + ) + })); +} + +#[tokio::test] +async fn symfony_bundle_override_templates_use_twig_namespaces() { + let template = "
Widget
\n"; + let consumer = "{% include '@Acme/widget.html.twig' %}\n"; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("templates/bundles/AcmeBundle/widget.html.twig", template), + ("templates/consumer.html.twig", consumer), + ], + ); + let template_uri = uri_for(&dir, "templates/bundles/AcmeBundle/widget.html.twig"); + let consumer_uri = uri_for(&dir, "templates/consumer.html.twig"); + open_doc(&backend, template_uri.clone(), "twig", template).await; + open_doc(&backend, consumer_uri.clone(), "twig", consumer).await; + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: consumer_uri }, + position: position_in(consumer, "@Acme/widget.html.twig", 8), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("Twig bundle namespace should resolve"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, template_uri); +} + +#[tokio::test] +async fn symfony_translations_complete_navigate_reference_and_show_lenses() { + let messages_yaml = "navigation:\n welcome: Welcome\n"; + let validators_xlf = r#" + + + + + app.invalid + Invalid + + + source.only + Source fallback + + + + +"#; + let admin_php = " ['title' => 'Dashboard']];\n"; + let consumer_php = r#"trans('navigation.welcome'); + $translator->trans('app.invalid', [], 'validators'); + $translator->trans('source.only', domain: 'validators'); + new TranslatableMessage('dashboard.title', [], 'admin'); +} +"#; + let template = r#"{% trans_default_domain 'validators' %} +{{ 'app.invalid'|trans }} +{{ 'navigation.welcome'|trans({}, 'messages') }} +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("translations/messages.en.yaml", messages_yaml), + ("translations/validators.en.xlf", validators_xlf), + ("translations/admin.en.php", admin_php), + ("src/translate.php", consumer_php), + ("templates/translated.html.twig", template), + ], + ); + let messages_uri = uri_for(&dir, "translations/messages.en.yaml"); + let validators_uri = uri_for(&dir, "translations/validators.en.xlf"); + let admin_uri = uri_for(&dir, "translations/admin.en.php"); + let consumer_uri = uri_for(&dir, "src/translate.php"); + let template_uri = uri_for(&dir, "templates/translated.html.twig"); + open_doc(&backend, messages_uri.clone(), "yaml", messages_yaml).await; + open_doc(&backend, validators_uri.clone(), "xml", validators_xlf).await; + open_doc(&backend, admin_uri.clone(), "php", admin_php).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + open_doc(&backend, template_uri.clone(), "twig", template).await; + + for (uri, content, name, occurrence, expected_uri) in [ + ( + &consumer_uri, + consumer_php, + "navigation.welcome", + 0, + &messages_uri, + ), + ( + &consumer_uri, + consumer_php, + "app.invalid", + 0, + &validators_uri, + ), + ( + &consumer_uri, + consumer_php, + "dashboard.title", + 0, + &admin_uri, + ), + ( + &consumer_uri, + consumer_php, + "source.only", + 0, + &validators_uri, + ), + ( + &template_uri, + template, + "navigation.welcome", + 0, + &messages_uri, + ), + ] { + let offset = content + .match_indices(name) + .nth(occurrence) + .expect("translation occurrence") + .0; + let position = position_in(content, &content[offset..offset + name.len()], 3); + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .unwrap_or_else(|| panic!("translation reference '{name}' should resolve")); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(&location.uri, expected_uri, "wrong definition for {name}"); + } + + for (uri, content, name) in [ + (&consumer_uri, consumer_php, "navigation.welcome"), + (&consumer_uri, consumer_php, "app.invalid"), + (&consumer_uri, consumer_php, "dashboard.title"), + (&consumer_uri, consumer_php, "source.only"), + (&template_uri, template, "app.invalid"), + ] { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: position_in(content, name, 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("translation completion should return candidates"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!( + items.iter().any(|item| item.label == name), + "expected {name} completion, got {:?}", + items + .iter() + .map(|item| item.label.as_str()) + .collect::>() + ); + } + + let references = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, "navigation.welcome", 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }) + .await + .unwrap() + .expect("translation references should be returned"); + assert!( + references + .iter() + .any(|location| location.uri == messages_uri) + ); + assert!( + references + .iter() + .any(|location| location.uri == template_uri) + ); + + let lenses = backend + .handle_code_lens(messages_uri.as_str(), messages_yaml) + .unwrap_or_default(); + assert!( + lenses.iter().any(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == "Symfony translation: 2 refs") + }), + "expected a translation reference lens, got {lenses:?}" + ); +} + +#[tokio::test] +async fn symfony_translation_diagnostics_are_scoped_to_known_domains() { + let messages_yaml = "known.message: Known\n"; + let consumer_php = r#"trans('missing.message'); + $translator->trans('dynamic.vendor.message', [], 'vendor'); +} +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("translations/messages.en.yaml", messages_yaml), + ("src/translate.php", consumer_php), + ], + ); + let messages_uri = uri_for(&dir, "translations/messages.en.yaml"); + let consumer_uri = uri_for(&dir, "src/translate.php"); + open_doc(&backend, messages_uri, "yaml", messages_yaml).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(consumer_uri.as_str(), consumer_php, &mut diagnostics); + let translations = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "unknown_symfony_translation" + ) + }) + .collect::>(); + assert_eq!( + translations.len(), + 1, + "only missing keys in known domains should be diagnosed" + ); + assert!(translations[0].message.contains("missing.message")); + assert!(translations[0].message.contains("'messages' domain")); +} + +#[tokio::test] +async fn symfony_events_link_dispatchers_listeners_and_listener_methods() { + let listener_php = r#" + + + + + + +"#; + let consumer_php = r#"dispatch($event, 'app.order.placed'); + $dispatcher->dispatch($event, 'app.yaml_event'); + $dispatcher->dispatch($event, 'app.xml_event'); + $dispatcher->dispatch($event, 'app.missing_event'); +} +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/EventListener/OrderListener.php", listener_php), + ("config/services.yaml", services_yaml), + ("config/services.xml", services_xml), + ("src/send.php", consumer_php), + ], + ); + let listener_uri = uri_for(&dir, "src/EventListener/OrderListener.php"); + let yaml_uri = uri_for(&dir, "config/services.yaml"); + let xml_uri = uri_for(&dir, "config/services.xml"); + let consumer_uri = uri_for(&dir, "src/send.php"); + open_doc(&backend, listener_uri.clone(), "php", listener_php).await; + open_doc(&backend, yaml_uri.clone(), "yaml", services_yaml).await; + open_doc(&backend, xml_uri.clone(), "xml", services_xml).await; + open_doc(&backend, consumer_uri.clone(), "php", consumer_php).await; + + for (name, expected_uri) in [ + ("app.order.placed", &listener_uri), + ("app.yaml_event", &yaml_uri), + ("app.xml_event", &xml_uri), + ] { + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, name, 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .unwrap_or_else(|| panic!("event '{name}' should resolve")); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(&location.uri, expected_uri, "wrong event definition"); + } + + let method_definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: listener_uri.clone(), + }, + position: position_in(listener_php, "'onOrderPlaced'", 5), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("event listener method should resolve"); + let method_location = match method_definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(method_location.uri, listener_uri); + assert_eq!(method_location.range.start.line, 8); + + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_in(consumer_php, "app.order.placed", 4), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("event completion should return candidates"); + let items = match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!(items.iter().any(|item| item.label == "app.order.placed")); + assert!(items.iter().any(|item| item.label == "app.yaml_event")); + + let lenses = backend + .handle_code_lens(listener_uri.as_str(), listener_php) + .unwrap_or_default(); + let titles = lenses + .iter() + .filter_map(|lens| lens.command.as_ref().map(|command| command.title.as_str())) + .collect::>(); + assert!(titles.contains(&"Symfony event: 1 ref")); + assert!(titles.contains(&"Symfony config: 1 ref")); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(consumer_uri.as_str(), consumer_php, &mut diagnostics); + assert!(diagnostics.iter().any(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "unknown_symfony_event" + ) && diagnostic.message.contains("app.missing_event") + })); +} + +#[tokio::test] +async fn symfony_messenger_links_messages_handlers_and_named_buses() { + let message_php = " location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, config_uri); + + let completion = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: handler_uri.clone(), + }, + position: position_in(handler_php, "command.bus", 0), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("Messenger bus completion should return candidates"); + let items = match completion { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!(items.iter().any(|item| item.label == "command.bus")); + assert!(items.iter().any(|item| item.label == "query.bus")); + + for (uri, content, expected_title) in [ + ( + &message_uri, + message_php, + "Symfony Messenger handler: PlaceOrderHandler", + ), + ( + &handler_uri, + handler_php, + "Symfony Messenger message: PlaceOrder", + ), + ] { + let lenses = backend + .handle_code_lens(uri.as_str(), content) + .unwrap_or_default(); + assert!( + lenses.iter().any(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == expected_title) + }), + "expected '{expected_title}', got {lenses:?}" + ); + } + + let config_lenses = backend + .handle_code_lens(config_uri.as_str(), messenger_yaml) + .unwrap_or_default(); + assert!(config_lenses.iter().any(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == "Symfony Messenger bus: 1 ref") + })); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(handler_uri.as_str(), handler_php, &mut diagnostics); + assert!(diagnostics.iter().any(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "unknown_symfony_messenger_bus" + ) && diagnostic.message.contains("app.missing_bus") + })); +} + +#[tokio::test] +async fn symfony_forms_and_validation_map_fields_to_entity_properties() { + let user_php = r#"add('email'); + $builder->add('name'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => User::class]); + } +} +"#; + let validation_yaml = r#"App\Entity\User: + properties: + email: + - NotBlank: ~ +"#; + let validation_xml = r#" + + + + + + +"#; + let constraints_php = r#" location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, user_uri); + assert_eq!(location.range.start.line, expected_line); + } + + for (uri, content, name) in [ + (&yaml_uri, validation_yaml, "NotBlank"), + (&xml_uri, validation_xml, "Length"), + ] { + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: position_in(content, name, 2), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .expect("constraint class should resolve"); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, constraints_uri); + } + + let completion = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: form_uri.clone(), + }, + position: position_in(form_php, "'email'", 1), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("form field completion should return entity properties"); + let items = match completion { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!(items.iter().any(|item| item.label == "email")); + assert!(items.iter().any(|item| item.label == "name")); + + let lenses = backend + .handle_code_lens(user_uri.as_str(), user_php) + .unwrap_or_default(); + assert!(lenses.iter().any(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == "Symfony form/validation: 2 refs") + })); +} + +#[tokio::test] +async fn symfony_tree_builder_schema_drives_yaml_config_intelligence() { + let configuration_php = r#"getRootNode(); + $rootNode + ->children() + ->scalarNode('api_key')->end() + ->arrayNode('mailer') + ->children() + ->scalarNode('dsn')->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } +} +"#; + let config_yaml = r#"acme_demo: + api_key: secret + mailer: + dsn: smtp://localhost + typo: true +"#; + let completion_yaml = r#"acme_demo: + mailer: + ds +"#; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ( + "src/DependencyInjection/Configuration.php", + configuration_php, + ), + ("config/packages/acme_demo.yaml", config_yaml), + ("config/packages/acme_completion.yaml", completion_yaml), + ], + ); + let schema_uri = uri_for(&dir, "src/DependencyInjection/Configuration.php"); + let config_uri = uri_for(&dir, "config/packages/acme_demo.yaml"); + let completion_uri = uri_for(&dir, "config/packages/acme_completion.yaml"); + open_doc(&backend, schema_uri.clone(), "php", configuration_php).await; + open_doc(&backend, config_uri.clone(), "yaml", config_yaml).await; + open_doc(&backend, completion_uri.clone(), "yaml", completion_yaml).await; + + for (name, expected_line) in [("api_key", 13), ("mailer", 14), ("dsn", 16)] { + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: config_uri.clone(), + }, + position: position_in(config_yaml, name, 2), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap() + .unwrap_or_else(|| panic!("configuration key '{name}' should resolve")); + let location = match definition { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(_) => panic!("unexpected location links"), + }; + assert_eq!(location.uri, schema_uri); + assert_eq!(location.range.start.line, expected_line); + } + + let completion = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: completion_uri, + }, + position: position_in(completion_yaml, "ds", 2), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap() + .expect("schema completion should return child keys"); + let items = match completion { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + }; + assert!(items.iter().any(|item| item.label == "dsn")); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(config_uri.as_str(), config_yaml, &mut diagnostics); + let config_diagnostics = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "unknown_symfony_config_key" + ) + }) + .collect::>(); + assert_eq!(config_diagnostics.len(), 1); + assert!(config_diagnostics[0].message.contains("acme_demo.typo")); + + let lenses = backend + .handle_code_lens(schema_uri.as_str(), configuration_php) + .unwrap_or_default(); + assert!(lenses.iter().any(|lens| { + lens.command + .as_ref() + .is_some_and(|command| command.title == "Symfony configuration: 1 ref") + })); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index ada9c109a..3daa6eebb 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -110,7 +110,10 @@ mod definition_members; mod definition_object_shapes; mod definition_offsets; mod definition_phpunit_covers; +mod definition_resource_files; mod definition_self_static; +mod definition_symfony_events; +mod definition_symfony_expressions; mod definition_type_hints; mod definition_unions; mod definition_variables; @@ -152,6 +155,7 @@ mod duplicate_class_declarations; mod duplicate_function_declarations; mod folding_ranges; mod formatting_blade; +mod framework_resources; mod hover; mod implementation; mod inlay_hints;