diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f9a7e6687..671a4ff57 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,10 @@ 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. +- **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 +67,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 +437,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/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..fb1aad152 100644 --- a/src/backend/startup.rs +++ b/src/backend/startup.rs @@ -413,6 +413,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/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..c916d832f 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(); @@ -83,6 +89,7 @@ impl Backend { let mut lenses = Vec::new(); 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,473 @@ 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() { + let FrameworkReferenceKind::SymfonySymbol { + kind, + name, + declaration: true, + } = &declaration.kind + else { + continue; + }; + if !matches!( + kind, + SymfonySymbolKind::Service + | SymfonySymbolKind::Parameter + | SymfonySymbolKind::Route + | SymfonySymbolKind::Template + ) { + 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 + ) { + 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 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 route config: 1 ref".to_string() + } else { + format!("Symfony route 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 +1315,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 +1363,466 @@ impl Backend { }) } } + +#[derive(Clone, Copy)] +struct CodeLensDeclaration { + offset: usize, + position: Position, + kind: RouteDeclarationKind, +} + +#[derive(Clone, Copy)] +enum RouteDeclarationKind { + Class, + Method, +} + +struct RouteAttribute { + end: usize, + path: Option, + name: Option, + methods: Vec, +} + +struct GetRepositoryCall { + offset: usize, + first_arg: String, +} + +fn class_lens_position(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..9c7d55bab --- /dev/null +++ b/src/completion/symfony.rs @@ -0,0 +1,473 @@ +//! 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, +} + +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 { + 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, + }), + 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, + }); + } + + 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), + }); + } + 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 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 { + 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, + }) +} + +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, + }); + } + 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), + }); + } + 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) + { + 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(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, + }); + } + + 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, + }); + } + } + + 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, + }); + } + } + + 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, + }) +} + +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 = " 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::Namespace { .. } + | FrameworkReferenceKind::Path { .. } + | FrameworkReferenceKind::SymfonySymbol { + declaration: true, .. + } + | FrameworkReferenceKind::RouteParameter { + 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..d5fbb5abb 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -249,6 +249,7 @@ mod stale; pub(crate) mod state; mod subject_cache; pub(crate) mod suppression; +mod symfony; mod syntax_errors; mod type_errors; pub(crate) mod undefined_variables; @@ -300,6 +301,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 +405,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 @@ -571,6 +579,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..c682d65e7 --- /dev/null +++ b/src/diagnostics/symfony.rs @@ -0,0 +1,90 @@ +//! Conservative diagnostics for project-local Symfony named resources. + +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, + .. + } + ) + }); + 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)) + }; + + for reference in references.iter() { + 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), + }; + 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_{label}"))), + 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("../")) +} diff --git a/src/framework.rs b/src/framework.rs new file mode 100644 index 000000000..a17fda4ff --- /dev/null +++ b/src/framework.rs @@ -0,0 +1,3481 @@ +//! 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, +} + +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", + } + } +} + +#[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, + }, +} + +#[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, Default)] +struct FrameworkLookupUriKeys { + classes: HashSet, + methods: HashSet, +} + +/// Inverted locations for class and method references in framework resources. +/// +/// The primary framework index stays keyed by URI for cursor-local features. +/// This derived index makes cross-file lookups proportional to the matching +/// references instead of to every YAML/XML reference in the workspace. The +/// reverse URI map keeps watched-file updates proportional to one resource. +#[derive(Debug, Default)] +pub(crate) struct FrameworkReferenceLookupIndexInner { + classes: HashMap>, + methods: HashMap>, + uri_keys: HashMap, FrameworkLookupUriKeys>, +} + +pub(crate) type FrameworkReferenceLookupIndex = Arc>; + +pub(crate) fn new_framework_reference_index() -> FrameworkReferenceIndex { + Arc::new(RwLock::new(HashMap::new())) +} + +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(".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" | "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")) +} + +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) + || 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(")) +} + +/// 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()); + } + _ => {} + } + } + + if !keys.classes.is_empty() || !keys.methods.is_empty() { + lookup.uri_keys.insert(uri, keys); + } + } + + fn remove_framework_lookup_uri(lookup: &mut FrameworkReferenceLookupIndexInner, uri: &str) { + let Some(keys) = lookup.uri_keys.remove(uri) else { + return; + }; + + for key in keys.classes { + let remove_key = lookup.classes.get_mut(&key).is_some_and(|locations| { + locations.retain(|location| location.uri.as_ref() != uri); + locations.is_empty() + }); + if remove_key { + lookup.classes.remove(&key); + } + } + for key in keys.methods { + let remove_key = lookup.methods.get_mut(&key).is_some_and(|locations| { + locations.retain(|entry| entry.location.uri.as_ref() != uri); + locations.is_empty() + }); + if remove_key { + lookup.methods.remove(&key); + } + } + } + + /// Scan 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_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) + && !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_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, + _ => 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 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); + + 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 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)) + { + 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 { + return; + }; + + push_symfony_symbol( + refs, + uri, + kind, + semantic_value, + literal.start + leading, + literal.end - trailing, + declaration, + ); +} + +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 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); + 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 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); + } 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); + } + refs.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + refs.dedup(); + refs +} + +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_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 valid_symfony_symbol_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| is_symfony_symbol_char(byte) || byte == 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 php_call_context_handles_multibyte_search_boundary() { + let content = format!("─{} service('app.mailer')", "x".repeat(2037)); + let quote_start = content.find("'app.mailer").unwrap(); + let call = php_call_context(&content, quote_start).unwrap(); + + assert_eq!(call.name, "service"); + assert_eq!(call.argument_index, 0); + } + + #[test] + fn php_symfony_scanner_ignores_whitespace_only_literal() { + let content = " 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..13e31e878 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -65,6 +65,7 @@ impl Backend { let is_laravel = self.resolved_class_cache.read().is_laravel(); 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(); @@ -150,6 +151,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 +162,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; } @@ -175,6 +188,9 @@ impl Backend { 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 +201,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)); @@ -197,6 +221,7 @@ impl Backend { && !config_changed && !schema_full_rebuild && migration_changes.is_empty() + && framework_changes.is_empty() { return false; } @@ -278,6 +303,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 +434,7 @@ 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), ("**/composer.json".to_string(), WatchKind::Change), ("**/composer.lock".to_string(), WatchKind::Change), ("**/.phpantom.toml".to_string(), watch_all), diff --git a/src/lib.rs b/src/lib.rs index 3cc00c5cf..b68ddc016 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; @@ -619,6 +620,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. @@ -1231,6 +1246,9 @@ 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(), skip_reference_index: false, symbols: SymbolIndex::new(), @@ -2034,6 +2052,9 @@ 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), skip_reference_index: self.skip_reference_index, symbols: self.symbols.clone(), diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 5cec98440..369b9c47b 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()), 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..a98bde45a 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,68 @@ 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::Namespace { .. } | FrameworkReferenceKind::Path { .. } => { + Vec::new() + } + }; + Some(locations) + } + /// Dispatch a symbol-map hit to the appropriate reference finder. fn dispatch_symbol_references( &self, @@ -218,12 +296,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 +463,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..d7bfaad75 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,154 @@ 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::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::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 +715,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..7ca56e659 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 diff --git a/tests/integration/code_lens.rs b/tests/integration/code_lens.rs index ab5b9868d..335b60752 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 = " 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 route 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); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 35889e31d..68abd85dc 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -172,6 +172,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();