diff --git a/config-schema.json b/config-schema.json index 3d745adad..990f3a8ff 100644 --- a/config-schema.json +++ b/config-schema.json @@ -168,6 +168,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 478b4bc12..189bd7254 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,6 +67,7 @@ src/ │ │ # Class & type resolution ├── resolution.rs # Multi-phase class/function lookup across files (find_or_load_class) +├── 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) @@ -143,6 +144,19 @@ 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 Runtime Metadata + +Framework-generated files feed small metadata adapters instead of creating a +second symbol resolver. `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. +Both inputs name the real classes: a compiled container records the class it +hands to a proxy factory, not the generated subclass. + ## External Crates PHPantom uses several crates from the [Mago](https://github.com/carthage-software/mago) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f9a7e6687..4e2f36e23 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Reference CodeLens.** A clickable reference count sits above the classes, functions, methods, properties, and constants a PHP file declares, and following one opens the same list Find References gives. This replaces the count that used to be drawn at the end of the declaration line, which could only be read, so the number appears once and in a place you can act on. A declaration nothing in the workspace names is answered from the index straight away, and the receiver of every member access in a candidate file is resolved once and kept in a compact semantic layer, so opening a large file does not turn into one expensive search per lens. Clients that can be asked to re-pull lenses are shown a member's count once it is ready rather than being made to resolve each one in turn. A lens whose count is being worked out reads `- references` and keeps its line, so editing a file does not shuffle every line in it up and down as counts come and go, and an edit only counts again what it can actually have changed: typing inside a method body leaves every count in the file standing, and a burst of keystrokes is answered once rather than once per keystroke. Contributed by @sidux. - **Implementation CodeLens.** Interfaces and abstract classes, and the methods they declare, show a clickable implementation count, and following one lists every implementation. A method a class inherits unchanged or takes from a trait is listed at the declaration that supplies its body. The count replaces the one that used to be drawn at the end of an interface's or abstract class's declaration line, which could only be read. Contributed by @sidux. - **Fully-qualified PHP classes navigate from YAML and XML.** Ctrl+Click a class name in any YAML key or value, or any XML attribute or text node, and PHPantom opens its PHP declaration without needing to know that file's schema. `Class::member` references navigate too. The same occurrences feed Find References and declaration CodeLens through the workspace reference index. Unknown and unqualified strings are left alone. Contributed by @sidux. +- **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. +- **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 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`. 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. Renaming a class, namespace, or controller action rewrites each configuration occurrence in the spelling its file uses, and namespace and resource-path refactors also update matching framework configuration. Contributed by @sidux. - **Headless consumers can export owned semantic records without starting an LSP transport.** The optional `semantic-export` feature accepts caller-supplied PHP documents, resolves them together, and returns deterministic declarations, occurrences, calls, byte ranges, and document diagnostics through batch or streaming APIs. An independent `offline-stubs` feature guarantees that missing stubs do not trigger a build-time download. Contributed by @aaaaaandrew. - **`analyze` takes more than one path.** `phpantom_lsp analyze app/ lib/Helper.php tests/` scans the union of everything named, mixing directories and single files freely, so a pre-commit hook or a CI step can hand it exactly the paths that changed instead of running the whole project or invoking the binary once per path. Overlapping arguments are reported once, and a path that does not exist still stops the run with exit code 2. Naming no path scans the entire project, as before. - **Blade directives a project registers itself.** A directive declared with `Blade::directive('priceTag', …)` or `Blade::if('bakeryOpen', …)` in a service provider is now read off that registration, so a template writing it gets the same treatment as one writing a directive Blade ships: the name is offered while it is being typed, and the expression the directive is handed stays real PHP whose types are checked, instead of the whole thing being masked as markup. `Blade::if()` registers four directives rather than one, and all four (`@bakeryOpen`, `@unlessbakeryOpen`, `@elsebakeryOpen`, `@endbakeryOpen`) are recognised as the block they form. A directive registered while the editor is open applies to the templates already open. @@ -63,6 +71,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **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. +- **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. - **An `AsEnumCollection` cast is a collection of that enum, not the cast class.** `$order->statuses` written as `AsEnumCollection::of(Status::class)` used to be typed as `AsEnumCollection` itself, so `pluck()` and the rest of the collection API were reported missing and the enum never showed up as the item type. It is now `Collection`. The other class-based casts Laravel ships resolve to the value they actually return: `AsCollection` and `AsEncryptedCollection` (including `of()` and `using()`), `AsArrayObject`, `AsEnumArrayObject`, `AsEncryptedArrayObject`, `AsStringable`, `AsFluent`, `AsHtmlString`, `AsUri`, `AsBinary`, and `AsVector`. Contributed by @calebdw. - **Go to Implementation finds a method a class takes from a trait.** A class that implements an interface method through a `use`d trait was left out of the method's implementations, so the trait's method never appeared among them. It is now listed, alongside methods inherited unchanged from a parent class. Contributed by @sidux. - **A constant defined in terms of other constants has a type.** `const TWO = ONE * 2;` inside a namespace used to have no type, because the `ONE` it names was only looked up in the global namespace. A class constant that divides, concatenates, or spreads other constants had no type either. `self::ONE / self::THREE` is now a `float` (or the exact integer when the division comes out even), `'The value of THREE is ' . self::THREE` is the string `'The value of THREE is 3'`, and `[...parent::KEYS, 'c' => 'c']` is the full array shape. @@ -430,6 +441,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A class named `Scalar` or `Numeric` is a class, not a PHPDoc pseudo-type.** `scalar` and `numeric` have no native spelling in PHP, so a project may name a class either of them, and nikic/php-parser does exactly that with `PhpParser\Node\Scalar`. Any capitalised spelling was folded into the pseudo-type instead of being resolved through the file's imports, which left the name unqualified and every check against it failing: passing a `Scalar` to a parameter typed as its own parent was reported as a mismatch, in a native type hint, a `@param`, a `@return`, and a `@implements` type argument alike. The all-lowercase spellings keep their PHPDoc meaning, which is the same rule already applied to `Number`, `Integer`, `Boolean`, `Double`, and `Resource`. - **An `&&` operand that pins a value to one class outranks a later operand that only lists alternatives.** `$bound instanceof GenericType && ($class === GenericType::class || $bound instanceof TemplateType)` read the two operands as peers and answered `GenericType|TemplateType`, so passing the value on to anything expecting a `GenericType` was reported as a mismatch — even though the first operand alone settles the question. The disjunction can only narrow the value further, never widen it past what was already proven. A disjunction still narrows on its own when nothing in the chain pinned the subject down. - **Reference 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. +- **Reference CodeLens stays responsive when a project has many framework resources.** Symfony and Doctrine class/member links now use an incremental inverted index instead of scanning every YAML/XML reference for each PHP declaration. 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. - **`array{…}|null` and `?array{…}` are the same nullable shape.** The two spellings were tracked as separate types, so a branch that wrote one left its shape standing beside the one another branch had already established: a variable holding `array{a: int}|null` and then reassigned `array{a: int, b: string}|null` under an `if` came out as `array{a: int}|null|array{a: int, b: string}`, one alternative per branch it was written in. Hover, and every check reading past the merge, now see the single folded `?array{a: int, b?: string}`, which is what the same code written without the `null` already reported. Contributed by @sidux. - **Argument checks accept the widenings PHP performs and the types the engine admits it does not know.** Four shapes of correct code were reported as type mismatches: a bounded `int<0, max>` passed to a `float` parameter, even though PHP widens an integer to a float on the way in; a `class-string` passed to `non-empty-string`, even though a string that names a class always has content; an `array-key` passed to `int` or to `string`, which is the key type of an array nobody described rather than a value measured to be two things; and a closure body doing `$a & $b` on untyped parameters, which produces a string from two strings just as readily as an int from two numbers. - **`get_class($x) === Foo::class` narrows the same subjects `instanceof` does.** The identity check only pinned a plain variable, so `get_class($this->held) === Sub::class` and `get_class($items[0]) === Sub::class` left the subject at its declared type and every member read past the check was reported as missing. A property fetch, an array element, and a call result are all narrowed now, in the `$x::class === Foo::class` spelling as well. diff --git a/docs/configuration.md b/docs/configuration.md index c260811ca..cfa69f7ff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,6 +61,102 @@ 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"`). | +### `[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. + +#### `[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/src/backend/documents.rs b/src/backend/documents.rs index 51d33e839..c7415df62 100644 --- a/src/backend/documents.rs +++ b/src/backend/documents.rs @@ -29,8 +29,11 @@ impl Backend { // Resource documents are not PHP source. Build a lightweight symbol // map so navigation, references, rename, and PHP declaration lenses // all consume the same indexed occurrences. - if crate::resource_navigation::is_resource_document(&uri) { - self.update_resource_symbol_index(&uri, &text); + if is_non_php_resource(&uri) { + self.index_resource_text(&uri, &text); + if crate::framework::is_framework_resource_uri(&uri) { + self.schedule_diagnostics(uri.clone()); + } self.log(MessageType::INFO, format!("Opened resource file: {}", uri)) .await; return; @@ -125,9 +128,15 @@ impl Backend { // service loop for every keystroke would stall interactive // requests, and refreshing lenses per keystroke would make the // client re-pull them faster than it can render them. - if crate::resource_navigation::is_resource_document(&uri) { + if is_non_php_resource(&uri) { + // A framework resource carries its own diagnostics (a template + // or route that does not exist), which follow its new text. + let diagnose = crate::framework::is_framework_resource_uri(&uri).then(|| uri.clone()); if self.sync_ast_updates { - self.update_resource_symbol_index(&uri, &text); + self.index_resource_text(&uri, &text); + if let Some(uri) = diagnose { + self.schedule_diagnostics(uri); + } return; } let backend = self.clone_for_blocking(); @@ -142,12 +151,15 @@ impl Backend { if !is_latest_text { return false; } - backend.update_resource_symbol_index(&uri, &text); + backend.index_resource_text(&uri, &text); true }) .await; if committed == Some(true) { + if let Some(uri) = diagnose { + refresh_backend.schedule_diagnostics(uri); + } refresh_backend.request_code_lens_refresh().await; } }); @@ -234,11 +246,16 @@ impl Backend { coalesce.last.lock().retain(|k, _| !k.ends_with(&suffix)); } - if crate::resource_navigation::is_resource_document(&uri) { - if let Some(content) = self.get_file_content(&uri) { - self.update_resource_symbol_index(&uri, &content); - } else { - self.clear_file_maps(&uri); + if is_non_php_resource(&uri) { + if crate::resource_navigation::is_resource_document(&uri) { + if let Some(content) = self.get_file_content(&uri) { + self.update_resource_symbol_index(&uri, &content); + } else { + self.clear_file_maps(&uri); + } + } + if crate::framework::is_framework_resource_uri(&uri) { + self.reindex_framework_uri_from_disk(&uri); } } else if let Some(path) = self.workspace_index_path(&uri) { // A workspace file stays in the index once closed, as the file @@ -272,6 +289,11 @@ impl Backend { }); } } else { + 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); } @@ -284,7 +306,7 @@ impl Backend { pub(crate) async fn on_did_save(&self, params: DidSaveTextDocumentParams) { let uri = params.text_document.uri.to_string(); - let is_resource = crate::resource_navigation::is_resource_document(&uri); + let is_resource = is_non_php_resource(&uri); if let Some(text) = params.text { let text = Arc::new(text); @@ -292,7 +314,7 @@ impl Backend { .write() .insert(uri.clone(), Arc::clone(&text)); if is_resource { - self.update_resource_symbol_index(&uri, &text); + self.index_resource_text(&uri, &text); } else { self.update_ast(&uri, &text); } @@ -356,4 +378,23 @@ impl Backend { self.request_code_lens_refresh().await; } } + + /// Refresh whichever indexes cover a non-PHP resource document: the + /// PHP symbols its YAML/XML names, and its Symfony and Doctrine + /// references. + fn index_resource_text(&self, uri: &str, text: &str) { + if crate::resource_navigation::is_resource_document(uri) { + self.update_resource_symbol_index(uri, text); + } + if crate::framework::is_framework_resource_uri(uri) { + self.index_framework_uri_content(uri, text); + } + } +} + +/// A YAML, XML, or Twig document: indexed for the PHP names and framework +/// symbols it holds, never parsed as PHP. +fn is_non_php_resource(uri: &str) -> bool { + crate::resource_navigation::is_resource_document(uri) + || crate::framework::is_framework_resource_uri(uri) } diff --git a/src/backend/startup.rs b/src/backend/startup.rs index 350ae8add..a1b207870 100644 --- a/src/backend/startup.rs +++ b/src/backend/startup.rs @@ -349,6 +349,20 @@ impl Backend { self.discover_workspace_symbols(&root, php_version, composer_package, Some(&progress)) .await; + // 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 @@ -413,6 +427,14 @@ impl 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; } diff --git a/src/call_hierarchy.rs b/src/call_hierarchy.rs index f863e2037..c53720c34 100644 --- a/src/call_hierarchy.rs +++ b/src/call_hierarchy.rs @@ -85,8 +85,11 @@ impl Backend { &self, item: &CallHierarchyItem, ) -> Option> { + let event_calls = self.symfony_event_incoming_calls(item); let mut cache = FileCache::default(); - let target = self.php_callable_from_item(&mut cache, item)?; + let Some(target) = self.php_callable_from_item(&mut cache, item) else { + return event_calls; + }; let (content, _) = self.cached_file(&mut cache, target.item.uri.as_str())?; let references = self .find_references( @@ -128,6 +131,7 @@ impl Backend { CallHierarchyIncomingCall { from, from_ranges } }) .collect(); + calls.extend(event_calls.unwrap_or_default()); calls.sort_by_cached_key(|call| php_item_key(&call.from)); Some(calls) } @@ -136,10 +140,13 @@ impl Backend { &self, item: &CallHierarchyItem, ) -> Option> { + let event_calls = self.symfony_event_outgoing_calls(item); let mut cache = FileCache::default(); - let callable = self.php_callable_from_item(&mut cache, item)?; + let Some(callable) = self.php_callable_from_item(&mut cache, item) else { + return event_calls; + }; let Some((body_start, body_end)) = callable.body else { - return Some(Vec::new()); + return event_calls.or_else(|| Some(Vec::new())); }; let uri = callable.item.uri.as_str(); let (content, symbol_map) = self.cached_file(&mut cache, uri)?; @@ -189,6 +196,7 @@ impl Backend { CallHierarchyOutgoingCall { to, from_ranges } }) .collect(); + calls.extend(event_calls.unwrap_or_default()); calls.sort_by_cached_key(|call| php_item_key(&call.to)); Some(calls) } @@ -265,6 +273,14 @@ impl Backend { self.php_callable_at(uri, &content, &symbol_map, offset) } + pub(crate) fn call_hierarchy_item_at_location( + &self, + location: &Location, + ) -> Option { + self.php_callable_at_location(&mut FileCache::default(), location) + .map(|callable| callable.item) + } + /// The innermost function or method whose name token or body contains /// `offset`. fn php_callable_at( diff --git a/src/code_actions/mod.rs b/src/code_actions/mod.rs index 230b66949..2f4117606 100644 --- a/src/code_actions/mod.rs +++ b/src/code_actions/mod.rs @@ -119,6 +119,7 @@ mod replace_deprecated; mod replace_fqcn; mod simplify_null; mod sort_use_statements; +mod symfony_template; mod update_docblock; mod docblock_edit; @@ -151,6 +152,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 249d2bfac..6d6a9bc9d 100644 --- a/src/code_lens.rs +++ b/src/code_lens.rs @@ -1,21 +1,26 @@ //! Code Lens (`textDocument/codeLens`) support. //! -//! Shows reference and implementation counts plus override/implement -//! annotations. +//! Shows reference and implementation counts plus override/implement, +//! Symfony, and Doctrine relationship annotations. +use std::collections::HashSet; use std::sync::Arc; use tower_lsp::lsp_types::*; use crate::Backend; use crate::atom::Atom; +use crate::class_lookup::find_class_at_offset; use crate::definition::implementation::member_implementation_providers; use crate::definition::member::MemberKind; +use crate::framework::{FrameworkReferenceKind, SymfonySymbolKind}; use crate::inheritance::find_declaring_ancestor; use crate::reference_index::ReferenceIndexKey; +use crate::references::doctrine_repository_matches_entity_convention; use crate::symbol_map::{SymbolKind, SymbolMap}; use crate::text_position::{LineIndex, offset_to_position}; -use crate::types::{ClassInfo, ClassLikeKind, Visibility}; +use crate::types::{ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, MethodInfo, Visibility}; +use crate::util::short_name; /// Shown while a declaration's references are being counted, so the lens /// keeps its line instead of vanishing and shifting the file, and reads as @@ -66,9 +71,10 @@ impl Backend { /// Handle a `textDocument/codeLens` request. /// /// Returns reference lenses for PHP declarations, implementation - /// lenses for interfaces and abstract classes and their methods, and + /// lenses for interfaces and abstract classes and their methods, /// navigation lenses for methods that override or implement an - /// ancestor declaration. + /// ancestor declaration, and indexed Symfony and Doctrine relationship + /// lenses. pub fn handle_code_lens(&self, uri: &str, content: &str) -> Option> { let classes = { let map = self.symbols.uri_classes_index.read(); @@ -81,8 +87,9 @@ impl Backend { // O(offset) on each call. let index = LineIndex::new(content); - let mut lenses = Vec::new(); + let mut lenses = self.symfony_event_lenses(&classes, uri, content); let class_loader = |name: &str| self.find_or_load_class(name); + let mut seen = HashSet::new(); for class in &classes { let class_fqn = class.fqn(); @@ -113,7 +120,17 @@ impl Backend { if let Some(lens) = self.build_covers_lens(class, uri, &index) { lenses.push(lens); } - + self.push_framework_class_lenses( + uri, + content, + &index, + class, + &class_loader, + (&mut lenses, &mut seen), + ); + // The ancestors a framework route may name a method through, built + // on the first method some framework resource mentions. + let framework_scope = std::cell::OnceCell::new(); for method in &class.methods { if method.name_offset == 0 || method.is_virtual @@ -177,12 +194,25 @@ impl Backend { let command = self.build_code_lens_command(title, target_uri, proto.position); - lenses.push(CodeLens { - range, - command: Some(command), - data: None, - }); + push_unique_lens( + &mut lenses, + &mut seen, + CodeLens { + range, + command: Some(command), + data: None, + }, + ); } + + self.push_framework_method_lenses( + uri, + &index, + class, + &framework_scope, + method, + (&mut lenses, &mut seen), + ); } for property in &class.properties { @@ -244,6 +274,33 @@ impl Backend { } } + self.push_symfony_route_attribute_lenses( + uri, + content, + &index, + &classes, + &mut lenses, + &mut seen, + ); + self.push_doctrine_get_repository_lenses( + uri, + content, + &index, + &class_loader, + &mut lenses, + &mut seen, + ); + self.push_symfony_resource_lenses(uri, content, &index, &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 { @@ -713,6 +770,527 @@ impl Backend { }) } + fn push_symfony_resource_lenses( + &self, + uri: &str, + content: &str, + index: &LineIndex, + 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::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 = index.position(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, + index: &LineIndex, + 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(index, 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, + index: &LineIndex, + class: &ClassInfo, + scope: &std::cell::OnceCell, + method: &MethodInfo, + output: (&mut Vec, &mut HashSet), + ) { + let (lenses, seen) = output; + let method_name = method.name.as_str(); + if !self.has_framework_member_references(method_name) { + return; + } + let scope = scope.get_or_init(|| { + let mut hierarchy = HashSet::new(); + hierarchy.insert(class.fqn().to_string()); + hierarchy.extend(self.class_hierarchy_names(class)); + crate::references::MemberScope::exact(hierarchy) + }); + + let route_locations = self.framework_member_reference_locations(method_name, Some(scope)); + if route_locations.is_empty() { + return; + } + let pos = index.position(method.name_offset as usize); + + 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, + index: &LineIndex, + classes: &[Arc], + lenses: &mut Vec, + seen: &mut HashSet, + ) { + let attributes = route_attributes(content); + if attributes.is_empty() { + return; + } + let declarations = code_lens_declarations(index, classes); + if declarations.is_empty() { + return; + } + + for attr in attributes { + // `declarations` is sorted by offset, so the first one past the + // attribute is the declaration it decorates. + let next = declarations.partition_point(|decl| decl.offset <= attr.end); + let Some(decl) = declarations.get(next) 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, + index: &LineIndex, + class_loader: &dyn Fn(&str) -> Option>, + lenses: &mut Vec, + seen: &mut HashSet, + ) { + let calls = get_repository_calls(content); + if calls.is_empty() { + return; + } + let ctx = self.file_context(uri); + for call in calls { + let offset = call.offset as u32; + let Some(entity_fqn) = class_expr_arg_to_fqn( + &call.first_arg, + ctx.use_map_at(offset), + ctx.namespace_at(offset), + &ctx.classes, + offset, + ) 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 = index.position(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; + } + if class_loader(&repository).is_none() || !self.is_doctrine_repository_class(&repository) { + return out; + } + + // The conventional spellings first: `Repository\UserRepository` + // belongs to `Entity\User` (or `Entities`, `Model`, `Models`, or its + // own namespace). Only an entity kept somewhere else is looked for + // among the indexed classes. + for entity_fqn in doctrine_entity_convention_candidates(&repository) { + if let Some(entity) = class_loader(&entity_fqn) { + let entity_fqn = normalize_class_name(&entity.fqn()); + if !out + .iter() + .any(|known| known.eq_ignore_ascii_case(&entity_fqn)) + { + out.push(entity_fqn); + } + } + } + if !out.is_empty() { + return out; + } + + let index = self.symbols.fqn_class_index.read(); + for entity_fqn in index.keys() { + let entity_fqn: &str = entity_fqn; + if entity_fqn.eq_ignore_ascii_case(&repository) + || !looks_like_doctrine_entity_name(entity_fqn) + || !doctrine_repository_matches_entity_convention(entity_fqn, &repository) + { + continue; + } + let entity_fqn = normalize_class_name(entity_fqn); + if !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 = Vec::new(); + let mut parent_name = class.parent_class; + for _ in 0..MAX_INHERITANCE_DEPTH { + let Some(name) = parent_name else { + break; + }; + let parent_fqn = name.to_string(); + if out + .iter() + .any(|known| known.eq_ignore_ascii_case(&parent_fqn)) + { + break; + } + out.push(parent_fqn); + let Some(parent) = self.find_or_load_class(&name) else { + break; + }; + parent_name = parent.parent_class; + } + out + } + /// The closest ancestor that declares a method with the given name, /// as a navigation target. /// @@ -791,6 +1369,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( @@ -821,3 +1417,471 @@ 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(index: &LineIndex, class: &ClassInfo) -> Option { + let offset = if class.keyword_offset > 0 { + class.keyword_offset + } else { + class.decl_start_offset + }; + if offset == 0 || offset as usize > index.content().len() { + None + } else { + Some(index.position(offset as usize)) + } +} + +fn code_lens_declarations( + index: &LineIndex, + classes: &[Arc], +) -> Vec { + let mut declarations = Vec::new(); + for class in classes { + if let Some(position) = class_lens_position(index, 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: index.position(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: &[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" + ) +} + +/// Where a repository's entity conventionally lives, from the repository's +/// own name: `App\Repository\UserRepository` → `App\Entity\User` and its +/// siblings, and a class of the same stem next to the repository. +fn doctrine_entity_convention_candidates(repository_fqn: &str) -> Vec { + let short = short_name(repository_fqn); + let Some(stem) = short + .strip_suffix("Repository") + .filter(|stem| !stem.is_empty()) + else { + return Vec::new(); + }; + let namespace = repository_fqn + .rsplit_once('\\') + .map_or("", |(namespace, _)| namespace); + let mut out = Vec::new(); + let names = [ + stem.to_string(), + format!("{stem}Entity"), + format!("{stem}Impl"), + ]; + for name in &names { + if namespace.is_empty() { + out.push(name.clone()); + continue; + } + out.push(format!("{namespace}\\{name}")); + for suffix in ["\\Repository", "\\Repositories"] { + if let Some(root) = namespace.strip_suffix(suffix) { + for folder in ["Entity", "Entities", "Model", "Models"] { + out.push(format!("{root}\\{folder}\\{name}")); + } + } + } + } + out +} + +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 b6c76b605..88bbc0983 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -192,6 +192,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 @@ -354,6 +358,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..b803da006 --- /dev/null +++ b/src/completion/symfony.rs @@ -0,0 +1,664 @@ +//! 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 { + 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 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. @@ -1048,6 +1220,102 @@ 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_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/resolve.rs b/src/definition/resolve.rs index 93cf57b22..c88f3f312 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}; @@ -67,9 +68,103 @@ 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::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::Namespace { .. } + | FrameworkReferenceKind::Path { .. } + | FrameworkReferenceKind::SymfonySymbol { + declaration: true, .. + } + | FrameworkReferenceKind::RouteParameter { + declaration: true, .. + } + | FrameworkReferenceKind::Translation { + 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)) } /// 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 414cf5ee3..f34b66597 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -116,6 +116,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, @@ -249,6 +252,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; @@ -300,6 +305,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); @@ -401,6 +409,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 @@ -477,6 +489,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) @@ -571,6 +587,7 @@ impl Backend { self.collect_blade_section_diagnostics(uri_str, out) ); } + self.collect_unknown_symfony_resource_diagnostics(uri_str, content, out); } } diff --git a/src/diagnostics/symfony.rs b/src/diagnostics/symfony.rs new file mode 100644 index 000000000..9bb731b1d --- /dev/null +++ b/src/diagnostics/symfony.rs @@ -0,0 +1,149 @@ +//! 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; + }; + // The workspace's declarations are only gathered for a file that + // uses a name, and in one pass over the index for every kind. + let uses_a_name = references.iter().any(|reference| { + matches!( + reference.kind, + FrameworkReferenceKind::SymfonySymbol { + declaration: false, + .. + } | FrameworkReferenceKind::Translation { + declaration: false, + .. + } + ) + }); + if !uses_a_name { + return; + } + let declared = self.framework_declared_symfony_symbols(); + let is_declared = |kind: SymfonySymbolKind, name: &str| { + declared + .get(&kind) + .is_some_and(|names| names.contains(name)) + }; + let mut translation_domains = HashSet::new(); + let mut known_translations = HashSet::new(); + for refs in self.framework_references.read().values() { + for reference in refs.iter() { + 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::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 => { + is_declared(*kind, name) + || (name.starts_with("App\\") && self.find_or_load_class(name).is_some()) + } + SymfonySymbolKind::Parameter => is_declared(*kind, name), + SymfonySymbolKind::Route => is_declared(*kind, name), + SymfonySymbolKind::RouteParameter => true, + SymfonySymbolKind::Template => is_declared(*kind, name), + SymfonySymbolKind::Translation => true, + SymfonySymbolKind::Event => is_declared(*kind, name), + SymfonySymbolKind::MessengerBus => is_declared(*kind, 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..f3231a6ad --- /dev/null +++ b/src/framework.rs @@ -0,0 +1,4673 @@ +//! 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::sort_locations_for_references; +use crate::text_position::{LineIndex, 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 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, + }, +} + +#[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, + messenger_classes: HashSet, +} + +/// Inverted class, method, and Messenger relations 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>, + 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\\")) +} + +/// Whether `path` sits in a dependency, VCS, or generated directory of the +/// workspace. Only the part below `root` is inspected: a workspace that itself +/// lives under `/var/www` or a temp directory is not a cache directory. +fn is_skipped_resource_path(root: Option<&Path>, path: &Path) -> bool { + let relative = root + .and_then(|root| path.strip_prefix(root).ok()) + .unwrap_or(path); + relative.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 line_index = LineIndex::new(content); + let mut keys = FrameworkLookupUriKeys::default(); + for reference in references { + let location = IndexedFrameworkLocation { + uri: Arc::clone(&uri), + range: Range::new( + line_index.position(reference.start as usize), + line_index.position(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: normalize_framework_fqn(class_fqn), + location, + }, + ); + keys.methods.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.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.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; + } + let relative = path.strip_prefix(&root).unwrap_or(path); + if (!is_framework_resource_path(path) + && !is_framework_php_config_path(relative) + && !is_symfony_translation_php_path(relative)) + || is_skipped_resource_path(Some(&root), 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 root = self.workspace.workspace_root.read().clone(); + 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(root.as_deref(), 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 + } + + /// Whether any framework resource names a method called `member`, so a + /// caller can skip building the class scope a lookup would filter by. + pub(crate) fn has_framework_member_references(&self, member: &str) -> bool { + self.framework_reference_lookup + .read() + .methods + .contains_key(member) + } + + pub(crate) fn framework_member_reference_locations( + &self, + target_member: &str, + hierarchy: Option<&crate::references::MemberScope>, + ) -> Vec { + let lookup = self.framework_reference_lookup.read(); + let mut locations = lookup + .methods + .get(target_member) + .into_iter() + .flatten() + .filter(|entry| { + hierarchy.is_none_or(|hierarchy| hierarchy.contains(self, &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 seen = HashSet::new(); + 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 && seen.insert(name.to_ascii_lowercase()) { + names.push(name.clone()); + } + } + } + names.sort_unstable(); + names + } + + /// Every declared Symfony symbol name, by kind, from one pass over the + /// framework index. + pub(crate) fn framework_declared_symfony_symbols( + &self, + ) -> HashMap> { + let mut declared: HashMap> = HashMap::new(); + for refs in self.framework_references.read().values() { + for reference in refs.iter() { + if let FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration: true, + } = &reference.kind + { + declared.entry(*kind).or_default().insert(name.clone()); + } + } + } + declared + } + + pub(crate) fn framework_symfony_symbol_locations( + &self, + target_kind: SymfonySymbolKind, + target_name: &str, + include_declarations: bool, + include_references: bool, + ) -> Vec { + self.framework_locations_where(|kind| { + let FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration, + } = kind + else { + return false; + }; + *kind == target_kind + && name == target_name + && if *declaration { + include_declarations + } else { + include_references + } + }) + } + + /// Every framework resource occurrence whose kind satisfies `matches`, + /// in Find References order. + fn framework_locations_where( + &self, + matches: impl Fn(&FrameworkReferenceKind) -> bool, + ) -> Vec { + let mut locations = Vec::new(); + for (uri, refs) in self.framework_references.read().iter() { + let mut matching = refs.iter().filter(|reference| matches(&reference.kind)); + let Some(first) = matching.next() else { + continue; + }; + let Ok(parsed_uri) = Url::parse(uri) else { + continue; + }; + let Some(content) = self.get_file_content_arc(uri) else { + continue; + }; + let index = LineIndex::new(&content); + for reference in std::iter::once(first).chain(matching) { + locations.push(Location { + uri: parsed_uri.clone(), + range: Range { + start: index.position(reference.start as usize), + end: index.position(reference.end as usize), + }, + }); + } + } + sort_locations_for_references(&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 { + self.framework_locations_where(|kind| { + let FrameworkReferenceKind::RouteParameter { + route_name: candidate_route, + name, + declaration, + } = kind + else { + return false; + }; + candidate_route == route_name + && name == parameter_name + && if *declaration { + include_declarations + } else { + include_references + } + }) + } + + 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 { + self.framework_locations_where(|kind| { + let FrameworkReferenceKind::Translation { + domain: candidate_domain, + name: candidate_name, + declaration, + } = kind + else { + return false; + }; + candidate_domain == domain + && candidate_name == name + && if *declaration { + include_declarations + } else { + include_references + } + }) + } + + 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); + self.framework_locations_where(|kind| { + let FrameworkReferenceKind::MessengerHandler { + message_fqn: candidate_message, + handler_fqn: candidate_handler, + .. + } = kind + else { + return false; + }; + normalize_framework_fqn(candidate_message).eq_ignore_ascii_case(&message_fqn) + && normalize_framework_fqn(candidate_handler).eq_ignore_ascii_case(&handler_fqn) + }) + } + + 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_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::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)) + } + _ => 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) + } + } + + /// Edits that move every framework resource name under `old_prefix` to + /// `new_prefix`: the namespace itself, the namespace-prefix service keys + /// under it, and every class it contains. + pub(crate) fn collect_framework_namespace_edits( + &self, + old_prefix: &str, + new_prefix: &str, + changes: &mut HashMap>, + ) { + self.collect_framework_name_edits(old_prefix, new_prefix, true, changes); + } + + /// Edits that rename class `old_fqn` to `new_fqn` wherever a framework + /// resource names it, `Class::method` controller strings included. + pub(crate) fn collect_framework_class_edits( + &self, + old_fqn: &str, + new_fqn: &str, + changes: &mut HashMap>, + ) { + self.collect_framework_name_edits(old_fqn, new_fqn, false, changes); + } + + /// Rewrite the framework resource names equal to `old_name`, or nested + /// under it when `nested` is set, to the matching name under + /// `new_name`. + /// + /// Each occurrence is written back in the spelling its document uses + /// (a leading backslash, the doubled backslashes of a quoted YAML + /// string), which is why these edits are built here rather than by the + /// PHP rename. Each occurrence is also checked against the document's + /// current text on its own: one the index no longer matches is left + /// alone instead of cancelling the edits around it. An occurrence the + /// PHP rename already edited (a `Foo::class` in a PHP configurator) is + /// left to that edit. + fn collect_framework_name_edits( + &self, + old_name: &str, + new_name: &str, + nested: bool, + changes: &mut HashMap>, + ) { + let old_name = normalize_framework_fqn(old_name); + let new_name = normalize_framework_fqn(new_name); + if old_name.is_empty() || new_name.is_empty() { + return; + } + let nested_prefix = format!("{}\\", old_name.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; + }; + let mut line_index = None; + let php_edits: Vec = changes + .get(&parsed_uri) + .map(|edits| edits.iter().map(|edit| edit.range).collect()) + .unwrap_or_default(); + for reference in refs.iter() { + let name = match &reference.kind { + FrameworkReferenceKind::Class { fqn } => fqn, + FrameworkReferenceKind::Namespace { prefix } if nested => prefix, + _ => continue, + }; + let normalized = normalize_framework_fqn(name); + let exact = normalized.eq_ignore_ascii_case(&old_name); + if !exact + && !(nested && normalized.to_ascii_lowercase().starts_with(&nested_prefix)) + { + continue; + } + let Some(source) = content.get(reference.start as usize..reference.end as usize) + else { + continue; + }; + if !normalize_framework_fqn(source).eq_ignore_ascii_case(&normalized) { + continue; + } + + let replacement = if exact { + new_name.clone() + } else { + format!("{}{}", new_name, &normalized[old_name.len()..]) + }; + let index = line_index.get_or_insert_with(|| LineIndex::new(&content)); + let range = Range { + start: index.position(reference.start as usize), + end: index.position(reference.end as usize), + }; + if php_edits + .iter() + .any(|edit| edit.start < range.end && range.start < edit.end) + { + continue; + } + changes + .entry(parsed_uri.clone()) + .or_default() + .push(TextEdit { + range, + new_text: rewrite_framework_fqn_literal(source, &replacement), + }); + } + } + } + + 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); + + 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() + } +} + +#[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 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); + } 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); + } + 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_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 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 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 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 workspace_inside_a_var_directory_is_indexed_but_its_own_cache_is_not() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("var/www/app"); + let yaml = "home:\n path: /\n controller: App\\Controller\\HomeController::index\n"; + for relative in ["config/routes.yaml", "var/cache/dev/routes.yaml"] { + let path = root.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, yaml).unwrap(); + } + + let backend = Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(root.clone()); + + assert_eq!(backend.index_framework_workspace(), 1); + let indexed: Vec = backend + .framework_references + .read() + .keys() + .cloned() + .collect(); + assert!(indexed[0].ends_with("/config/routes.yaml"), "{indexed:?}"); + } + + #[test] + fn namespace_segments_count_only_names_in_escaped_and_rooted_spellings() { + // `App\\Domain\\` as a quoted YAML string: `Domain` is segment 1. + let escaped = "App\\\\Domain\\\\"; + assert_eq!( + namespace_segment_range_at_offset(escaped, 10, 16), + Some((1, 15, 21)) + ); + // `\App\Domain`: the leading separator is not a segment. + assert_eq!( + namespace_segment_range_at_offset("\\App\\Domain", 0, 2), + Some((0, 1, 4)) + ); + } + + #[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/watch.rs b/src/indexing/watch.rs index aef910742..ff12dae1f 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -56,6 +56,7 @@ impl Backend { ) -> bool { let mut composer_changed = false; let mut config_changed = false; + let mut 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(); @@ -63,13 +64,18 @@ impl Backend { let mut migration_discovery = crate::virtual_members::laravel::database_schema::MigrationDiscovery::default(); let is_laravel = self.resolved_class_cache.read().is_laravel(); + let current_config = self.config(); + 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 changes = self.spell_changes_as_indexed(¶ms.changes); + let mut framework_changes: Vec<(String, PathBuf, FileChangeType)> = Vec::new(); { let open = self.open_files.read(); let parsed = self.parsed_uris.read(); let indexed = self.symbol_maps.read(); - let laravel_config = self.config().laravel; + let laravel_config = current_config.laravel; let filters = self.index_filters(); for change in changes.iter() { let path_str = change.uri.path(); @@ -150,6 +156,9 @@ impl Backend { continue; } } + if crate::framework::is_framework_resource_uri(&uri_str) { + framework_changes.push((uri_str.clone(), file_path.clone(), change.typ)); + } resource_changes.push((uri_str, file_path, change.typ)); continue; } @@ -158,6 +167,15 @@ impl Backend { .and_then(|ext| ext.to_str()) .is_some_and(|ext| filters.is_php_extension(ext)); if !is_php { + // A Twig template names routes, translations, and other + // templates the framework index tracks. + if crate::framework::is_framework_resource_uri(&uri_str) + && !open.contains_key(&uri_str) + && let Ok(file_path) = change.uri.to_file_path() + && !filters.is_excluded_path(&file_path, false) + { + framework_changes.push((uri_str, file_path, change.typ)); + } continue; } @@ -169,12 +187,26 @@ impl Backend { 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; + } + // Excluded paths are invisible to indexing; skip their // events the way the workspace scanners skip the files. if filters.is_excluded_path(&file_path, false) { 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; @@ -185,6 +217,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)); @@ -195,8 +235,10 @@ impl Backend { && resource_changes.is_empty() && !composer_changed && !config_changed + && !symfony_metadata_rebuild && !schema_full_rebuild && migration_changes.is_empty() + && framework_changes.is_empty() { return false; } @@ -204,6 +246,7 @@ impl Backend { if config_changed { tracing::info!("PHPantom: .phpantom.toml changed, reloading configuration"); self.reload_config(root); + 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 @@ -247,6 +290,15 @@ impl Backend { self.laravel_aliases.invalidate(); self.member_completion_cache.lock().clear(); } + 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); + } + } + } } if composer_changed { @@ -267,6 +319,11 @@ impl Backend { } } } + 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); @@ -278,6 +335,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 } @@ -399,6 +466,8 @@ impl Backend { ("**/*.php".to_string(), watch_all), ("**/*.{yaml,yml,xml}".to_string(), watch_all), ("**/*.{yaml,yml,xml}.dist".to_string(), watch_all), + ("**/*.twig".to_string(), watch_all), + ("**/*.{xlf,xliff}".to_string(), watch_all), ("**/composer.json".to_string(), WatchKind::Change), ("**/composer.lock".to_string(), WatchKind::Change), ("**/.phpantom.toml".to_string(), watch_all), @@ -552,6 +621,12 @@ 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 || { + metadata_backend.rebuild_symfony_metadata(&metadata_root) + }) + .await; } } } @@ -560,6 +635,51 @@ impl Backend { mod tests { use super::*; + /// XLIFF catalogs are watched like the other framework resources, so a + /// key added on disk while the catalog is closed is known without a + /// restart. + #[test] + fn an_xliff_catalog_changed_on_disk_is_reindexed() { + let dir = tempfile::tempdir().unwrap(); + let catalog = dir.path().join("translations/messages.en.xlf"); + std::fs::create_dir_all(catalog.parent().unwrap()).unwrap(); + let xliff = |keys: &[&str]| { + let units: String = keys + .iter() + .map(|key| format!("{key}")) + .collect(); + format!( + "{units}" + ) + }; + std::fs::write(&catalog, xliff(&["app.hello"])).unwrap(); + + let backend = Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + backend.index_framework_workspace(); + assert_eq!( + backend.framework_translation_names("messages"), + vec!["app.hello"] + ); + + let (registration, _) = backend.build_watched_file_registration(); + let options = registration.register_options.unwrap().to_string(); + assert!(options.contains("xlf"), "{options}"); + + std::fs::write(&catalog, xliff(&["app.hello", "app.bye"])).unwrap(); + let params = DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Url::from_file_path(&catalog).unwrap(), + typ: FileChangeType::CHANGED, + }], + }; + assert!(backend.apply_watched_file_changes(¶ms, dir.path())); + assert_eq!( + backend.framework_translation_names("messages"), + vec!["app.bye", "app.hello"] + ); + } + #[test] fn non_laravel_projects_ignore_schema_watch_changes() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 3cc00c5cf..867134689 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -262,6 +262,7 @@ pub mod fix; mod folding; pub mod format_cli; pub mod formatting; +mod framework; mod highlight; mod hover; mod indexing; @@ -308,6 +309,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; @@ -619,6 +621,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. @@ -626,6 +642,9 @@ 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, + /// 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 @@ -1231,7 +1250,11 @@ 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(), + symfony_events: Arc::new(RwLock::new(symfony::SymfonyEventIndex::default())), skip_reference_index: false, symbols: SymbolIndex::new(), workspace, @@ -2034,7 +2057,11 @@ 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), + 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), diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 5cec98440..94c314e2d 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -298,6 +298,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 blade = self.lower_blade_template(uri, content); let content_to_parse = blade.as_ref().map_or_else( || Arc::new(content.to_string()), @@ -326,6 +336,12 @@ impl Backend { self.update_ast_inner(&uri_owned, &content_owned, blade) }); + // 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); + } // The refreshes below that rebuild from the registered provider list // share one read of it. let providers = crate::backend::laravel::ProvidersOnce::new(self); diff --git a/src/references/classes.rs b/src/references/classes.rs index a6abc26aa..7c8445180 100644 --- a/src/references/classes.rs +++ b/src/references/classes.rs @@ -108,6 +108,27 @@ impl Backend { ) } + /// [`find_class_references`](Self::find_class_references) plus the + /// Symfony and Doctrine resources that name the class. + /// + /// A rename leaves the resource occurrences out: the framework index + /// rewrites those itself, in the spelling each one is written in, where + /// a PHP-shaped edit could not be planned over an escaped string. + pub(super) fn find_class_references_in_mode( + &self, + target_fqn: &str, + include_declaration: bool, + mode: ReferenceSearchMode, + ) -> Vec { + let mut locations = self.find_class_references(target_fqn, include_declaration); + if mode != ReferenceSearchMode::Rename { + locations + .extend(self.framework_class_reference_locations(strip_fqn_prefix(target_fqn))); + sort_locations_for_references(&mut locations); + } + locations + } + /// Find all references to a constructor (`__construct`). /// /// Unlike ordinary methods, constructors are not invoked through diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 0186f6929..0c512c74d 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -99,16 +99,20 @@ impl Backend { include_declaration, mode, ); - // A YAML/XML occurrence is a reference the user can be shown, - // but not one an edit can be planned against: its text may be - // the escaped `App\\Handler` form the document's own quoting - // requires, which is neither the PHP spelling of the name nor - // something a PHP-shaped replacement can be written over. - // Rename verifies every location before emitting any edit and - // drops the whole rename when one fails, so leaving these in - // makes a single escaped name in any config file silently turn - // the class rename into a no-op. - if mode == ReferenceSearchMode::Rename { + // A YAML/XML occurrence of a class or namespace is a reference + // the user can be shown, but not one a PHP-shaped edit can be + // written over: its text may be the escaped `App\\Handler` form + // the document's own quoting requires. Rename rewrites those + // through the framework resource index, which keeps each + // occurrence's spelling, so they are left out here. A member + // occurrence (`Controller::action`) covers the bare member name, + // which the PHP edit replaces as written. + if mode == ReferenceSearchMode::Rename + && !matches!( + sym.kind, + SymbolKind::MemberAccess { .. } | SymbolKind::MemberDeclaration { .. } + ) + { locations.retain(|location| { !crate::resource_navigation::is_resource_document(location.uri.as_str()) }); @@ -151,6 +155,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() @@ -158,6 +174,76 @@ impl Backend { None } + /// The occurrences a rename of the Symfony or Doctrine name under the + /// cursor has to edit. + pub(crate) fn find_framework_references_for_rename( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option> { + self.find_framework_references_at(uri, content, position, true, 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_in_mode(&fqn, include_declaration, mode) + } + 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::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::Namespace { .. } | FrameworkReferenceKind::Path { .. } => { + Vec::new() + } + }; + Some(locations) + } + /// Dispatch a symbol-map hit to the appropriate reference finder. fn dispatch_symbol_references( &self, @@ -218,12 +304,12 @@ impl Backend { } else { ctx.resolve_name_at(name, span_start) }; - self.find_class_references(&fqn, include_declaration) + self.find_class_references_in_mode(&fqn, include_declaration, mode) } SymbolKind::ClassDeclaration { name } => { let ctx = self.file_context(uri); let fqn = build_fqn(name, ctx.namespace_at(span_start).as_deref()); - self.find_class_references(&fqn, include_declaration) + self.find_class_references_in_mode(&fqn, include_declaration, mode) } SymbolKind::MemberAccess { subject_text, @@ -385,7 +471,7 @@ impl Backend { if let Some(fqn) = crate::class_lookup::resolve_class_keyword(keyword, current_class) { - self.find_class_references(&fqn, include_declaration) + self.find_class_references_in_mode(&fqn, include_declaration, mode) } else { Vec::new() } diff --git a/src/references/member_scope.rs b/src/references/member_scope.rs index 36163f1c3..cb021ce80 100644 --- a/src/references/member_scope.rs +++ b/src/references/member_scope.rs @@ -60,7 +60,7 @@ struct MemberScopeInner { impl MemberScope { /// A scope that is exactly `fqns`, with no walk behind it. - pub(super) fn exact(fqns: HashSet) -> Self { + pub(crate) fn exact(fqns: HashSet) -> Self { Self(Arc::new(MemberScopeInner { indexed: fqns, roots: HashSet::new(), @@ -85,7 +85,7 @@ impl MemberScope { } /// Whether a receiver resolved to `fqn` carries the searched member. - pub(super) fn contains(&self, backend: &Backend, fqn: &str) -> bool { + pub(crate) fn contains(&self, backend: &Backend, fqn: &str) -> bool { let fqn = strip_fqn_prefix(fqn); if self.0.indexed.contains(fqn) { return true; @@ -202,7 +202,13 @@ impl Backend { access_offset: u32, content: &str, ) -> Vec { - match self.resolve_subject_type_at(subject_text, is_static, ctx, access_offset, content) { + let resolved = match self.resolve_subject_type_at( + subject_text, + is_static, + ctx, + access_offset, + content, + ) { Some(php_type) => { self.class_names_to_fqns(php_type.top_level_class_names(), ctx, access_offset) } @@ -216,7 +222,27 @@ impl Backend { ctx.namespace_at(access_offset), ), ), + }; + + // Doctrine types `getRepository()` as its generic repository, so when + // that (or nothing) is all the type engine knows, the entity class the + // call names picks the concrete repository instead. + if resolved + .iter() + .all(|fqn| is_generic_doctrine_repository(fqn)) + && (subject_text.contains("getRepository") || subject_text.starts_with('$')) + { + let doctrine_repository_fqns = self.resolve_doctrine_repository_subject_to_fqns( + subject_text, + ctx, + access_offset, + content, + ); + if !doctrine_repository_fqns.is_empty() { + return doctrine_repository_fqns; + } } + resolved } /// The type a member-access subject resolves to, through the shared @@ -279,6 +305,168 @@ impl Backend { .collect() } + fn resolve_doctrine_repository_subject_to_fqns( + &self, + subject_text: &str, + ctx: &crate::types::FileContext, + access_offset: u32, + content: &str, + ) -> Vec { + let use_map = ctx.use_map_at(access_offset); + let namespace = ctx.namespace_at(access_offset); + let class_loader = self.class_loader_with(&ctx.classes, use_map, namespace); + let expr = crate::type_engine::subject_expr::SubjectExpr::parse(subject_text); + let mut candidates = self.doctrine_repository_fqns_from_expr( + &expr, + use_map, + 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, + use_map, + 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); + let mapped = candidate_fqns.len(); + candidate_fqns.extend(doctrine_repository_convention_candidates( + &entity, + &repository_short, + )); + let conventional = candidate_fqns.len(); + candidate_fqns.extend( + self.doctrine_repository_classes() + .into_iter() + .filter(|fqn| crate::util::short_name(fqn).eq_ignore_ascii_case(&repository_short)), + ); + + 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 (index, candidate) in candidate_fqns.into_iter().enumerate() { + let normalized = normalize_fqn(&candidate); + if resolved + .iter() + .any(|known: &String| known.eq_ignore_ascii_case(&normalized)) + { + continue; + } + let Some(class_info) = class_loader(&normalized) else { + continue; + }; + // A class the mapping names is the repository whatever it extends; + // one found by its name has to actually be a Doctrine repository, + // or a project's own `UserRepository` would pass for one. + if (mapped..conventional).contains(&index) + && !self.is_doctrine_repository_class(&class_info.fqn()) + { + continue; + } + resolved.push(normalize_fqn(&class_info.fqn())); + } + resolved + } + + /// Whether `class_fqn` extends or implements one of Doctrine's + /// repository types, directly or through its ancestors. + pub(crate) fn is_doctrine_repository_class(&self, class_fqn: &str) -> bool { + let class_loader = |name: &str| -> Option> { self.find_or_load_class(name) }; + let mut ancestors = HashSet::new(); + self.collect_ancestors(class_fqn, &class_loader, &mut ancestors); + ancestors + .iter() + .any(|ancestor| is_generic_doctrine_repository(ancestor)) + } + + /// Every class the inheritance index records as a Doctrine repository. + fn doctrine_repository_classes(&self) -> Vec { + let gti = self.symbols.gti_index.read(); + let mut pending: Vec<&str> = DOCTRINE_REPOSITORY_BASES.to_vec(); + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::new(); + while let Some(parent) = pending.pop() { + let Some(children) = gti.get(parent) else { + continue; + }; + for child in children { + if seen.insert(child.to_ascii_lowercase()) { + out.push(child.clone()); + pending.push(child.as_str()); + } + } + } + out + } + fn resolve_static_laravel_builder_subject_to_fqns( &self, subject_text: &str, @@ -457,7 +645,7 @@ impl Backend { closure } - fn collect_member_receiver_scope( + pub(super) fn collect_member_receiver_scope( &self, seed_fqns: &[String], member_name: &str, @@ -744,3 +932,161 @@ 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 +} + +/// Doctrine's own repository types, spelled the way a class may name them. +const DOCTRINE_REPOSITORY_BASES: [&str; 6] = [ + "Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository", + "Doctrine\\ORM\\EntityRepository", + "Doctrine\\Persistence\\ObjectRepository", + "ServiceEntityRepository", + "EntityRepository", + "ObjectRepository", +]; + +/// Whether `fqn` is one of Doctrine's generic repository types rather than a +/// project's concrete repository. +fn is_generic_doctrine_repository(fqn: &str) -> bool { + let fqn = fqn.trim_start_matches('\\'); + DOCTRINE_REPOSITORY_BASES + .iter() + .any(|base| base.eq_ignore_ascii_case(fqn)) +} + +/// The expression last assigned to `var_name` before `access_offset`, within +/// the function the access sits in. +/// +/// Only a plain `$var = …` counts: `$var == …`, `$var => …`, and a longer +/// name such as `$variable = …` are skipped. Assignments in another +/// function body are out of reach of the access, so the search stops at the +/// nearest `function` keyword before it. +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 scope = &prefix[enclosing_function_start(prefix)..]; + let mut search_end = scope.len(); + while let Some(at) = scope[..search_end].rfind(var_name) { + search_end = at; + let after = &scope[at + var_name.len()..]; + if after.starts_with(|c: char| c == '_' || c.is_ascii_alphanumeric()) { + continue; + } + let Some(value) = after.trim_start().strip_prefix('=') else { + continue; + }; + if value.starts_with(['=', '>']) { + continue; + } + let value = value.trim_start(); + let end = value + .find(';') + .or_else(|| value.find('\n')) + .unwrap_or(value.len()); + let expr = value[..end].trim(); + return (!expr.is_empty()).then_some(expr); + } + None +} + +/// Byte offset just past the last `function` keyword in `prefix`, or `0`. +fn enclosing_function_start(prefix: &str) -> usize { + let bytes = prefix.as_bytes(); + let mut end = prefix.len(); + while let Some(at) = prefix[..end].rfind("function") { + let before_ok = at == 0 || !is_identifier_byte(bytes[at - 1]); + let after = at + "function".len(); + let after_ok = bytes + .get(after) + .is_none_or(|byte| !is_identifier_byte(*byte)); + if before_ok && after_ok { + return after; + } + end = at; + } + 0 +} + +fn is_identifier_byte(byte: u8) -> bool { + byte == b'_' || byte == b'$' || byte.is_ascii_alphanumeric() +} diff --git a/src/references/members.rs b/src/references/members.rs index 5aa3f0e12..f33781f7d 100644 --- a/src/references/members.rs +++ b/src/references/members.rs @@ -490,7 +490,7 @@ impl Backend { let candidate_keys = member_candidate_keys(target_member, target_is_static, hierarchy); let member = crate::atom::atom(target_member); let target_name = target_member.strip_prefix('$').unwrap_or(target_member); - self.scan_reference_candidates( + let mut locations = self.scan_reference_candidates( &candidate_keys, "Scanning for member references", |file, symbol_map, locations| { @@ -605,7 +605,10 @@ impl Backend { } } }, - ) + ); + locations.extend(self.framework_member_reference_locations(target_member, hierarchy)); + sort_locations_for_references(&mut locations); + locations } /// The accesses to `member` in one file that Find References reports. diff --git a/src/references/mod.rs b/src/references/mod.rs index 98b8fbe64..bf6fe0a88 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -39,13 +39,16 @@ mod members; mod receivers; mod variables; +pub(crate) use member_scope::{MemberScope, doctrine_repository_matches_entity_convention}; pub(crate) use members::MemberDeclarationReferenceQuery; + use std::collections::HashSet; use std::sync::Arc; use tower_lsp::lsp_types::{Location, Position, Range, Url}; use crate::Backend; +use crate::framework::FrameworkReferenceKind; use crate::reference_index::ReferenceIndexKey; use crate::symbol_map::SymbolMap; use crate::util::strip_fqn_prefix; diff --git a/src/rename/class/mod.rs b/src/rename/class/mod.rs index 5e70704ab..2fb9eacdd 100644 --- a/src/rename/class/mod.rs +++ b/src/rename/class/mod.rs @@ -237,6 +237,8 @@ impl Backend { } } + self.collect_framework_class_edits(old_fqn_normalized, &new_fqn, &mut changes); + if changes.is_empty() { return None; } @@ -303,6 +305,8 @@ impl Backend { } } + self.collect_framework_class_edits(old_fqn, new_fqn, &mut changes); + if changes.is_empty() { return Ok(None); } diff --git a/src/rename/namespace/mod.rs b/src/rename/namespace/mod.rs index 23e5824b3..06c6417f9 100644 --- a/src/rename/namespace/mod.rs +++ b/src/rename/namespace/mod.rs @@ -204,17 +204,20 @@ 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 Ok(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) - { - let renames = ops.iter().map(|(old_uri, new_uri)| { + if !psr4_rename_ops.is_empty() && self.supports_file_rename.load(Ordering::Acquire) { + let renames = psr4_rename_ops.iter().map(|(old_uri, new_uri)| { ResourceOp::Rename(RenameFile { old_uri: old_uri.clone(), new_uri: new_uri.clone(), @@ -231,7 +234,7 @@ impl Backend { // names keeps its own URI, which is what leaves a skipped file // edited in place. let edits = changes.into_iter().map(|(uri, edits)| { - let target_uri = ops + let target_uri = psr4_rename_ops .iter() .find_map(|(old_u, new_u)| { let rest = uri.as_str().strip_prefix(old_u.as_str())?; diff --git a/src/rename/prepare.rs b/src/rename/prepare.rs index f083ef463..40c34ff1b 100644 --- a/src/rename/prepare.rs +++ b/src/rename/prepare.rs @@ -12,8 +12,12 @@ use tower_lsp::lsp_types::*; use crate::Backend; use crate::code_actions::multi_file_edit; +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::RenameOutcome; @@ -84,16 +88,19 @@ impl Backend { content: &str, position: Position, ) -> Option { - // A YAML/XML occurrence is not a rename site. Its text may be the - // escaped `App\\Handler` form the document's quoting requires - // rather than the PHP spelling, so nothing here can plan the edit - // that replaces it, and the PHP occurrences are reached from the - // declaration instead. + // A YAML/XML occurrence is a rename site only through the Symfony + // and Doctrine resource index, which edits a single segment of the + // name. Anything else there may be the escaped `App\\Handler` form + // the document's quoting requires rather than the PHP spelling, so + // nothing here can plan the edit that replaces it, and the PHP + // occurrences are reached from the declaration instead. if crate::resource_navigation::is_resource_document(uri) { - return None; + return self.handle_framework_prepare_rename(uri, content, position); } - 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 @@ -186,17 +193,21 @@ impl Backend { position: Position, new_name: &str, ) -> RenameOutcome { - // A YAML/XML occurrence is not a rename site. Its text may be the - // escaped `App\\Handler` form the document's quoting requires - // rather than the PHP spelling, so nothing here can plan the edit - // that replaces it, and the PHP occurrences are reached from the - // declaration instead. + // A YAML/XML occurrence is a rename site only through the Symfony + // and Doctrine resource index, which edits a single segment of the + // name. Anything else there may be the escaped `App\\Handler` form + // the document's quoting requires rather than the PHP spelling, so + // nothing here can plan the edit that replaces it, and the PHP + // occurrences are reached from the declaration instead. if crate::resource_navigation::is_resource_document(uri) { - return Ok(None); + return self.handle_framework_rename(uri, content, position, new_name); } + // A string a Symfony PHP configurator or container call gives + // meaning to (a service id, a `Class::method` controller) has no + // PHP symbol of its own. let Some(span) = self.lookup_symbol_at_position(uri, content, position) else { - return Ok(None); + return self.handle_framework_rename(uri, content, position, new_name); }; // Every edit below is derived, directly or through find-references, @@ -278,9 +289,19 @@ impl Backend { // location has to be checked against *that* file's text, not the // buffer this request arrived on. let magic_use = magic_rename.as_ref().map(|(old_use, _)| old_use.as_str()); + let (resource_locations, mut locations): (Vec, Vec) = + locations.into_iter().partition(|location| { + crate::resource_navigation::is_resource_document(location.uri.as_str()) + }); if !self.rename_locations_verified(&span.kind, magic_use, &locations) { return Ok(None); } + // A YAML/XML occurrence rides along with the PHP rename, so one the + // resource index no longer matches is left out on its own rather + // than cancelling every PHP edit with it. + locations.extend(resource_locations.into_iter().filter(|location| { + self.rename_locations_verified(&span.kind, None, std::slice::from_ref(location)) + })); // A function is named three different ways across its references, // and only one of them is the plain new name. The old short name @@ -425,6 +446,158 @@ impl Backend { Ok(Some(multi_file_edit(changes))) } + fn handle_framework_prepare_rename( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option { + let reference = self.framework_reference_at_position(uri, content, position)?; + let (start, end, placeholder) = match reference.kind { + FrameworkReferenceKind::Class { fqn } => { + let source = content.get(reference.start as usize..reference.end as usize)?; + let (start, end) = short_segment_range(source, reference.start); + (start, end, crate::util::short_name(&fqn).to_string()) + } + FrameworkReferenceKind::Method { member_name, .. } => { + (reference.start, reference.end, member_name) + } + FrameworkReferenceKind::Namespace { prefix } => { + let source = content.get(reference.start as usize..reference.end as usize)?; + let cursor = position_to_byte_offset(content, position) as u32; + let (segment_idx, start, end) = + namespace_segment_range_at_offset(source, reference.start, cursor)?; + let placeholder = prefix + .split('\\') + .nth(segment_idx) + .unwrap_or(prefix.as_str()) + .to_string(); + (start, end, placeholder) + } + FrameworkReferenceKind::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::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, + }) + } + + /// Rename the PHP symbol a Symfony or Doctrine resource names. + /// + /// A class or controller action is renamed from its PHP declaration, the + /// one go-to-definition lands on, so starting the rename in the resource + /// or in the PHP file produces the same edits: the PHP ones, and the + /// resource ones the framework index rewrites in each document's own + /// spelling. A namespace-prefix service key renames the namespace + /// segment under the cursor. + fn handle_framework_rename( + &self, + uri: &str, + content: &str, + position: Position, + new_name: &str, + ) -> RenameOutcome { + let Some(reference) = self.framework_reference_at_position(uri, content, position) else { + return Ok(None); + }; + + match reference.kind { + FrameworkReferenceKind::Class { .. } | FrameworkReferenceKind::Method { .. } => { + let Some((target_uri, target_content, target_position)) = + self.framework_rename_target(uri, content, position) + else { + return Ok(None); + }; + self.handle_rename(&target_uri, &target_content, target_position, new_name) + } + FrameworkReferenceKind::Namespace { prefix } => { + let cursor = position_to_byte_offset(content, position) as u32; + let Some((segment_idx, _start, _end)) = content + .get(reference.start as usize..reference.end as usize) + .and_then(|source| { + namespace_segment_range_at_offset(source, reference.start, cursor) + }) + else { + return Ok(None); + }; + self.build_namespace_rename_edit(&prefix, segment_idx, new_name) + } + FrameworkReferenceKind::SymfonySymbol { + kind: SymfonySymbolKind::Template, + .. + } => Ok(None), + FrameworkReferenceKind::SymfonySymbol { kind, name, .. } => { + let locations = self.framework_symfony_symbol_locations(kind, &name, true, true); + Ok(build_simple_rename_edit( + self, uri, content, &locations, new_name, true, + )) + } + FrameworkReferenceKind::RouteParameter { .. } => Ok(self + .find_framework_references_for_rename(uri, content, position) + .and_then(|locations| { + build_simple_rename_edit(self, uri, content, &locations, new_name, false) + })), + FrameworkReferenceKind::Translation { .. } => Ok(None), + FrameworkReferenceKind::MessengerHandler { .. } => Ok(None), + FrameworkReferenceKind::Path { .. } => Ok(None), + } + } + + /// The PHP declaration a resource occurrence names, resolved the way + /// go-to-definition resolves it, as the file, its text, and the position + /// of the declared name. + fn framework_rename_target( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option<(String, String, Position)> { + let target = self + .resolve_resource_definition(content, position) + .or_else(|| { + let mut definitions = self.resolve_definition(uri, content, position); + (definitions.len() == 1).then(|| definitions.remove(0)) + })?; + let target_uri = target.uri.to_string(); + if crate::resource_navigation::is_resource_document(&target_uri) { + return None; + } + let target_content = self.get_file_content(&target_uri)?; + + // A class's definition sits on its `class` keyword; the rename has + // to start on the name that follows it. + let offset = position_to_byte_offset(&target_content, target.range.start) as u32; + let symbol_map = self.symbol_maps.read().get(&target_uri).cloned()?; + let name_start = symbol_map + .spans + .iter() + .find(|span| span.start <= offset && offset < span.end) + .or_else(|| { + symbol_map.spans.iter().find(|span| { + span.start >= offset && matches!(span.kind, SymbolKind::ClassDeclaration { .. }) + }) + })? + .start; + let target_position = offset_to_position(&target_content, name_start as usize); + Some((target_uri, target_content, target_position)) + } + /// Extract the renameable symbol name and its source range. /// /// Returns `None` for symbols that cannot be renamed. @@ -546,6 +719,65 @@ fn is_config_resource_identity(kind: &SymbolKind) -> bool { } } +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, + }) + } +} + #[cfg(test)] mod config_resource_identity_tests { use super::*; diff --git a/src/server.rs b/src/server.rs index 1a0580723..cde77a23e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -237,7 +237,9 @@ impl LanguageServer for Backend { ) .flatten() }); - return Ok(location.map(GotoDefinitionResponse::Scalar)); + if let Some(location) = location { + return Ok(Some(GotoDefinitionResponse::Scalar(location))); + } } // A component tag is HTML, so it has no position in the virtual @@ -254,6 +256,26 @@ impl LanguageServer for Backend { { return Ok(Some(GotoDefinitionResponse::Scalar(location))); } + if let Some(locations) = backend.get_file_content(uri).and_then(|content| { + backend.symfony_event_definitions_at(uri, &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).and_then(|content| { + backend.symfony_expression_definitions_at(uri, &content, position) + }) { + return Ok(match locations.as_slice() { + [] => None, + [location] => Some(GotoDefinitionResponse::Scalar(location.clone())), + _ => Some(GotoDefinitionResponse::Array(locations)), + }); + } + // For Blade files, check if the cursor is on a `{{`/`}}` echo // delimiter first, so go-to-definition agrees with hover on the // same position (the implicit `e()` call) instead of falling @@ -485,6 +507,16 @@ impl LanguageServer for Backend { // Ahead of reading the file: a refresh that parses new files can // rewrite a template's virtual PHP (see `Backend::find_references`). backend.ensure_workspace_indexed_for_request(); + 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) 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..0f5a01d03 --- /dev/null +++ b/src/symfony/events.rs @@ -0,0 +1,1460 @@ +//! 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() + } +} + +/// A method carrying one configured attribute: what a publisher or +/// subscriber rule reads from it. +#[derive(Clone, Copy)] +struct AttributedMethod<'a> { + uri: &'a str, + content: &'a str, + arguments: &'a [PhpArgument<'a>], + owner_fqn: &'a str, + method: &'a str, + method_start: usize, + method_end: usize, +} + +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 = normalize_fqn(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 = normalize_fqn(owner); + let (sites, subscriptions) = self.symfony_event_snapshot(); + let mut events: Vec<(String, String, Option<&EventSite>)> = subscriptions + .iter() + .filter(|subscription| { + same_class(&normalize_fqn(&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( + &normalize_fqn(&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 = { + let config = self.workspace.config.lock(); + let events = &config.symfony.events; + if events.publishers.is_empty() && events.subscribers.is_empty() { + None + } else { + Some(events.clone()) + } + }; + let Some(event_config) = event_config else { + // Nothing to scan for: only sites a previous rule set recorded + // for this file need dropping, which most projects never have. + if self.symfony_events.read().sources.contains_key(uri) { + 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 = normalize_fqn(&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)); + let target = AttributedMethod { + uri, + content, + arguments: &arguments, + owner_fqn: &owner_fqn, + method: &method, + method_start, + method_end, + }; + + for rule in event_config + .publishers + .iter() + .filter(|rule| normalize_fqn(&rule.attribute).eq_ignore_ascii_case(&attribute_fqn)) + { + scan_publisher_attribute(&target, rule, &mut sites); + } + for rule in event_config + .subscribers + .iter() + .filter(|rule| normalize_fqn(&rule.attribute).eq_ignore_ascii_case(&attribute_fqn)) + { + scan_subscriber_attribute(&target, rule, &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 = normalize_fqn(&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(&normalize_fqn(&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( + &normalize_fqn(&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( + &normalize_fqn(&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( + &normalize_fqn(&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 = normalize_fqn(&owner); + subjects.extend( + subscriptions + .iter() + .filter(|subscription| { + same_class(&normalize_fqn(&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 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 +} + +fn scan_publisher_attribute( + target: &AttributedMethod<'_>, + rule: &SymfonyEventPublisherConfig, + sites: &mut Vec, +) { + let AttributedMethod { + uri, + content, + arguments, + owner_fqn, + method, + method_start, + method_end, + } = *target; + 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, + }); + } +} + +fn scan_subscriber_attribute( + target: &AttributedMethod<'_>, + rule: &SymfonyEventSubscriberConfig, + sites: &mut Vec, +) { + let AttributedMethod { + uri, + content, + arguments, + owner_fqn, + method, + method_start, + method_end, + } = *target; + 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( + &AttributedMethod { + uri: "file:///project/PublishCourse.php", + content, + arguments: &arguments, + owner_fqn: "App\\UseCase\\PublishCourse", + method: "execute", + method_start: 0, + method_end: "execute".len(), + }, + &example_publisher_rule(), + &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( + &AttributedMethod { + uri: "file:///project/PublishCourse.php", + content, + arguments: &arguments, + owner_fqn: "App\\UseCase\\PublishCourse", + method: "execute", + method_start: 0, + method_end: "execute".len(), + }, + &example_publisher_rule(), + &mut sites, + ); + + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].event, "course.failed"); + assert_eq!(sites[0].event_start, Some(7)); + } + + #[test] + fn 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(); + let publisher_uri = "file:///job.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.class_member_declaration_location(&class.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 + .class_declaration_location(&class_fqn) + .into_iter() + .collect(); + let classes = class_loader(&class_fqn).into_iter().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/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index ab5b9868d..80cc1d9f8 100644 --- a/tests/integration/code_lens.rs +++ b/tests/integration/code_lens.rs @@ -1,4 +1,4 @@ -use crate::common::{create_psr4_workspace, create_test_backend, open_php}; +use crate::common::{create_psr4_workspace, create_test_backend, open_document, open_php}; use tower_lsp::LanguageServer; use tower_lsp::lsp_types::*; @@ -827,6 +827,11 @@ class User extends Model {} Some("1 reference") ); } +fn uri_for(dir: &tempfile::TempDir, rel: &str) -> Url { + Url::from_file_path(dir.path().join(rel)).unwrap() +} + +const COMPOSER: &str = r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#; // ─── Basic Override Detection ─────────────────────────────────────────────── @@ -2133,3 +2138,275 @@ function list_author(\App\Author $author): void { Some("2 references") ); } + +// ─── 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_document(&backend, &entity_uri, "php", entity_php).await; + open_document(&backend, &repo_uri, "php", repo_php).await; + open_document( + &backend, + &uri_for(&dir, "config/doctrine/User.orm.yaml"), + "yaml", + doctrine_yaml, + ) + .await; + open_document( + &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_document( + &backend, + &uri_for(&dir, "src/Entity/User.php"), + "php", + entity_php, + ) + .await; + open_document( + &backend, + &uri_for(&dir, "src/Storage/SpecialUserStore.php"), + "php", + repo_php, + ) + .await; + open_document(&backend, &service_uri, "php", service_php).await; + open_document( + &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#">(), vec![&php_uri]); assert_eq!(changes[&php_uri].len(), 2); + assert!(!changes.contains_key(&dist_uri), "{changes:?}"); + + let mut yaml_edits: Vec<(u32, &str)> = changes[&yaml_uri] + .iter() + .map(|edit| (edit.range.start.line, edit.new_text.as_str())) + .collect(); + yaml_edits.sort(); + assert_eq!( + yaml_edits, + vec![(0, "App\\Gadget"), (1, "App\\\\Gadget")], + "each occurrence keeps its own spelling" + ); } +/// Renaming a class from a resource file renames the PHP class it names, +/// with the same edits a rename started on the declaration produces. #[tokio::test] -async fn rename_is_refused_from_inside_a_resource_file() { +async fn rename_from_inside_a_resource_file_renames_the_php_class() { let backend = Backend::new_test(); let php_uri = Url::parse("file:///test.php").unwrap(); let php = "addListener( + 'use_case.post.publish_course.async', + [#[\Closure(name: 'App\\Listener\\CourseListener')] fn () => ($container->privates['App\\Listener\\CourseListener'] ?? null), 'onPublished'], + 0, +); +$factory->createProxy(new \App\UseCase\PublishCourse()); +"#; + +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 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, + ) +} + +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/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:?}" + ); + // `Mailer::class` is both a PHP class reference and a framework one; + // it must be edited once, not by both renames. + let mut ranges: Vec = edit.changes.as_ref().unwrap()[&config_uri] + .iter() + .map(|edit| edit.range) + .collect(); + ranges.sort_by_key(|range| (range.start.line, range.start.character)); + assert!( + ranges.windows(2).all(|pair| pair[0].end <= pair[1].start), + "config edits must not overlap: {ranges:?}" + ); +} + +#[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 renaming_a_controller_action_from_php_updates_the_route() { + 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") + })); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 35889e31d..6f18852ae 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -127,6 +127,8 @@ 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; @@ -172,6 +174,7 @@ mod fix_cli; mod folding_blade; mod folding_ranges; mod formatting_blade; +mod framework_resources; mod hover; mod implementation; mod inlay_hints; diff --git a/tests/integration/references.rs b/tests/integration/references.rs index 9d5154cad..a1f8bd71c 100644 --- a/tests/integration/references.rs +++ b/tests/integration/references.rs @@ -4068,10 +4068,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_php(&backend, &uri, text).await; @@ -4090,8 +4091,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!( @@ -4115,12 +4121,56 @@ 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 ); } +/// A `getRepository()` assignment only types the variable inside the +/// function it is written in: a same-named parameter of another method keeps +/// its declared type. +#[tokio::test] +async fn test_repository_assignment_does_not_leak_into_another_method() { + let backend = create_test_backend(); + let uri = Url::parse("file:///test.php").unwrap(); + let text = concat!( + "getRepository(User::class);\n", // L10 + " $repo->find(1);\n", // L11 + " }\n", // L12 + " public function b(OrderRepository $repo): void {\n", // L13 + " $repo->find(2);\n", // L14 + " if ($repo == null) {}\n", // L15 + " }\n", // L16 + "}\n", // L17 + ); + open_php(&backend, &uri, text).await; + + let lines: Vec = references_at(&backend, &uri, 3, 21, false) + .await + .iter() + .map(|l| l.range.start.line) + .collect(); + assert!( + lines.contains(&11), + "the assignment in a() types $repo there: {lines:?}" + ); + assert!( + !lines.contains(&14), + "b()'s $repo is an OrderRepository parameter: {lines:?}" + ); +} + #[tokio::test] async fn test_concrete_method_references_include_interface_typed_calls() { let backend = create_test_backend();