From 66630f0ec5a031164218bec47a9f3d3c683a45ca Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Wed, 11 Nov 2026 14:03:14 +0100 Subject: [PATCH 1/6] Read event subscriber maps built by assignment A subscriber's getSubscribedEvents() is read when it builds the map by assigning to the variable it returns ($events[KEY][] = ..., $events[KEY] = ..., $events = [...]), anywhere in the body, as well as when it returns a literal. Assisted-by: claude-code/claude-opus-5-5 --- src/symbol_map/extraction/symfony.rs | 244 ++++++++++++++++++++++----- tests/integration/symfony_events.rs | 49 ++++++ 2 files changed, 249 insertions(+), 44 deletions(-) diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index 50151f0b2..46352a93e 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -2304,21 +2304,87 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_listener_spans( .eq_ignore_ascii_case(b"getSubscribedEvents") && let MethodBody::Concrete(body) = &method.body { - for statement in body.statements.iter() { - if let Statement::Return(statement) = statement - && let Some(events) = statement.value + push_subscriber_map_spans(body, subject.as_deref(), ctx.content, &mut spans); + } + } + ctx.spans.extend(spans); +} + +/// Push the spans of the map a `getSubscribedEvents()` body builds: the +/// literal it returns, and the literals and entries it assigns to the +/// variable it returns (`$events[KEY][] = ['onEvent', 10];`), anywhere in +/// the body. +fn push_subscriber_map_spans( + body: &Block<'_>, + subject: Option<&str>, + content: &str, + spans: &mut Vec, +) { + let mut returned = Vec::new(); + let mut assignments = Vec::new(); + collect_subscriber_map_writes(Node::Block(body), &mut returned, &mut assignments); + for value in &returned { + push_subscribed_event_spans(value, subject, content, spans); + } + let returns = |variable: &Expression<'_>| { + let Expression::Variable(Variable::Direct(variable)) = variable else { + return false; + }; + returned.iter().any(|value| { + matches!(value, Expression::Variable(Variable::Direct(other)) if other.name == variable.name) + }) + }; + for assignment in assignments { + match assignment.lhs { + // `$events = [...]` + lhs if returns(lhs) => { + push_subscribed_event_spans(assignment.rhs, subject, content, spans); + } + // `$events[KEY] = ...` + Expression::ArrayAccess(access) if returns(access.array) => { + push_subscription_spans(access.index, assignment.rhs, subject, content, spans); + } + // `$events[KEY][] = ...`: one listener. + Expression::ArrayAppend(append) => { + if let Expression::ArrayAccess(access) = append.array + && returns(access.array) { - push_subscribed_event_spans( - events, - subject.as_deref(), - ctx.content, - &mut spans, - ); + push_event_span(access.index, content, spans); + if let Some(subject) = subject { + push_listener_span(subject, assignment.rhs, content, spans); + } } } + _ => {} } } - ctx.spans.extend(spans); +} + +/// Collect what a `getSubscribedEvents()` body returns and the plain +/// assignments it makes, leaving out the closures and classes it defines, +/// whose variables are their own. +fn collect_subscriber_map_writes<'a>( + node: Node<'a, 'a>, + returned: &mut Vec<&'a Expression<'a>>, + assignments: &mut Vec<&'a Assignment<'a>>, +) { + node.visit_children(|child| match child { + Node::Closure(_) + | Node::ArrowFunction(_) + | Node::AnonymousClass(_) + | Node::Function(_) + | Node::Class(_) => {} + Node::Return(statement) => { + returned.extend(statement.value); + } + Node::Assignment(assignment) => { + if assignment.operator.is_assign() { + assignments.push(assignment); + } + collect_subscriber_map_writes(child, returned, assignments); + } + other => collect_subscriber_map_writes(other, returned, assignments), + }); } /// Push the event name a listener subscribes to, when it is a string. @@ -2346,45 +2412,66 @@ fn push_subscribed_event_spans( return; }; for entry in entries.iter() { - let ArrayElement::KeyValue(entry) = entry else { - continue; - }; - push_event_span(entry.key, content, spans); - let Some(subject) = subject else { - continue; - }; - let Some(listeners) = crate::parser::array_literal_elements(entry.value) else { - push_own_method_span(subject, entry.value, content, spans); - continue; - }; - let mut values = listeners.iter().filter_map(|element| match element { - ArrayElement::Value(value) => Some(value.value), - _ => None, - }); - match values.next() { - // `['onEvent', 10]`: the method, then its priority. - Some(first @ Expression::Literal(Literal::String(_))) => { - push_own_method_span(subject, first, content, spans); - } - // `[['onEvent', 10], ['onOther']]`: one listener per entry. - Some(first) => { - for listener in std::iter::once(first).chain(values) { - if let Some(method) = crate::parser::array_literal_elements(listener) - .and_then(|listener| listener.iter().next()) - .and_then(|element| match element { - ArrayElement::Value(value) => Some(value.value), - _ => None, - }) - { - push_own_method_span(subject, method, content, spans); - } - } + if let ArrayElement::KeyValue(entry) = entry { + push_subscription_spans(entry.key, entry.value, subject, content, spans); + } + } +} + +/// Push the spans of one subscription: the event name its key gives, and +/// the method that listens to it, alone, with a priority, or as a list of +/// either. +fn push_subscription_spans( + event: &Expression<'_>, + listeners: &Expression<'_>, + subject: Option<&str>, + content: &str, + spans: &mut Vec, +) { + push_event_span(event, content, spans); + let Some(subject) = subject else { + return; + }; + let Some(elements) = crate::parser::array_literal_elements(listeners) else { + push_own_method_span(subject, listeners, content, spans); + return; + }; + let mut values = elements.iter().filter_map(|element| match element { + ArrayElement::Value(value) => Some(value.value), + _ => None, + }); + match values.next() { + // `['onEvent', 10]`: the method, then its priority. + Some(first @ Expression::Literal(Literal::String(_))) => { + push_own_method_span(subject, first, content, spans); + } + // `[['onEvent', 10], ['onOther']]`: one listener per entry. + Some(first) => { + for listener in std::iter::once(first).chain(values) { + push_listener_span(subject, listener, content, spans); } - None => {} } + None => {} } } +/// Push the method of one listener, `'onEvent'` or `['onEvent', 10]`. +fn push_listener_span( + subject: &str, + listener: &Expression<'_>, + content: &str, + spans: &mut Vec, +) { + let method = match crate::parser::array_literal_elements(listener) { + Some(elements) => match elements.iter().next() { + Some(ArrayElement::Value(value)) => value.value, + _ => return, + }, + None => listener, + }; + push_own_method_span(subject, method, content, spans); +} + /// Record the Messenger handlers a class's `#[AsMessageHandler]` /// attributes register, and emit the bus each one names and the method a /// class-level one names. @@ -3357,6 +3444,75 @@ mod tests { ); } + #[test] + fn a_subscriber_map_built_by_assignment_is_read() { + let php = r#" 'onRefunded']; + $map['acme.order.returned'] = ['onReturned', 1]; + return $map; + } +} +"#; + let map = map_of(php); + let events: Vec = keys_of(&map, crate::symbol_map::StringKeyKind::SymfonyEvent) + .into_iter() + .map(|(key, is_write)| { + assert!(is_write, "a subscription declares its event"); + key + }) + .collect(); + assert_eq!( + events, + vec![ + "acme.order.placed", + "acme.order.paid", + "acme.order.shipped", + "acme.order.refunded", + "acme.order.returned", + ] + ); + let named = |class: &str| -> Vec { + let subject = format!("\\Acme\\EventSubscriber\\{class}"); + methods(&map, php) + .into_iter() + .filter(|(on, _)| *on == subject) + .map(|(_, method)| method) + .collect() + }; + assert_eq!( + named("OrderSubscriber"), + vec![ + "onPlaced", + "onPaid", + "onShipped", + "onShippedLate", + "onOther", + ] + ); + assert_eq!(named("RefundSubscriber"), vec!["onRefunded", "onReturned"]); + } + #[test] fn message_handlers_are_recorded_with_their_messages_and_buses() { let php = " Date: Wed, 11 Nov 2026 14:39:24 +0100 Subject: [PATCH 2/6] Listener methods in Drupal event subscribers A Drupal site reads each subscriber's getSubscribedEvents() as a Symfony project does, so the listener methods its map names go to their definition, hover, take part in references and rename, and are reported when the class has no such method. Event names become a kind both frameworks read. #[AsEventListener] stays Symfony-only, in the symbol map and in completion, since Drupal never honours it. Assisted-by: claude-code/claude-opus-5-5 --- examples/drupal/README.md | 19 +- examples/drupal/assertions.php | 51 ++++ .../web/modules/custom/acme/acme.services.yml | 11 + .../EventSubscriber/GreetingSubscriber.php | 45 +++ .../src/EventSubscriber/RequestSubscriber.php | 29 ++ src/completion/symfony_string_keys.rs | 24 +- src/drupal/tests.rs | 10 +- src/symbol_map/extraction/symfony.rs | 53 ++-- src/symbol_map/string_keys.rs | 11 +- tests/integration/drupal_events.rs | 272 ++++++++++++++++++ tests/integration/main.rs | 1 + 11 files changed, 487 insertions(+), 39 deletions(-) create mode 100644 examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php create mode 100644 examples/drupal/web/modules/custom/acme/src/EventSubscriber/RequestSubscriber.php create mode 100644 tests/integration/drupal_events.rs diff --git a/examples/drupal/README.md b/examples/drupal/README.md index a419e89b5..821065b95 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -3,7 +3,8 @@ A standalone editor playground for PHPantom on Drupal 11. It is a `drupal/recommended-project` site with one custom module, `acme`, and one theme, `acme_theme`, declaring a service, a route, a content entity type, -a render element, a block plugin, hooks, a theme hook, permissions, and configuration the +a render element, a block plugin, hooks, a theme hook, permissions, event +subscribers, and configuration the way a real Drupal module declares them. Run `composer install` here first. Composer puts core and contrib into @@ -193,6 +194,14 @@ Run `composer install` here first. Composer puts core and contrib into `web/modules/custom/acme/src/Plugin/Block/FarewellBlock.php`, go to the definition of `@Block`, which lands on core's block annotation class, or of `@Translation`, which lands on core's `Translation` annotation. +- An event subscriber's `getSubscribedEvents()` names the methods that + listen to each event, whether it returns the map or builds it by + assignment. In + `web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php`, go + to the definition of `'onResponse'`, which lands on the method, or hover + it; rename `onResponse()` and the string follows. Misspell a method name + in the map and it is reported. In `RequestSubscriber.php`, which returns + its map, find the references of `onRequest()`: the string is one of them. ## What the Symfony support already reads @@ -212,10 +221,10 @@ project. What works today: What does not work yet: -- **Event subscribers.** The `getSubscribedEvents()` map of an event - subscriber is read only in a Symfony project, so in a Drupal site its - event names and listener methods neither complete nor navigate. See - `docs/todo/drupal.md`. +- **Event names keyed by a class constant.** A subscriber keyed by a + constant such as `KernelEvents::REQUEST` does not declare the event name + the constant holds, so the dispatcher calls that name it neither complete + nor navigate to the subscriber. See `docs/todo/drupal.md`. ## Runtime assertions diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 2bc978e56..9302eaa38 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -17,6 +17,8 @@ use Drupal\acme\Controller\NoteController; use Drupal\acme\Element\GreetingCard; use Drupal\acme\Entity\Note; +use Drupal\acme\EventSubscriber\GreetingSubscriber; +use Drupal\acme\EventSubscriber\RequestSubscriber; use Drupal\acme\Form\GreetingForm; use Drupal\acme\Form\SettingsForm; use Drupal\acme\Greeter; @@ -33,6 +35,7 @@ use Drupal\Core\Block\Attribute\Block; use Drupal\Core\Block\BlockBase; use Drupal\Core\Block\BlockManager; +use Drupal\Core\Config\ConfigEvents; use Drupal\Core\Field\Attribute\FieldType; use Drupal\Core\Field\Attribute\FieldWidget; use Drupal\Core\Field\Plugin\Field\FieldType\StringItem; @@ -72,6 +75,8 @@ use Drupal\Core\Utility\CallableResolver; use Drupal\language\ConfigurableLanguageManager; use Drupal\language\LanguageServiceProvider; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\KernelEvents; $passed = 0; $failed = 0; @@ -183,6 +188,52 @@ protected function getDefaultLanguageValues() check('RequestClock::secondsSinceRequest() returns an int', is_int((new RequestClock(new Time()))->secondsSinceRequest())); check('acme_request_age() looks up datetime.time', str_contains((string) file_get_contents("$module/acme.module"), "\\Drupal::service('datetime.time')")); +// ─── Event subscribers ────────────────────────────────────────────────────── + +/** + * The methods a subscriber's map names for each event: the value is one + * method, a method with its priority, or a list of either. + * + * @return array> + */ +function listenerMethods(array $events): array +{ + $methods = []; + foreach ($events as $event => $listeners) { + if (is_string($listeners)) { + $listeners = [[$listeners]]; + } elseif (is_string($listeners[0] ?? null)) { + $listeners = [$listeners]; + } + $methods[$event] = array_map(fn (array $listener) => $listener[0], $listeners); + } + return $methods; +} + +$subscribers = [ + 'acme.greeting_subscriber' => [GreetingSubscriber::class, [ + KernelEvents::RESPONSE => ['onResponse'], + ConfigEvents::SAVE => ['onConfigSave'], + ]], + 'acme.request_subscriber' => [RequestSubscriber::class, [ + KernelEvents::REQUEST => ['onRequest'], + ]], +]; +foreach ($subscribers as $id => [$class, $expected]) { + $definition = $container->getDefinition($id); + check("$id is built from " . (new ReflectionClass($class))->getShortName(), $definition->getClass() === $class); + check("$id is tagged event_subscriber, which Drupal registers listeners from", $definition->hasTag('event_subscriber')); + check("$class is an event subscriber", is_subclass_of($class, EventSubscriberInterface::class)); + $methods = listenerMethods($class::getSubscribedEvents()); + check("$class subscribes to the events its map names", $methods === $expected); + foreach (array_merge(...array_values($methods)) as $method) { + check("$class::$method(), which its map names, is a public method", method_exists($class, $method) && (new ReflectionMethod($class, $method))->isPublic()); + } +} +check('GreetingSubscriber builds its map by assignment', str_contains((string) file_get_contents("$module/src/EventSubscriber/GreetingSubscriber.php"), '$events[KernelEvents::RESPONSE][] = ')); +check('RequestSubscriber returns its map as a literal', str_contains((string) file_get_contents("$module/src/EventSubscriber/RequestSubscriber.php"), 'return [KernelEvents::REQUEST => ')); +check('A misspelt listener such as onRespons is no method of GreetingSubscriber', !method_exists(GreetingSubscriber::class, 'onRespons')); + // ─── Routes ───────────────────────────────────────────────────────────────── $routes = Yaml::decode((string) file_get_contents("$module/acme.routing.yml")); diff --git a/examples/drupal/web/modules/custom/acme/acme.services.yml b/examples/drupal/web/modules/custom/acme/acme.services.yml index 926840c43..e2fd31521 100644 --- a/examples/drupal/web/modules/custom/acme/acme.services.yml +++ b/examples/drupal/web/modules/custom/acme/acme.services.yml @@ -23,3 +23,14 @@ services: arguments: ['@.inner'] # Lets controllers and hook classes autowire the greeter by its class. Drupal\acme\Greeter: '@acme.greeter' + # Try: open src/EventSubscriber/GreetingSubscriber.php, which builds its + # map by assignment, or src/EventSubscriber/RequestSubscriber.php, which + # returns it. Each listener method its map names navigates. + acme.greeting_subscriber: + class: Drupal\acme\EventSubscriber\GreetingSubscriber + tags: + - { name: event_subscriber } + acme.request_subscriber: + class: Drupal\acme\EventSubscriber\RequestSubscriber + tags: + - { name: event_subscriber } diff --git a/examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php b/examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php new file mode 100644 index 000000000..dadfa4b37 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php @@ -0,0 +1,45 @@ +getResponse()->headers->set('X-Acme-Greeting', 'hello'); + } + + public function onConfigSave(ConfigCrudEvent $event): void + { + if ($event->getConfig()->getName() === 'acme.settings') { + $this->greetingChanged = true; + } + } +} diff --git a/examples/drupal/web/modules/custom/acme/src/EventSubscriber/RequestSubscriber.php b/examples/drupal/web/modules/custom/acme/src/EventSubscriber/RequestSubscriber.php new file mode 100644 index 000000000..f6f3ec5bf --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/EventSubscriber/RequestSubscriber.php @@ -0,0 +1,29 @@ + ['onRequest', 50]]; + } + + public function onRequest(RequestEvent $event): void + { + $event->getRequest()->attributes->set('_acme_greeted', true); + } +} diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index 123980350..39b76a3d8 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -93,7 +93,16 @@ impl Backend { let (kind, prefix_start) = self .symfony_call_key(content, cursor, code, ctx, &mut receiver_type) .map(|kind| (kind, literal_start)) - .or_else(|| attribute_key(content, code, literal, literal_start, ctx)) + .or_else(|| { + attribute_key( + content, + code, + literal, + literal_start, + ctx, + self.is_symfony_project(), + ) + }) .filter(|(kind, _)| self.string_key_kind_enabled(*kind))?; // A config object's `get()`: its keys come from the name its type, // resolved once above, carries. @@ -943,7 +952,8 @@ pub(crate) enum OfferedMethods { /// `#[AsMessageHandler]` string names, and where the name in it starts: /// `service:` a service, `param:` a parameter, and its value an `@id` /// service reference or a `%name%` placeholder; a `#[Template]`'s -/// `template:` a template; an `#[AsEventListener]`'s `event:` an event; an +/// `template:` a template; an `#[AsEventListener]`'s `event:` an event, +/// in Symfony only, since Drupal never honours the attribute; an /// `#[AsMessageHandler]`'s `bus:` a bus. fn attribute_key( content: &str, @@ -951,6 +961,7 @@ fn attribute_key( literal: &str, literal_start: usize, ctx: &FileContext, + is_symfony: bool, ) -> Option<(StringKeyKind, usize)> { let (callable, slot) = scalar_argument_at(content, code)?; let name_start = callable.len() - trailing_name(callable).len(); @@ -965,10 +976,11 @@ fn attribute_key( .then_some((StringKeyKind::TwigTemplate, literal_start)); } if is_event_listener_attribute(&attribute) { - return matches!( - slot, - ArgumentSlot::Named("event") | ArgumentSlot::Positional(0) - ) + return (is_symfony + && matches!( + slot, + ArgumentSlot::Named("event") | ArgumentSlot::Positional(0) + )) .then_some((StringKeyKind::SymfonyEvent, literal_start)); } if crate::yii::routes::is_route_argument_attribute(&attribute) { diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index dcd7a3e6d..0cabfb1c7 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -229,12 +229,16 @@ fn other_yaml_is_not_read_as_services() { #[test] fn drupal_reads_the_service_container_and_nothing_else_from_symfony() { let (backend, _dir) = drupal_backend(&[]); - assert!(backend.string_key_kind_enabled(StringKeyKind::ServiceId)); - assert!(backend.string_key_kind_enabled(StringKeyKind::ContainerParameter)); + for kind in [ + StringKeyKind::ServiceId, + StringKeyKind::ContainerParameter, + StringKeyKind::SymfonyEvent, + ] { + assert!(backend.string_key_kind_enabled(kind), "{kind:?}"); + } for kind in [ StringKeyKind::SymfonyRoute, StringKeyKind::SymfonyTranslation, - StringKeyKind::SymfonyEvent, StringKeyKind::MessengerBus, StringKeyKind::SymfonyConfigKey, ] { diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index 46352a93e..7da17e265 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -234,8 +234,8 @@ pub(in crate::symbol_map::extraction) fn record_symfony_receiver_sites( }; // Outside Symfony, only the calls of the framework the project uses // are left: Twig's own `render()`, Yii's URL generator, or a Drupal - // container lookup. Only Symfony declares services and parameters - // in PHP; in Drupal a `setParameter()` is far more often a + // container lookup. Only Symfony declares services, parameters and + // events in PHP; in Drupal a `setParameter()` is far more often a // validator's or a query's than the container's. let frameworks = ReceiverFrameworks { laravel: false, @@ -2259,29 +2259,39 @@ fn push_own_method_span( /// they name: an `#[AsEventListener]` on the class or one of its methods, /// and the event-to-method map a subscriber's `getSubscribedEvents()` /// returns. Each subscription declares the event name. +/// +/// Drupal reads subscribers too, but never honours the attribute. pub(in crate::symbol_map::extraction) fn emit_symfony_listener_spans( class: &Class<'_>, ctx: &mut ExtractionCtx<'_>, ) { - if !ctx.symfony { + if !ctx.service_container { return; } // Rooted, so the class is not read again against the file's namespace - // and imports. - let subject = ctx - .resolved_name_at(class.name.span.start.offset) - .map(|fqn| format!("\\{}", fqn.trim_start_matches('\\'))); + // and imports. Built once, and only for a class that names a listener. + let subject_name = std::cell::OnceCell::new(); + let subject = || { + subject_name + .get_or_init(|| { + ctx.resolved_name_at(class.name.span.start.offset) + .map(|fqn| format!("\\{}", fqn.trim_start_matches('\\'))) + }) + .clone() + }; let mut spans = Vec::new(); - for attribute in attributes_named(&class.attribute_lists, AS_EVENT_LISTENER, ctx) { - for (parameter, value) in attribute_arguments(attribute, EVENT_LISTENER_PARAMETERS) { - match parameter { - "event" => push_event_span(value, ctx.content, &mut spans), - "method" => { - if let Some(subject) = &subject { - push_own_method_span(subject, value, ctx.content, &mut spans); + if ctx.symfony { + for attribute in attributes_named(&class.attribute_lists, AS_EVENT_LISTENER, ctx) { + for (parameter, value) in attribute_arguments(attribute, EVENT_LISTENER_PARAMETERS) { + match parameter { + "event" => push_event_span(value, ctx.content, &mut spans), + "method" => { + if let Some(subject) = subject() { + push_own_method_span(&subject, value, ctx.content, &mut spans); + } } + _ => {} } - _ => {} } } } @@ -2290,10 +2300,13 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_listener_spans( continue; }; // On a method, the method is the listener. - for attribute in attributes_named(&method.attribute_lists, AS_EVENT_LISTENER, ctx) { - for (parameter, value) in attribute_arguments(attribute, EVENT_LISTENER_PARAMETERS) { - if parameter == "event" { - push_event_span(value, ctx.content, &mut spans); + if ctx.symfony { + for attribute in attributes_named(&method.attribute_lists, AS_EVENT_LISTENER, ctx) { + for (parameter, value) in attribute_arguments(attribute, EVENT_LISTENER_PARAMETERS) + { + if parameter == "event" { + push_event_span(value, ctx.content, &mut spans); + } } } } @@ -2304,7 +2317,7 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_listener_spans( .eq_ignore_ascii_case(b"getSubscribedEvents") && let MethodBody::Concrete(body) = &method.body { - push_subscriber_map_spans(body, subject.as_deref(), ctx.content, &mut spans); + push_subscriber_map_spans(body, subject().as_deref(), ctx.content, &mut spans); } } ctx.spans.extend(spans); diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index 3a8105c0a..8335c2ff1 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -329,12 +329,13 @@ impl StringKeyKind { | Self::MixAsset | Self::ViteAsset | Self::InertiaPage => KeyFramework::Laravel, - Self::ServiceId | Self::ContainerParameter => KeyFramework::ServiceContainer, + Self::ServiceId | Self::ContainerParameter | Self::SymfonyEvent => { + KeyFramework::ServiceContainer + } Self::SymfonyRoute | Self::SymfonyTranslation | Self::TranslationDomain | Self::SymfonyConfigKey - | Self::SymfonyEvent | Self::MessengerBus | Self::SerializerGroup => KeyFramework::Symfony, Self::TwigTemplate @@ -467,11 +468,11 @@ pub(crate) enum KeyFramework { Laravel, /// Tempest's explicitly qualified view helper, including standalone use. Tempest, - /// Symfony's service IDs and container parameters, which any project - /// that builds a Symfony service container declares. + /// Symfony's service IDs, container parameters, and event names, which + /// any project that builds a Symfony service container declares. ServiceContainer, /// The rest of Symfony: routes, translations, bundle configuration, - /// events, and Messenger. + /// and Messenger. Symfony, /// Twig's template, block, filter, function, and variable names, /// read in any project that uses Twig. diff --git a/tests/integration/drupal_events.rs b/tests/integration/drupal_events.rs new file mode 100644 index 000000000..9324fbdbb --- /dev/null +++ b/tests/integration/drupal_events.rs @@ -0,0 +1,272 @@ +//! Drupal's event subscribers: the listener methods a subscriber's +//! `getSubscribedEvents()` names, whether it returns the map or builds it +//! by assignment. + +use crate::common::{hover_text_at, message_text, rename}; +use crate::drupal_services::{definitions_at, position_in, references_at, uri_for, workspace_in}; +use tower_lsp_server::gen_lsp_types::*; + +/// Just enough of Symfony's event dispatcher, where Composer would put it +/// in a Drupal site. +const STUBS: &[(&str, &str)] = &[ + ( + "vendor/symfony/event-dispatcher/EventSubscriberInterface.php", + " 'audit']; + } + + public function audit(object $event): void {} +} +"#; + +const ORDER_PATH: &str = "web/modules/custom/acme/src/EventSubscriber/OrderSubscriber.php"; +const AUDIT_PATH: &str = "web/modules/custom/acme/src/EventSubscriber/AuditSubscriber.php"; + +/// A Drupal site holding the dispatcher stubs and `files`, all open. +async fn site(files: &[(&str, &str)]) -> (phpantom_lsp::Backend, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in STUBS { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + workspace_in(dir, files).await +} + +/// The line `needle` first appears on. +fn line_of(content: &str, needle: &str) -> u32 { + position_in(content, needle, 0).line +} + +/// The messages of the unknown-member warnings in `content`. +fn unknown_members(backend: &phpantom_lsp::Backend, uri: &Uri, content: &str) -> Vec { + let mut diagnostics = Vec::new(); + backend.collect_unknown_member_diagnostics(uri.as_str(), content, &mut diagnostics); + diagnostics + .iter() + .map(|diagnostic| message_text(&diagnostic.message).to_string()) + .collect() +} + +#[tokio::test] +async fn a_listener_method_goes_to_its_definition_in_both_map_shapes() { + let (backend, dir) = site(&[ + (ORDER_PATH, ORDER_SUBSCRIBER), + (AUDIT_PATH, AUDIT_SUBSCRIBER), + ]) + .await; + let order = uri_for(&dir, ORDER_PATH); + let audit = uri_for(&dir, AUDIT_PATH); + + for (uri, content, needle, method) in [ + (&order, ORDER_SUBSCRIBER, "'onPlaced'", "function onPlaced"), + (&order, ORDER_SUBSCRIBER, "'onPaid'", "function onPaid"), + (&audit, AUDIT_SUBSCRIBER, "'audit'", "function audit"), + ] { + let found = definitions_at(&backend, uri, position_in(content, needle, 2)).await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!(&found[0].uri, uri, "{needle}"); + assert_eq!( + found[0].range.start.line, + line_of(content, method), + "{needle}" + ); + } +} + +#[tokio::test] +async fn a_listener_method_hovers_and_is_referenced_from_the_map() { + let (backend, dir) = site(&[(ORDER_PATH, ORDER_SUBSCRIBER)]).await; + let order = uri_for(&dir, ORDER_PATH); + + let position = position_in(ORDER_SUBSCRIBER, "'onPlaced'", 2); + let hover = hover_text_at(&backend, &order, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("onPlaced"), "{hover}"); + + let found = references_at( + &backend, + &order, + position_in(ORDER_SUBSCRIBER, "function onPlaced", 10), + ) + .await; + assert_eq!( + found, + vec![ + (order.clone(), line_of(ORDER_SUBSCRIBER, "'onPlaced'")), + ( + order.clone(), + line_of(ORDER_SUBSCRIBER, "function onPlaced") + ), + ] + ); +} + +#[tokio::test] +async fn renaming_a_listener_method_rewrites_the_map() { + let (backend, dir) = site(&[ + (ORDER_PATH, ORDER_SUBSCRIBER), + (AUDIT_PATH, AUDIT_SUBSCRIBER), + ]) + .await; + let order = uri_for(&dir, ORDER_PATH); + let audit = uri_for(&dir, AUDIT_PATH); + + for (uri, content, needle, method) in [ + (&order, ORDER_SUBSCRIBER, "function onPlaced", "'onPlaced'"), + (&audit, AUDIT_SUBSCRIBER, "function audit", "'audit'"), + ] { + let position = position_in(content, needle, 10); + let edit = rename(&backend, uri, position.line, position.character, "renamed") + .await + .expect("the rename should produce edits"); + let changes = edit.changes.expect("a change map"); + let mut lines: Vec = changes + .get(uri) + .into_iter() + .flatten() + .inspect(|edit| assert_eq!(edit.new_text, "renamed")) + .map(|edit| edit.range.start.line) + .collect(); + lines.sort_unstable(); + assert_eq!( + lines, + vec![line_of(content, method), line_of(content, needle)], + "{needle}" + ); + } +} + +#[tokio::test] +async fn a_missing_listener_method_is_reported_and_an_inherited_one_is_not() { + let missing = ORDER_SUBSCRIBER.replace("['onPaid']", "['onRefunded']"); + let inherited = r#" Date: Wed, 11 Nov 2026 15:23:23 +0100 Subject: [PATCH 3/6] Declare the event a subscriber keys by a class constant A subscription keyed by a class constant (KernelEvents::REQUEST, self::X, static::X) declares the constant's value when it is a plain string literal, read with what the class inherits from its parents and interfaces. A subscriber keyed only by constants now counts as declaring event names, so dispatcher calls complete, navigate to and count its subscriptions. An X::class key and a constant built any other way declare nothing. Assisted-by: claude-code/claude-opus-5-5 --- src/hover/laravel.rs | 4 +- src/mem_audit.rs | 5 + src/symbol_map/extraction/mod.rs | 8 + src/symbol_map/extraction/symfony.rs | 350 +++++++++++++-------- src/symbol_map/map.rs | 17 + src/symbol_map/string_keys.rs | 17 + src/symfony/mod.rs | 62 ++++ src/virtual_members/laravel/string_keys.rs | 16 +- tests/integration/symfony_events.rs | 340 ++++++++++++++++++++ 9 files changed, 688 insertions(+), 131 deletions(-) diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index 516fc87ab..0b6e6c4c6 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -360,10 +360,8 @@ impl Backend { } (label, detail) } - // Every listener declares the event it subscribes to, so the - // declarations are its listeners. StringKeyKind::SymfonyEvent => { - let listeners = self.symfony_declaration_locations(*kind, key).len(); + let listeners = self.symfony_event_listener_count(key); let detail = match listeners { 0 => "No listener in the project subscribes to it".to_string(), 1 => "1 listener in the project".to_string(), diff --git a/src/mem_audit.rs b/src/mem_audit.rs index f2ecb408c..b3769a537 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1395,6 +1395,11 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { sm.messenger_handlers.capacity() * size_of::(), ); + sym.add( + sm.event_constant_keys.capacity() + * size_of::(), + ); + sym.add(sm.event_listener_lists.capacity() * size_of::<(u32, u32)>()); sym.add( sm.twig_callables.capacity() * size_of::(), ); diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 67d43222f..8bcde6e80 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -101,6 +101,10 @@ struct ExtractionCtx<'a> { /// The Messenger handlers the file's `#[AsMessageHandler]` attributes /// register. messenger_handlers: Vec, + /// The event subscriptions keyed by a class constant. + event_constant_keys: Vec, + /// The event subscriptions that list other than one listener. + event_listener_lists: Vec<(u32, u32)>, /// The Twig filters and functions the file declares. twig_callables: Vec, /// The variables the file's renders hand Twig templates. @@ -626,6 +630,8 @@ fn extract_symbol_map_inner( morph_column_sites: Vec::new(), gate_subjects: Vec::new(), messenger_handlers: Vec::new(), + event_constant_keys: Vec::new(), + event_listener_lists: Vec::new(), twig_callables: Vec::new(), twig_contexts: Vec::new(), container_values: Vec::new(), @@ -874,6 +880,8 @@ fn extract_symbol_map_inner( morph_column_sites: ctx.morph_column_sites, gate_subjects: ctx.gate_subjects, messenger_handlers: ctx.messenger_handlers, + event_constant_keys: ctx.event_constant_keys, + event_listener_lists: ctx.event_listener_lists, twig_callables: ctx.twig_callables, twig_contexts: ctx.twig_contexts, container_values: ctx.container_values, diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index 7da17e265..7f0a5643f 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -2280,6 +2280,8 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_listener_spans( .clone() }; let mut spans = Vec::new(); + let mut constant_keys = Vec::new(); + let mut listener_lists = Vec::new(); if ctx.symfony { for attribute in attributes_named(&class.attribute_lists, AS_EVENT_LISTENER, ctx) { for (parameter, value) in attribute_arguments(attribute, EVENT_LISTENER_PARAMETERS) { @@ -2317,60 +2319,194 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_listener_spans( .eq_ignore_ascii_case(b"getSubscribedEvents") && let MethodBody::Concrete(body) = &method.body { - push_subscriber_map_spans(body, subject().as_deref(), ctx.content, &mut spans); + let mut map = SubscriberMap { + ctx: &*ctx, + subject: subject(), + spans: &mut spans, + constant_keys: &mut constant_keys, + listener_lists: &mut listener_lists, + }; + map.read_body(body); } } ctx.spans.extend(spans); + ctx.event_constant_keys.extend(constant_keys); + ctx.event_listener_lists.extend(listener_lists); } -/// Push the spans of the map a `getSubscribedEvents()` body builds: the -/// literal it returns, and the literals and entries it assigns to the -/// variable it returns (`$events[KEY][] = ['onEvent', 10];`), anywhere in -/// the body. -fn push_subscriber_map_spans( - body: &Block<'_>, - subject: Option<&str>, - content: &str, - spans: &mut Vec, -) { - let mut returned = Vec::new(); - let mut assignments = Vec::new(); - collect_subscriber_map_writes(Node::Block(body), &mut returned, &mut assignments); - for value in &returned { - push_subscribed_event_spans(value, subject, content, spans); - } - let returns = |variable: &Expression<'_>| { - let Expression::Variable(Variable::Direct(variable)) = variable else { - return false; +/// Push the event name a listener subscribes to, when it is a string. +fn push_event_span(event: &Expression<'_>, content: &str, spans: &mut Vec) { + push_laravel_string_span( + StringKeyKind::SymfonyEvent, + true, + false, + event, + content, + spans, + ); +} + +/// The reader of a subscriber's `getSubscribedEvents()`: each key an event +/// name, each value the method that listens to it, alone, with a priority +/// (`['onEvent', 10]`), or as a list of either. +struct SubscriberMap<'r, 'c> { + ctx: &'r ExtractionCtx<'c>, + /// The subscriber, rooted, so the class is not read again against the + /// file's namespace and imports. + subject: Option, + spans: &'r mut Vec, + constant_keys: &'r mut Vec, + /// Where each subscription that lists other than one listener starts, + /// with how many it lists. + listener_lists: &'r mut Vec<(u32, u32)>, +} + +impl SubscriberMap<'_, '_> { + /// Read the map the body builds: the literal it returns, and the + /// literals and entries it assigns to the variable it returns + /// (`$events[KEY][] = ['onEvent', 10];`), anywhere in the body. + fn read_body(&mut self, body: &Block<'_>) { + let mut returned = Vec::new(); + let mut assignments = Vec::new(); + collect_subscriber_map_writes(Node::Block(body), &mut returned, &mut assignments); + for value in &returned { + self.read_literal(value); + } + let returns = |variable: &Expression<'_>| { + let Expression::Variable(Variable::Direct(variable)) = variable else { + return false; + }; + returned.iter().any(|value| { + matches!(value, Expression::Variable(Variable::Direct(other)) if other.name == variable.name) + }) }; - returned.iter().any(|value| { - matches!(value, Expression::Variable(Variable::Direct(other)) if other.name == variable.name) - }) - }; - for assignment in assignments { - match assignment.lhs { - // `$events = [...]` - lhs if returns(lhs) => { - push_subscribed_event_spans(assignment.rhs, subject, content, spans); + for assignment in assignments { + match assignment.lhs { + // `$events = [...]` + lhs if returns(lhs) => self.read_literal(assignment.rhs), + // `$events[KEY] = ...` + Expression::ArrayAccess(access) if returns(access.array) => { + self.read_subscription(access.index, assignment.rhs); + } + // `$events[KEY][] = ...`: one listener. + Expression::ArrayAppend(append) => { + if let Expression::ArrayAccess(access) = append.array + && returns(access.array) + { + self.read_event(access.index); + self.read_listener(assignment.rhs); + } + } + _ => {} } - // `$events[KEY] = ...` - Expression::ArrayAccess(access) if returns(access.array) => { - push_subscription_spans(access.index, assignment.rhs, subject, content, spans); + } + } + + /// Read a map written as a literal. + fn read_literal(&mut self, events: &Expression<'_>) { + let Some(entries) = crate::parser::array_literal_elements(events) else { + return; + }; + for entry in entries.iter() { + if let ArrayElement::KeyValue(entry) = entry { + self.read_subscription(entry.key, entry.value); } - // `$events[KEY][] = ...`: one listener. - Expression::ArrayAppend(append) => { - if let Expression::ArrayAccess(access) = append.array - && returns(access.array) - { - push_event_span(access.index, content, spans); - if let Some(subject) = subject { - push_listener_span(subject, assignment.rhs, content, spans); - } + } + } + + /// Read one subscription: the event its key names, and the methods + /// that listen to it. + fn read_subscription(&mut self, event: &Expression<'_>, listeners: &Expression<'_>) { + let start = self.read_event(event); + let Some(elements) = crate::parser::array_literal_elements(listeners) else { + self.read_listener(listeners); + return; + }; + let mut values = elements.iter().filter_map(|element| match element { + ArrayElement::Value(value) => Some(value.value), + _ => None, + }); + let listed = match values.next() { + // `['onEvent', 10]`: the method, then its priority. + Some(first @ Expression::Literal(Literal::String(_))) => { + self.read_listener(first); + return; + } + // `[['onEvent', 10], ['onOther']]`: one listener per entry. + Some(first) => { + let mut listed = 0; + for listener in std::iter::once(first).chain(values) { + self.read_listener(listener); + listed += 1; } + listed } - _ => {} + None => 0, + }; + if listed != 1 + && let Some(start) = start + { + self.listener_lists.push((start, listed)); } } + + /// Read the event a key names: a string declares it outright, and a + /// class constant (`KernelEvents::REQUEST`, `self::X`) declares the + /// value the constant holds, once its class is loaded. `X::class` + /// names the event by its class, which no caller writes as a string. + /// Gives where the declaration starts. + fn read_event(&mut self, event: &Expression<'_>) -> Option { + let written = self.spans.len(); + push_event_span(event, self.ctx.content, self.spans); + if self.spans.len() > written { + return self.spans.last().map(|span| span.start); + } + let Expression::Access(Access::ClassConstant(access)) = event else { + return None; + }; + let ClassLikeConstantSelector::Identifier(constant) = &access.constant else { + return None; + }; + if constant.value.eq_ignore_ascii_case(b"class") { + return None; + } + let class = match access.class { + Expression::Self_(_) | Expression::Static(_) => self + .subject + .as_deref() + .map(|subject| subject.trim_start_matches('\\')), + Expression::Identifier(identifier) => self + .ctx + .resolved_name_at(identifier.span().start.offset) + .map(|fqn| fqn.trim_start_matches('\\')), + _ => None, + }; + let class = class?; + let span = access.span(); + self.constant_keys + .push(crate::symbol_map::EventConstantKey { + start: span.start.offset, + end: span.end.offset, + class: crate::atom::atom(class), + constant: crate::atom::atom_bytes(constant.value), + }); + Some(span.start.offset) + } + + /// Read the method of one listener, `'onEvent'` or `['onEvent', 10]`. + fn read_listener(&mut self, listener: &Expression<'_>) { + let Some(subject) = &self.subject else { + return; + }; + let method = match crate::parser::array_literal_elements(listener) { + Some(elements) => match elements.iter().next() { + Some(ArrayElement::Value(value)) => value.value, + _ => return, + }, + None => listener, + }; + push_own_method_span(subject, method, self.ctx.content, self.spans); + } } /// Collect what a `getSubscribedEvents()` body returns and the plain @@ -2400,91 +2536,6 @@ fn collect_subscriber_map_writes<'a>( }); } -/// Push the event name a listener subscribes to, when it is a string. -fn push_event_span(event: &Expression<'_>, content: &str, spans: &mut Vec) { - push_laravel_string_span( - StringKeyKind::SymfonyEvent, - true, - false, - event, - content, - spans, - ); -} - -/// Push the spans of the map a `getSubscribedEvents()` returns: each key -/// an event name, each value the method that listens to it, alone, with a -/// priority (`['onEvent', 10]`), or as a list of either. -fn push_subscribed_event_spans( - events: &Expression<'_>, - subject: Option<&str>, - content: &str, - spans: &mut Vec, -) { - let Some(entries) = crate::parser::array_literal_elements(events) else { - return; - }; - for entry in entries.iter() { - if let ArrayElement::KeyValue(entry) = entry { - push_subscription_spans(entry.key, entry.value, subject, content, spans); - } - } -} - -/// Push the spans of one subscription: the event name its key gives, and -/// the method that listens to it, alone, with a priority, or as a list of -/// either. -fn push_subscription_spans( - event: &Expression<'_>, - listeners: &Expression<'_>, - subject: Option<&str>, - content: &str, - spans: &mut Vec, -) { - push_event_span(event, content, spans); - let Some(subject) = subject else { - return; - }; - let Some(elements) = crate::parser::array_literal_elements(listeners) else { - push_own_method_span(subject, listeners, content, spans); - return; - }; - let mut values = elements.iter().filter_map(|element| match element { - ArrayElement::Value(value) => Some(value.value), - _ => None, - }); - match values.next() { - // `['onEvent', 10]`: the method, then its priority. - Some(first @ Expression::Literal(Literal::String(_))) => { - push_own_method_span(subject, first, content, spans); - } - // `[['onEvent', 10], ['onOther']]`: one listener per entry. - Some(first) => { - for listener in std::iter::once(first).chain(values) { - push_listener_span(subject, listener, content, spans); - } - } - None => {} - } -} - -/// Push the method of one listener, `'onEvent'` or `['onEvent', 10]`. -fn push_listener_span( - subject: &str, - listener: &Expression<'_>, - content: &str, - spans: &mut Vec, -) { - let method = match crate::parser::array_literal_elements(listener) { - Some(elements) => match elements.iter().next() { - Some(ArrayElement::Value(value)) => value.value, - _ => return, - }, - None => listener, - }; - push_own_method_span(subject, method, content, spans); -} - /// Record the Messenger handlers a class's `#[AsMessageHandler]` /// attributes register, and emit the bus each one names and the method a /// class-level one names. @@ -3455,6 +3506,51 @@ mod tests { "onOther", ] ); + assert_eq!(constant_keys(&map), vec![("App\\Listener\\Other", "NAME")]); + } + + /// The class and constant of each subscription keyed by a constant. + fn constant_keys(map: &SymbolMap) -> Vec<(&str, &str)> { + map.event_constant_keys + .iter() + .map(|key| (key.class.as_str(), key.constant.as_str())) + .collect() + } + + #[test] + fn a_subscription_keyed_by_a_constant_records_its_class_and_constant() { + let php = r#", + /// The event subscriptions the file's subscribers key by a class + /// constant, in the order each subscriber's map is read. Empty for + /// every file that has none. + pub event_constant_keys: Vec, + /// The event subscriptions whose value lists other than one listener + /// (`[['onEvent', 10], ['onOther']]`), by where their key starts, with + /// how many it lists. Every other subscription names one. + pub event_listener_lists: Vec<(u32, u32)>, /// The Twig filters and functions the file declares, in source order. /// Empty for every file that declares none. pub twig_callables: Vec, @@ -428,6 +436,15 @@ pub(crate) struct SymbolMap { } impl SymbolMap { + /// How many listeners the event subscription whose key starts at + /// `start` names. + pub(crate) fn event_listeners_at(&self, start: u32) -> usize { + self.event_listener_lists + .iter() + .find(|(key, _)| *key == start) + .map_or(1, |(_, listeners)| *listeners as usize) + } + /// Whether this map's offsets are valid indices into `content`. /// /// A length match means no text was inserted or removed since the map diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index 8335c2ff1..6bde9bcf4 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -710,6 +710,23 @@ pub(crate) struct MessengerHandlerSite { pub message: Atom, } +/// An event subscription keyed by a class constant +/// (`KernelEvents::REQUEST => 'onRequest'`), which declares the event name +/// the constant holds. +/// +/// Kept in a side table rather than as a span, since the name is only +/// known once the constant's class is loaded, which is left to the lookups +/// that ask for event names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EventConstantKey { + /// The byte range of the whole key, `KernelEvents::REQUEST`. + pub start: u32, + pub end: u32, + /// The class the key names, fully qualified without a leading `\`. + pub class: Atom, + pub constant: Atom, +} + /// A Twig filter or function an extension declares, and the PHP callable /// behind it. /// diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 50d71f237..75aeae112 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -166,6 +166,7 @@ fn declares_symfony_keys(map: &SymbolMap, enabled: impl Fn(StringKeyKind) -> boo || (!map.route_imports.is_empty() && enabled(StringKeyKind::SymfonyRoute)) || (!map.service_decorations.is_empty() && enabled(StringKeyKind::ServiceId)) || (map.declares_twig_loader && enabled(StringKeyKind::TwigTemplate)) + || (!map.event_constant_keys.is_empty() && enabled(StringKeyKind::SymfonyEvent)) || (map.sets_unread_yii_view_parameters && enabled(StringKeyKind::YiiViewParameter)) || ((!map.drupal_route_patterns.is_empty() || !map.drupal_route_aliases.is_empty()) && enabled(StringKeyKind::DrupalRoute)) @@ -185,6 +186,10 @@ fn declares_symfony_keys(map: &SymbolMap, enabled: impl Fn(StringKeyKind) -> boo .any(|site| site.is_write && site.receiver.names_any(counts)) } +/// The classes the constant keys of one lookup of event names have loaded, +/// by name, each fully resolved, or `None` when it does not load. +type EventConstantClasses = crate::atom::AtomMap>>; + impl Backend { /// Whether the project builds a Symfony service container; see /// [`Backend::is_symfony`](crate::Backend::is_symfony). @@ -338,6 +343,9 @@ impl Backend { | KeyFramework::WordPress | KeyFramework::Tempest => true, }; + let mut event_constants = + (kind == StringKeyKind::SymfonyEvent).then(EventConstantClasses::default); + let loader = self.resolved_class_loader_with(&[]); for uri in uris { let Some(map) = self.symbol_map_for(&uri) else { continue; @@ -359,7 +367,61 @@ impl Backend { visit(&uri, &map, span.start, span.end, key); } } + if let Some(classes) = &mut event_constants { + for key in &map.event_constant_keys { + if let Some(value) = self.event_constant_value(key, &loader, classes) { + visit(&uri, &map, key.start, key.end, &value); + } + } + } + } + } + + /// How many listeners subscribe to the event `name`: every declaration + /// of it is a subscription, and a subscription may list several. + pub(crate) fn symfony_event_listener_count(&self, name: &str) -> usize { + let mut listeners = 0; + self.visit_symfony_declarations( + StringKeyKind::SymfonyEvent, + |_, map, start, _, declared| { + if declared == name { + listeners += map.event_listeners_at(start); + } + }, + ); + listeners + } + + /// The event name a subscription keyed by a class constant declares: + /// the constant's value, when it is a plain string literal. The class + /// is read with what it inherits, so a constant from a parent or an + /// interface counts. + fn event_constant_value( + &self, + key: &crate::symbol_map::EventConstantKey, + loader: &crate::class_lookup::ResolvedClassLoader<'_>, + classes: &mut EventConstantClasses, + ) -> Option { + let class = classes + .entry(key.class) + .or_insert_with(|| { + let class = loader(crate::class_lookup::Fqn::new(key.class.as_str()))?; + Some(crate::virtual_members::resolve_class_fully_cached( + &class, + loader, + &self.resolved_class_cache, + )) + }) + .as_ref()?; + let constant = class.get_constant(key.constant.as_str())?; + if constant.is_enum_case { + return None; } + let value = constant.value.as_deref()?.trim(); + // A constant's initializer cannot interpolate, so a double-quoted + // string is a plain one too, with its escapes decoded. + crate::text_scan::unquote_php_string(value)?; + crate::text_scan::decode_php_string_literal(value).map(std::borrow::Cow::into_owned) } /// Every service ID, container parameter, route name, translation key diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 939a0ee88..0bca2be18 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -357,7 +357,6 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::TwigFilter | StringKeyKind::TwigFunction | StringKeyKind::SymfonyConfigKey - | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::SerializerGroup | StringKeyKind::YiiRoute @@ -370,6 +369,21 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::DrupalLibrary => { return find_string_key_usages(kind, key, backend, snapshot, include_declaration); } + // A subscription keyed by a class constant declares the name the + // constant holds, which no span spells, so its declarations are + // added to the ones the usages hold. + StringKeyKind::SymfonyEvent => { + let mut locations = + find_string_key_usages(kind, key, backend, snapshot, include_declaration); + if include_declaration { + for location in backend.symfony_declaration_locations(*kind, key) { + if !locations.contains(&location) { + locations.push(location); + } + } + } + return locations; + } // A documented pattern stands for every hook that fills it in. StringKeyKind::DrupalHook => { return backend.drupal_hook_references(key, include_declaration); diff --git a/tests/integration/symfony_events.rs b/tests/integration/symfony_events.rs index 41df675e5..f0db8912e 100644 --- a/tests/integration/symfony_events.rs +++ b/tests/integration/symfony_events.rs @@ -417,6 +417,346 @@ function run(EventDispatcherInterface $events, object $event): void } } +const ORDER_EVENTS_PHP: &str = r#"dispatch($event, ''); +} +"#; + +#[tokio::test] +async fn a_subscription_keyed_by_a_constant_declares_its_value() { + let (backend, dir) = workspace(&[ + ("src/Event/OrderEvents.php", ORDER_EVENTS_PHP), + ("src/Event/BaseEvents.php", BASE_EVENTS_PHP), + ("src/Event/StoreEvents.php", STORE_EVENTS_PHP), + ("src/Event/PlaceOrder.php", PLACE_ORDER_PHP), + ( + "src/EventSubscriber/StoreSubscriber.php", + CONSTANT_SUBSCRIBER_PHP, + ), + ("src/Service/announce.php", DISPATCH_ANY_PHP), + ("src/Service/Checkout.php", DISPATCHER_PHP), + ]) + .await; + let subscriber = uri_for(&dir, "src/EventSubscriber/StoreSubscriber.php"); + let announce = uri_for(&dir, "src/Service/announce.php"); + let checkout = uri_for(&dir, "src/Service/Checkout.php"); + let anywhere = position_in(DISPATCH_ANY_PHP, "$event, ''", 9); + + // An inherited constant counts, from a parent or an interface, and so + // does a double-quoted string, which a constant cannot interpolate; a + // class name, a concatenation, and another constant declare nothing. + let labels = completion_labels(&backend, &announce, anywhere).await; + assert_eq!( + labels, + vec![ + "app.order.$priced", + "app.order.closed", + "app.order.placed", + "app.store.opened" + ] + ); + + let found = definitions( + &backend, + &checkout, + position_in(DISPATCHER_PHP, "'app.order.placed'", 3), + ) + .await; + assert_eq!(found, vec![(subscriber.clone(), 10)]); + + let position = position_in(DISPATCHER_PHP, "'app.order.placed'", 3); + let hover = + crate::common::hover_text_at(&backend, &checkout, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("1 listener in the project"), "{hover}"); + + // The subscription the constant declares is among the references, as + // it is among the definitions. + let found = references( + &backend, + &checkout, + position_in(DISPATCHER_PHP, "'app.order.placed'", 3), + ) + .await; + assert_eq!( + found, + sorted(vec![(checkout.clone(), 11), (subscriber.clone(), 10)]) + ); + + // The value is read when the names are asked for. + let changed = ORDER_EVENTS_PHP.replace("'app.order.placed'", "'app.order.submitted'"); + backend + .did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { + text_document_identifier: TextDocumentIdentifier { + uri: uri_for(&dir, "src/Event/OrderEvents.php"), + }, + version: 2, + }, + content_changes: vec![ + TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument( + TextDocumentContentChangeWholeDocument { text: changed }, + ), + ], + }) + .await; + let labels = completion_labels(&backend, &announce, anywhere).await; + assert_eq!( + labels, + vec![ + "app.order.$priced", + "app.order.closed", + "app.order.submitted", + "app.store.opened" + ] + ); +} + +#[tokio::test] +async fn hover_counts_each_listener_whatever_the_map_shape() { + let listed = r#" [['onPlaced', 10], ['onLogged']]]; + } + + public function onPlaced(object $event): void {} + public function onLogged(object $event): void {} +} +"#; + let appended = r#" 'onPlaced']; + } + + public function onPlaced(object $event): void {} +} +"#; + // No import: `OrderEvents` is a class of this namespace, which does + // not exist, never the global one. + let unimported = r#" 'onPaid']; + } + + public function onPaid(object $event): void {} +} +"#; + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("composer.json"), CLASSMAP_COMPOSER).unwrap(); + let files: Vec<(&str, &str)> = STUBS + .iter() + .copied() + .chain([ + ("src/Event/OrderEvents.php", namespaced), + ("lib/OrderEvents.php", global), + ("src/EventSubscriber/ImportedSubscriber.php", imported), + ("src/EventSubscriber/UnimportedSubscriber.php", unimported), + ("lib/GlobalSubscriber.php", global_subscriber), + ("src/Service/announce.php", DISPATCH_ANY_PHP), + ]) + .collect(); + for (path, content) in &files { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let backend = Backend::new_test(); + crate::common::initialize_with_options(&backend, dir.path(), None).await; + // Only the dispatcher is open: the subscribers are read by the + // workspace index, as a subscriber nobody has open is. A test backend + // has no client, so `initialized` leaves the index out. + backend.ensure_workspace_indexed(); + let announce = uri_for(&dir, "src/Service/announce.php"); + open_document(&backend, &announce, "php", DISPATCH_ANY_PHP).await; + + let labels = completion_labels( + &backend, + &announce, + position_in(DISPATCH_ANY_PHP, "$event, ''", 9), + ) + .await; + assert_eq!( + labels, + vec!["app.order.placed", "app.order.shipped", "global.order.paid"] + ); +} + #[tokio::test] async fn buses_navigate_complete_and_report_unknown_names() { let consumer = r#" Date: Wed, 11 Nov 2026 15:29:31 +0100 Subject: [PATCH 4/6] Event names in Drupal dispatcher calls In a Drupal site, the event name a dispatcher call takes completes with every name the project's subscribers declare, goes to each subscription, and shows how many listeners subscribe. This works on the contracts and component interfaces and on \Drupal::service('event_dispatcher'). The name a listener is added for in code completes too, but declares nothing. Assisted-by: claude-code/claude-opus-5-5 --- benches/drupal_completion.rs | 53 ++++- examples/drupal/README.md | 15 +- examples/drupal/assertions.php | 15 ++ .../web/modules/custom/acme/acme.module | 13 + .../custom/acme/src/Event/GreetingEvents.php | 16 ++ .../EventSubscriber/GreetingSubscriber.php | 13 + tests/integration/drupal_events.rs | 224 +++++++++++++++++- 7 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php diff --git a/benches/drupal_completion.rs b/benches/drupal_completion.rs index a4141c87e..3d28f3763 100644 --- a/benches/drupal_completion.rs +++ b/benches/drupal_completion.rs @@ -10,7 +10,8 @@ use tower_lsp_server::gen_lsp_types::*; /// services, `ROUTES` routes, `ENTITY_TYPES` entity types, `PERMISSIONS` /// permissions, `CONFIGS` configuration objects, with their default /// configuration and schema, `ELEMENTS` render elements, `BLOCKS` block -/// plugins, `THEME_HOOKS` theme hooks, and `LIBRARIES` libraries, +/// plugins, `THEME_HOOKS` theme hooks, `LIBRARIES` libraries, and an +/// events class of `EVENTS` constants its event subscriber subscribes to, /// documenting `HOOKS` hooks, and implementing the hooks the module before /// it documents. const MODULES: usize = 30; @@ -24,6 +25,7 @@ const BLOCKS: usize = 3; const HOOKS: usize = 10; const THEME_HOOKS: usize = 5; const LIBRARIES: usize = 10; +const EVENTS: usize = 5; const COMPOSER: &str = r#"{ "require": { "drupal/core-recommended": "^11" }, @@ -32,7 +34,8 @@ const COMPOSER: &str = r#"{ /// Just enough of core, where Composer puts it, for the type engine to /// tell a container, the entity type manager, the module handler, an -/// account, a URL, and the block manager from any other receiver. +/// account, a URL, the block manager, and the event dispatcher from any +/// other receiver. const STUBS: &[(&str, &str)] = &[ ( "web/core/lib/Drupal/Component/Plugin/PluginManagerInterface.php", @@ -90,6 +93,10 @@ const STUBS: &[(&str, &str)] = &[ "web/core/lib/Drupal/Core/Hook/Attribute/Hook.php", " ['library' => ['mod1/']]]; $build = ['#submit' => ['mod1_']]; $build = ['#pre_render' => ['Drupal\acme\Consumer::r']]; + $this->events->dispatch($this, 'mod1.'); } } @@ -148,7 +158,7 @@ fn rt() -> tokio::runtime::Runtime { /// The files of a site with many custom modules: their services, routes, /// entity types, render elements, documented hooks, hook implementations, -/// and theme hooks. +/// theme hooks, and event subscribers. fn generate_site() -> Vec<(String, String)> { let mut files = Vec::new(); @@ -256,6 +266,36 @@ fn generate_site() -> Vec<(String, String)> { theme.push_str(" ];\n }\n}\n"); files.push((format!("{dir}/src/Hook/ThemeHooks.php"), theme)); + // The subscriber keys its map by the events class's constants, so + // each name is the value of a constant read when names are asked for. + let mut events = + format!(" 0 { let previous = m - 1; let mut module = String::from(" ['Drupal\\acme\\Consumer::r'", "run", ), + ( + "event_name", + &consumer, + CONSUMER_PHP, + "dispatch($this, 'mod1.'", + "mod1.event3", + ), ( "hook_implementation", &module, diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 821065b95..c75275820 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -202,6 +202,14 @@ Run `composer install` here first. Composer puts core and contrib into it; rename `onResponse()` and the string follows. Misspell a method name in the map and it is reported. In `RequestSubscriber.php`, which returns its map, find the references of `onRequest()`: the string is one of them. +- Event names navigate from the dispatcher calls that name them to the + subscriptions that listen to them, a subscription keyed by a class + constant included. In `acme_announce_greeting()` in + `web/modules/custom/acme/acme.module`, go to the definition of + `'acme.greeted'`, which lands on the `GreetingEvents::GREETED` key of + `GreetingSubscriber`, or hover it to see how many listeners subscribe. + Complete an event name inside the quotes of `dispatch()` or of the + dispatcher's other listener methods. ## What the Symfony support already reads @@ -219,13 +227,6 @@ project. What works today: method name there completes from the public methods of the service's class. -What does not work yet: - -- **Event names keyed by a class constant.** A subscriber keyed by a - constant such as `KernelEvents::REQUEST` does not declare the event name - the constant holds, so the dispatcher calls that name it neither complete - nor navigate to the subscriber. See `docs/todo/drupal.md`. - ## Runtime assertions `assertions.php` reads the module's declarations with Drupal's own loaders diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 9302eaa38..ca4da651c 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -17,6 +17,7 @@ use Drupal\acme\Controller\NoteController; use Drupal\acme\Element\GreetingCard; use Drupal\acme\Entity\Note; +use Drupal\acme\Event\GreetingEvents; use Drupal\acme\EventSubscriber\GreetingSubscriber; use Drupal\acme\EventSubscriber\RequestSubscriber; use Drupal\acme\Form\GreetingForm; @@ -75,6 +76,7 @@ use Drupal\Core\Utility\CallableResolver; use Drupal\language\ConfigurableLanguageManager; use Drupal\language\LanguageServiceProvider; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\KernelEvents; @@ -214,6 +216,7 @@ function listenerMethods(array $events): array 'acme.greeting_subscriber' => [GreetingSubscriber::class, [ KernelEvents::RESPONSE => ['onResponse'], ConfigEvents::SAVE => ['onConfigSave'], + GreetingEvents::GREETED => ['onGreeted'], ]], 'acme.request_subscriber' => [RequestSubscriber::class, [ KernelEvents::REQUEST => ['onRequest'], @@ -234,6 +237,18 @@ function listenerMethods(array $events): array check('RequestSubscriber returns its map as a literal', str_contains((string) file_get_contents("$module/src/EventSubscriber/RequestSubscriber.php"), 'return [KernelEvents::REQUEST => ')); check('A misspelt listener such as onRespons is no method of GreetingSubscriber', !method_exists(GreetingSubscriber::class, 'onRespons')); +// A key written as a class constant subscribes to the event the constant +// holds, which is the name a dispatcher call writes. +check('GreetingEvents::GREETED holds acme.greeted', GreetingEvents::GREETED === 'acme.greeted'); +$greetedListeners = 0; +foreach ($subscribers as [$class]) { + $greetedListeners += count(listenerMethods($class::getSubscribedEvents())['acme.greeted'] ?? []); +} +check('One listener in the module subscribes to acme.greeted', $greetedListeners === 1); +check("acme_announce_greeting() dispatches acme.greeted on core's event_dispatcher", str_contains((string) file_get_contents("$module/acme.module"), "\\Drupal::service('event_dispatcher')->dispatch(new Event(), 'acme.greeted')")); +check('core.services.yml builds event_dispatcher from Symfony\'s EventDispatcher', ltrim((string) $core->getDefinition('event_dispatcher')->getClass(), '\\') === EventDispatcher::class); +check("EventDispatcher takes the event name second in dispatch()", (new ReflectionMethod(EventDispatcher::class, 'dispatch'))->getParameters()[1]->getName() === 'eventName'); + // ─── Routes ───────────────────────────────────────────────────────────────── $routes = Yaml::decode((string) file_get_contents("$module/acme.routing.yml")); diff --git a/examples/drupal/web/modules/custom/acme/acme.module b/examples/drupal/web/modules/custom/acme/acme.module index 3376c5905..dd2225577 100644 --- a/examples/drupal/web/modules/custom/acme/acme.module +++ b/examples/drupal/web/modules/custom/acme/acme.module @@ -8,6 +8,7 @@ declare(strict_types=1); use Drupal\acme\Greeter; +use Drupal\Component\EventDispatcher\Event; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; @@ -93,3 +94,15 @@ function acme_greeting_form_submit(array &$form, FormStateInterface $form_state) // the string in GreetingForm::buildForm(). \Drupal::logger('acme')->info('Greeted @name.', ['@name' => $form_state->getValue('name')]); } + +/** + * Tells the module's subscribers a visitor has been greeted. + */ +function acme_announce_greeting(): void +{ + // Try: go to the definition of 'acme.greeted', which lands on the + // GreetingEvents::GREETED key of GreetingSubscriber, or hover it to see + // how many listeners subscribe. Complete an event name inside the + // quotes: the names the subscribers' constant keys hold are offered. + \Drupal::service('event_dispatcher')->dispatch(new Event(), 'acme.greeted'); +} diff --git a/examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php b/examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php new file mode 100644 index 000000000..abeb5cd5b --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php @@ -0,0 +1,16 @@ +getResponse()->headers->set('X-Acme-Greeting', 'hello'); } + public function onGreeted(Event $event): void + { + $this->greeted++; + } + public function onConfigSave(ConfigCrudEvent $event): void { if ($event->getConfig()->getName() === 'acme.settings') { diff --git a/tests/integration/drupal_events.rs b/tests/integration/drupal_events.rs index 9324fbdbb..fb56552a3 100644 --- a/tests/integration/drupal_events.rs +++ b/tests/integration/drupal_events.rs @@ -1,24 +1,79 @@ -//! Drupal's event subscribers: the listener methods a subscriber's -//! `getSubscribedEvents()` names, whether it returns the map or builds it -//! by assignment. +//! Drupal's event subscribers and event names: the listener methods a +//! subscriber's `getSubscribedEvents()` names, whether it returns the map +//! or builds it by assignment, and the event names its keys declare for +//! the dispatcher calls that name them. -use crate::common::{hover_text_at, message_text, rename}; +use crate::common::{completion_response, hover_text_at, message_text, rename, response_labels}; use crate::drupal_services::{definitions_at, position_in, references_at, uri_for, workspace_in}; use tower_lsp_server::gen_lsp_types::*; -/// Just enough of Symfony's event dispatcher, where Composer would put it -/// in a Drupal site. +/// Just enough of Symfony's event dispatcher, where the Drupal walk reads +/// core, core's `event_dispatcher` service, and a core module that +/// subscribes by constant: files nobody has open. const STUBS: &[(&str, &str)] = &[ ( - "vendor/symfony/event-dispatcher/EventSubscriberInterface.php", + "web/core/lib/Symfony/EventDispatcher/EventSubscriberInterface.php", " 'onTested'];\n }\n public function onTested(object $event): void {}\n}\n", + ), ]; +/// A core subscriber keyed only by constants, one of them inherited from +/// an interface. +const SHOP_SUBSCRIBER: &str = " (phpantom_lsp::Backend, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); for (path, content) in STUBS { @@ -64,7 +120,10 @@ async fn site(files: &[(&str, &str)]) -> (phpantom_lsp::Backend, tempfile::TempD std::fs::create_dir_all(full.parent().unwrap()).unwrap(); std::fs::write(full, content).unwrap(); } - workspace_in(dir, files).await + let (backend, dir) = workspace_in(dir, files).await; + // A test backend has no client, so `initialized` leaves the index out. + backend.ensure_workspace_indexed(); + (backend, dir) } /// The line `needle` first appears on. @@ -270,3 +329,148 @@ async fn nothing_is_read_outside_a_project_with_a_service_container() { let reported = unknown_members(&backend, &uri, &content); assert!(reported.is_empty(), "{reported:?}"); } + +/// A module's calls on each of the dispatcher's typings, and on something +/// that is not a dispatcher. +const CHECKOUT: &str = r#"contracts->dispatch($order, 'acme.order_placed'); + $this->events->hasListeners('shop.opened'); + \Drupal::service('event_dispatcher')->dispatch($order, 'shop.closed'); + $this->events->addListener('acme.order_paid', [$this, 'place']); + $other->offsetSet($order, 'acme.order_placed'); + } +} +"#; + +const CHECKOUT_PATH: &str = "web/modules/custom/acme/src/Checkout.php"; + +#[tokio::test] +async fn a_dispatched_event_goes_to_every_subscription_on_each_dispatcher_typing() { + let (backend, dir) = site(&[(ORDER_PATH, ORDER_SUBSCRIBER), (CHECKOUT_PATH, CHECKOUT)]).await; + let checkout = uri_for(&dir, CHECKOUT_PATH); + let order = uri_for(&dir, ORDER_PATH); + let shop = uri_for(&dir, SHOP_SUBSCRIBER_PATH); + + for (needle, uri, line) in [ + ( + "'acme.order_placed'", + &order, + line_of(ORDER_SUBSCRIBER, "'acme.order_placed'"), + ), + ( + "'shop.opened'", + &shop, + line_of(SHOP_SUBSCRIBER, "ShopEvents::OPENED"), + ), + ( + "'shop.closed'", + &shop, + line_of(SHOP_SUBSCRIBER, "ShopEvents::CLOSED"), + ), + ] { + let found = definitions_at(&backend, &checkout, position_in(CHECKOUT, needle, 2)).await; + let found: Vec<(&Uri, u32)> = found + .iter() + .map(|location| (&location.uri, location.range.start.line)) + .collect(); + assert_eq!(found, vec![(uri, line)], "{needle}"); + + let position = position_in(CHECKOUT, needle, 2); + let hover = hover_text_at(&backend, &checkout, position.line, position.character) + .await + .unwrap_or_default(); + assert!( + hover.contains("1 listener in the project"), + "{needle}: {hover}" + ); + } + + // `offsetSet()` on something that is not a dispatcher names nothing. + let position = position_in(CHECKOUT, "$order, 'acme.order_placed');\n }", 10); + let found = definitions_at(&backend, &checkout, position).await; + assert!(found.is_empty(), "{found:?}"); +} + +#[tokio::test] +async fn references_of_an_event_name_include_the_subscriptions_keyed_by_a_constant() { + let (backend, dir) = site(&[(ORDER_PATH, ORDER_SUBSCRIBER), (CHECKOUT_PATH, CHECKOUT)]).await; + let checkout = uri_for(&dir, CHECKOUT_PATH); + let shop = uri_for(&dir, SHOP_SUBSCRIBER_PATH); + + let found = references_at( + &backend, + &checkout, + position_in(CHECKOUT, "'shop.opened'", 2), + ) + .await; + let mut expected = vec![ + (checkout.clone(), line_of(CHECKOUT, "'shop.opened'")), + (shop, line_of(SHOP_SUBSCRIBER, "ShopEvents::OPENED")), + ]; + expected.sort(); + assert_eq!(found, expected); +} + +#[tokio::test] +async fn a_listener_added_in_code_completes_but_declares_nothing() { + let (backend, dir) = site(&[(ORDER_PATH, ORDER_SUBSCRIBER), (CHECKOUT_PATH, CHECKOUT)]).await; + let checkout = uri_for(&dir, CHECKOUT_PATH); + + // Drupal records no event a call declares, so the name the call adds + // a listener for neither navigates nor hovers. + let position = position_in(CHECKOUT, "'acme.order_paid'", 2); + let found = definitions_at(&backend, &checkout, position).await; + assert!(found.is_empty(), "{found:?}"); + let hover = hover_text_at(&backend, &checkout, position.line, position.character).await; + assert!(hover.is_none(), "{hover:?}"); +} + +#[tokio::test] +async fn event_names_complete_in_each_dispatcher_call() { + let typing = CHECKOUT + .replace( + "'acme.order_placed');\n $this->events", + "'');\n $this->events", + ) + .replace("'shop.opened'", "''") + .replace("'shop.closed'", "''") + .replace("'acme.order_paid'", "''"); + let (backend, dir) = site(&[ + (ORDER_PATH, ORDER_SUBSCRIBER), + (CHECKOUT_PATH, typing.as_str()), + ]) + .await; + let checkout = uri_for(&dir, CHECKOUT_PATH); + let expected = vec![ + "acme.order_paid".to_string(), + "acme.order_placed".to_string(), + "shop.closed".to_string(), + "shop.opened".to_string(), + ]; + for needle in [ + "dispatch($order, '", + "hasListeners('", + "->dispatch($order, '');\n $this->events->addListener", + "addListener('", + ] { + let at = typing.find(needle).expect("needle should exist"); + let cursor = at + needle.find('\'').unwrap() + 1; + let position = crate::common::offset_position(&typing, cursor); + let labels = response_labels( + completion_response(&backend, &checkout, position.line, position.character, None).await, + ); + assert_eq!(labels, expected, "{needle}"); + } +} From e945aba1189c76bfd22594c351f559c55383cab2 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Wed, 11 Nov 2026 17:05:46 +0100 Subject: [PATCH 5/6] Treat an event constant as the definition of its event name A class constant a subscriber keys its map by is the event name it holds in another spelling. Go to definition on an event string offers the constant first, then every subscription, and Find References on the string, on the constant, or on the string the constant is initialized with lists the constant, its uses, and the dispatches as one event. A constant no subscriber keys by stays a plain constant, and renaming one never rewrites an event string. Assisted-by: claude-code/claude-opus-5-5 --- examples/drupal/README.md | 15 +- examples/drupal/assertions.php | 2 + .../web/modules/custom/acme/acme.module | 10 +- .../custom/acme/src/Event/GreetingEvents.php | 6 + .../EventSubscriber/GreetingSubscriber.php | 3 +- src/references/dispatch.rs | 25 +- src/references/events.rs | 269 +++++++++++++++ src/references/mod.rs | 1 + src/symfony/event_constants.rs | 168 ++++++++++ src/symfony/mod.rs | 52 +++ src/virtual_members/laravel/string_keys.rs | 13 +- tests/integration/drupal_events.rs | 307 +++++++++++++++++- tests/integration/symfony_events.rs | 285 +++++++++++++++- 13 files changed, 1131 insertions(+), 25 deletions(-) create mode 100644 src/references/events.rs create mode 100644 src/symfony/event_constants.rs diff --git a/examples/drupal/README.md b/examples/drupal/README.md index c75275820..8b7ae4f35 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -203,13 +203,16 @@ Run `composer install` here first. Composer puts core and contrib into in the map and it is reported. In `RequestSubscriber.php`, which returns its map, find the references of `onRequest()`: the string is one of them. - Event names navigate from the dispatcher calls that name them to the - subscriptions that listen to them, a subscription keyed by a class - constant included. In `acme_announce_greeting()` in - `web/modules/custom/acme/acme.module`, go to the definition of - `'acme.greeted'`, which lands on the `GreetingEvents::GREETED` key of + subscriptions that listen to them, and a class constant a subscriber + keys its map by is the event's definition. In `acme_announce_greeting()` + in `web/modules/custom/acme/acme.module`, go to the definition of + `'acme.greeted'`, which offers `GreetingEvents::GREETED` in + `src/Event/GreetingEvents.php` first, then the key of `GreetingSubscriber`, or hover it to see how many listeners subscribe. - Complete an event name inside the quotes of `dispatch()` or of the - dispatcher's other listener methods. + Find the references of `GREETED`, or of `'acme.greeted'`: the constant, + the subscription, and the dispatch are one list. Complete an event name + inside the quotes of `dispatch()` or of the dispatcher's other methods + that take one. ## What the Symfony support already reads diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index ca4da651c..8a4435669 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -240,6 +240,8 @@ function listenerMethods(array $events): array // A key written as a class constant subscribes to the event the constant // holds, which is the name a dispatcher call writes. check('GreetingEvents::GREETED holds acme.greeted', GreetingEvents::GREETED === 'acme.greeted'); +check('GreetingSubscriber keys its map by GreetingEvents::GREETED, which makes it the definition of acme.greeted', str_contains((string) file_get_contents("$module/src/EventSubscriber/GreetingSubscriber.php"), '$events[GreetingEvents::GREETED][] = ')); +check('GreetingEvents declares GREETED itself, as a plain string', (new ReflectionClassConstant(GreetingEvents::class, 'GREETED'))->getDeclaringClass()->getName() === GreetingEvents::class && is_string(GreetingEvents::GREETED)); $greetedListeners = 0; foreach ($subscribers as [$class]) { $greetedListeners += count(listenerMethods($class::getSubscribedEvents())['acme.greeted'] ?? []); diff --git a/examples/drupal/web/modules/custom/acme/acme.module b/examples/drupal/web/modules/custom/acme/acme.module index dd2225577..e344e7234 100644 --- a/examples/drupal/web/modules/custom/acme/acme.module +++ b/examples/drupal/web/modules/custom/acme/acme.module @@ -100,9 +100,11 @@ function acme_greeting_form_submit(array &$form, FormStateInterface $form_state) */ function acme_announce_greeting(): void { - // Try: go to the definition of 'acme.greeted', which lands on the - // GreetingEvents::GREETED key of GreetingSubscriber, or hover it to see - // how many listeners subscribe. Complete an event name inside the - // quotes: the names the subscribers' constant keys hold are offered. + // Try: go to the definition of 'acme.greeted', which offers the + // constant GreetingEvents::GREETED first, then the key of + // GreetingSubscriber that subscribes by it. Hover it to see how many + // listeners subscribe, or find its references, the constant's included. + // Complete an event name inside the quotes: the names the subscribers' + // constant keys hold are offered. \Drupal::service('event_dispatcher')->dispatch(new Event(), 'acme.greeted'); } diff --git a/examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php b/examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php index abeb5cd5b..bdbedcee4 100644 --- a/examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php +++ b/examples/drupal/web/modules/custom/acme/src/Event/GreetingEvents.php @@ -11,6 +11,12 @@ final class GreetingEvents { /** * Dispatched once a visitor has been greeted. + * + * GreetingSubscriber keys its map by this constant, so it defines the + * event 'acme.greeted'. Try: find the references of GREETED, or of the + * string it holds: the key of GreetingSubscriber and the dispatch of + * 'acme.greeted' in acme.module are listed with it. Renaming GREETED + * leaves that string alone. */ public const GREETED = 'acme.greeted'; } diff --git a/examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php b/examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php index 00efe35af..d6ca6bdf4 100644 --- a/examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php +++ b/examples/drupal/web/modules/custom/acme/src/EventSubscriber/GreetingSubscriber.php @@ -34,7 +34,8 @@ public static function getSubscribedEvents(): array $events[KernelEvents::RESPONSE][] = ['onResponse', 10]; $events[ConfigEvents::SAVE][] = ['onConfigSave']; // GreetingEvents::GREETED declares the event 'acme.greeted', which - // acme_announce_greeting() in acme.module dispatches. + // acme_announce_greeting() in acme.module dispatches. Try: find the + // references of GREETED: that dispatch is among them. $events[GreetingEvents::GREETED][] = ['onGreeted']; return $events; } diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 189db585b..02892f689 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -97,6 +97,17 @@ impl Backend { ); let mut locations = self.dispatch_symbol_references(sym, uri, content, include_declaration, mode); + // An event string and the constant that holds it are one event. + if mode == ReferenceSearchMode::References { + self.link_event_references( + sym, + uri, + content, + position, + include_declaration, + &mut locations, + ); + } // 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 @@ -128,6 +139,18 @@ impl Backend { } } + if mode == ReferenceSearchMode::References + && let Some(mut locations) = self.event_constant_initializer_references( + uri, + content, + position, + include_declaration, + ) + { + sort_locations_for_references(&mut locations); + return Some(locations); + } + if self.resolved_class_cache.read().laravel().is_enabled() && let Some(locations) = laravel::find_json_trans_references( self, @@ -175,7 +198,7 @@ impl Backend { } /// Dispatch a symbol-map hit to the appropriate reference finder. - fn dispatch_symbol_references( + pub(super) fn dispatch_symbol_references( &self, span: &SymbolSpan, uri: &str, diff --git a/src/references/events.rs b/src/references/events.rs new file mode 100644 index 000000000..d51daa46d --- /dev/null +++ b/src/references/events.rs @@ -0,0 +1,269 @@ +//! Find References on an event name, in either of its spellings. +//! +//! A class constant a subscriber keys its map by is the definition of the +//! event name it holds (see [`crate::symfony::event_constants`]), so the +//! references of the event string, of the constant's name, and of the +//! string the constant is initialized with are one list: the constant's +//! declaration and uses, the subscriptions, and the dispatcher calls. It +//! is put together when asked for; the reference index holds only spans. + +use super::*; + +use mago_span::HasSpan; +use mago_syntax::cst::*; + +use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::virtual_members::laravel; + +impl Backend { + /// Add the rest of the event `sym` belongs to to its `locations`: the + /// constant that holds an event string, or the event strings a + /// constant holds. Anything else is left as it is. + pub(super) fn link_event_references( + &self, + sym: &SymbolSpan, + uri: &str, + content: &str, + position: Position, + include_declaration: bool, + locations: &mut Vec, + ) { + if !self.string_key_kind_enabled(StringKeyKind::SymfonyEvent) { + return; + } + let name = match &sym.kind { + SymbolKind::StringKey { + kind: StringKeyKind::SymfonyEvent, + key, + .. + } => key.clone(), + SymbolKind::MemberDeclaration { + name, + is_static: true, + is_method: false, + } if self.names_event_constant(name) => { + let Some(declaration) = location_of(uri, content, sym.start, sym.end) else { + return; + }; + let Some(constant) = self.symfony_event_constant_at(&declaration, name) else { + return; + }; + constant.event + } + SymbolKind::MemberAccess { + member_name, + is_static: true, + is_method_call: false, + .. + } if self.names_event_constant(member_name) => { + let constant = self + .resolve_definition(uri, content, position) + .iter() + .find_map(|declaration| { + self.symfony_event_constant_at(declaration, member_name) + }); + let Some(constant) = constant else { + return; + }; + constant.event + } + _ => return, + }; + self.add_event_references(&name, include_declaration, locations); + } + + /// The references of the event a class constant's initializer names, + /// when the cursor is on the string a subscriber's constant holds. + pub(super) fn event_constant_initializer_references( + &self, + uri: &str, + content: &str, + position: Position, + include_declaration: bool, + ) -> Option> { + if !self.string_key_kind_enabled(StringKeyKind::SymfonyEvent) { + return None; + } + let offset = position_to_offset(content, position); + let (name, start, end) = constant_initialized_at(content, offset)?; + if !self.names_event_constant(&name) { + return None; + } + let declaration = location_of(uri, content, start, end)?; + let constant = self.symfony_event_constant_at(&declaration, &name)?; + let mut locations = self.constant_references_from(&constant.use_site, include_declaration); + self.add_event_references(&constant.event, include_declaration, &mut locations); + Some(locations) + } + + /// Add every reference of the event `name` to `locations`, each once: + /// the event strings, the subscriptions, and each constant that holds + /// it with its uses. + /// + /// A subscription declares the event whatever its spelling, so with + /// declarations left out the subscriptions keyed by a constant go too; + /// with them in, such a subscription is listed as the use of the + /// constant its key holds. + fn add_event_references( + &self, + name: &str, + include_declaration: bool, + locations: &mut Vec, + ) { + let kind = StringKeyKind::SymfonyEvent; + let reference_key = crate::reference_index::string_key_reference_key(kind, name); + let snapshot = self.user_file_symbol_maps_for_reference_keys(&[reference_key]); + let strings = laravel::find_laravel_string_key_references( + self, + &kind, + name, + "", + &snapshot, + include_declaration, + ); + let found = self.symfony_event_constants_named(name); + let mut seen: HashSet = locations.iter().cloned().collect(); + let uses = found + .constants + .iter() + .flat_map(|constant| { + self.constant_references_from(&constant.use_site, include_declaration) + }) + .collect::>(); + for location in strings.into_iter().chain(uses) { + if seen.insert(location.clone()) { + locations.push(location); + } + } + // A constant's own references search the project's files, which a + // constant a dependency declares is not among. + if include_declaration { + for constant in &found.constants { + let declaration = &constant.declaration; + if !locations.iter().any(|location| { + location.uri == declaration.uri + && location.range.start == declaration.range.start + }) { + locations.push(name_location(declaration, constant.name.as_str())); + } + } + } + if found.keys.is_empty() { + return; + } + let keys: HashSet<&Location> = found.keys.iter().collect(); + let dropped: Vec = locations + .iter() + .map(|location| { + if include_declaration { + keys.contains(location) + && locations + .iter() + .any(|inner| inner != location && within(inner, location)) + } else { + found.keys.iter().any(|key| within(location, key)) + } + }) + .collect(); + let mut index = 0; + locations.retain(|_| { + let keep = !dropped[index]; + index += 1; + keep + }); + } + + /// The references of a class constant, searched from a use of it at + /// `offset` in the file `uri`, a file whose symbol map is read even + /// when the constant's own file is not. + fn constant_references_from( + &self, + (uri, offset): &(String, u32), + include_declaration: bool, + ) -> Vec { + let Some(content) = self.get_file_content_arc(uri) else { + return Vec::new(); + }; + let position = offset_to_position(&content, *offset as usize); + let Some(symbol) = self.lookup_symbol_at_position(uri, &content, position) else { + return Vec::new(); + }; + self.dispatch_symbol_references( + &symbol, + uri, + &content, + include_declaration, + ReferenceSearchMode::References, + ) + } +} + +/// The location of the byte range `start..end` of `content`, the text of +/// the file `uri`. +fn location_of(uri: &str, content: &str, start: u32, end: u32) -> Option { + Some(Location { + uri: crate::util::parse_url(uri)?, + range: Range { + start: offset_to_position(content, start as usize), + end: offset_to_position(content, end as usize), + }, + }) +} + +/// `declaration`, covering the constant's name `name` when it is the point +/// a definition gives. +fn name_location(declaration: &Location, name: &str) -> Location { + let mut location = declaration.clone(); + if location.range.end == location.range.start { + location.range.end.character += name.encode_utf16().count() as u32; + } + location +} + +/// Whether `inner` lies within `outer`, in the same file. +fn within(inner: &Location, outer: &Location) -> bool { + inner.uri == outer.uri + && outer.range.start <= inner.range.start + && inner.range.end <= outer.range.end +} + +/// The class constant whose initializer is the string literal `offset` is +/// in: its name and where the name starts and ends. +fn constant_initialized_at(content: &str, offset: u32) -> Option<(String, u32, u32)> { + crate::parser::with_parsed_program(content, "event_constant_initializer", |program, _| { + let mut found = None; + find_constant_initialized_at(Node::Program(program), offset, &mut found); + found + }) +} + +fn find_constant_initialized_at( + node: Node<'_, '_>, + offset: u32, + found: &mut Option<(String, u32, u32)>, +) { + node.visit_children(|child| { + if found.is_some() { + return; + } + if let Node::ClassLikeConstantItem(item) = child { + if let Expression::Literal(Literal::String(literal)) = item.value { + let span = literal.span(); + if span.start.offset <= offset && offset < span.end.offset { + let name = &item.name; + *found = Some(( + String::from_utf8_lossy(name.value).into_owned(), + name.span.start.offset, + name.span.end.offset, + )); + } + } + return; + } + let span = child.span(); + if span.start.offset <= offset && offset <= span.end.offset { + find_constant_initialized_at(child, offset, found); + } + }); +} diff --git a/src/references/mod.rs b/src/references/mod.rs index f51aac52b..74c597b1a 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -33,6 +33,7 @@ mod classes; mod covers; mod dispatch; mod eloquent; +mod events; mod functions; mod member_scope; mod members; diff --git a/src/symfony/event_constants.rs b/src/symfony/event_constants.rs new file mode 100644 index 000000000..8a1d9b37e --- /dev/null +++ b/src/symfony/event_constants.rs @@ -0,0 +1,168 @@ +//! The class constants event subscribers key their maps by, as the +//! definition of the event name each holds. +//! +//! `KernelEvents::REQUEST` and `'kernel.request'` are one event in two +//! spellings once a subscriber keys its map by the constant, so the +//! constant's declaration is where the event name is defined. Nothing +//! here is indexed: the constants are found from the subscriptions when +//! a definition or the references are asked for, and declared where PHP's +//! own go-to-definition of the key's constant lands, inherited ones +//! included. + +use std::collections::HashMap; + +use tower_lsp_server::gen_lsp_types::{Location, Range}; + +use crate::Backend; +use crate::atom::Atom; +use crate::symbol_map::{EventConstantKey, StringKeyKind}; + +/// A class constant subscribers key their maps by. +pub(crate) struct EventConstant { + pub name: Atom, + /// Where it is declared. + pub declaration: Location, + /// The event name it holds. + pub event: String, + /// A use of it a subscription's key makes, in a file whose symbol map + /// is always read: the file and an offset inside the constant's name. + pub use_site: (String, u32), +} + +/// The class constants that hold one event name, and the subscriptions +/// keyed by them. +#[derive(Default)] +pub(crate) struct EventConstants { + pub constants: Vec, + /// The whole key of each subscription, `Events::NAME`. + pub keys: Vec, +} + +impl Backend { + /// Whether a subscriber keys its map by a class constant named + /// `constant`, of any class. + pub(crate) fn names_event_constant(&self, constant: &str) -> bool { + self.string_key_kind_enabled(StringKeyKind::SymfonyEvent) + && self + .event_constant_names() + .contains(&crate::atom::atom(constant)) + } + + /// The class constants subscribers key their maps by whose value is + /// the event name `name`, each once, and the subscriptions keyed by + /// them. + pub(crate) fn symfony_event_constants_named(&self, name: &str) -> EventConstants { + let mut keys = Vec::new(); + let constants = self.event_constants( + |_| true, + |uri, key, value| { + if value != name { + return false; + } + keys.push((uri.to_string(), key.start, key.end)); + true + }, + ); + EventConstants { + constants, + keys: self.locations_of(keys), + } + } + + /// The class constant named `constant` declared at `declaration`, when + /// a subscriber keys its map by it. Any other constant is a plain one. + pub(crate) fn symfony_event_constant_at( + &self, + declaration: &Location, + constant: &str, + ) -> Option { + self.event_constants(|key| key.constant == constant, |_, _, _| true) + .into_iter() + .find(|found| { + found.declaration.uri == declaration.uri + && found.declaration.range.start == declaration.range.start + }) + } + + /// The constants the keys `wanted` and `keep` let through name, each + /// resolved once however many subscriptions name it. `wanted` sees a + /// key before its class is loaded, `keep` with the value it holds. + fn event_constants( + &self, + wanted: impl Fn(&EventConstantKey) -> bool, + mut keep: impl FnMut(&str, &EventConstantKey, &str) -> bool, + ) -> Vec { + let mut named: HashMap<(Atom, Atom), (String, u32, String)> = HashMap::new(); + self.visit_event_constant_keys(wanted, |uri, key, value| { + if keep(uri, key, value) { + named + .entry((key.class, key.constant)) + .or_insert_with(|| (uri.to_string(), key.end, value.to_string())); + } + }); + let mut named: Vec<_> = named + .into_iter() + .map(|((_, name), (uri, end, event))| (uri, end, event, name)) + .collect(); + named.sort_unstable_by(|a, b| (&a.0, a.1).cmp(&(&b.0, b.1))); + let mut constants: Vec = Vec::new(); + for (uri, end, event, name) in named { + let use_offset = end.saturating_sub(1); + for declaration in self.event_constant_key_definition(&uri, use_offset) { + if constants + .iter() + .all(|known| known.declaration != declaration) + { + constants.push(EventConstant { + name, + declaration, + event: event.clone(), + use_site: (uri.clone(), use_offset), + }); + } + } + } + constants + } + + /// Where the constant a subscription's key names is declared: PHP's own + /// definition of the constant's name, at `offset` in it. + fn event_constant_key_definition(&self, uri: &str, offset: u32) -> Vec { + let Some(content) = self.get_file_content_arc(uri) else { + return Vec::new(); + }; + // Resolved for another file than the one a diagnostic pass may be + // checking on this thread, so neither of its caches may answer. + let _isolated = ( + crate::type_engine::variable::forward_walk::suspend_diagnostic_scope(), + crate::type_engine::resolver::with_isolated_chain_cache(), + ); + let position = crate::text_position::offset_to_position(&content, offset as usize); + self.resolve_definition(uri, &content, position) + } + + /// The locations of byte ranges in files, each file read once. + fn locations_of(&self, mut ranges: Vec<(String, u32, u32)>) -> Vec { + ranges.sort_unstable(); + let mut locations = Vec::with_capacity(ranges.len()); + for group in ranges.chunk_by(|a, b| a.0 == b.0) { + let uri = &group[0].0; + let (Some(parsed), Some(content)) = + (crate::util::parse_url(uri), self.get_file_content_arc(uri)) + else { + continue; + }; + let lines = crate::text_position::LineIndex::new(&content); + for (_, start, end) in group { + locations.push(Location { + uri: parsed.clone(), + range: Range { + start: lines.position(*start as usize), + end: lines.position(*end as usize), + }, + }); + } + } + locations + } +} diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 75aeae112..0ae1e7387 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -36,6 +36,7 @@ pub(crate) mod bundles; pub(crate) mod config_tree; pub(crate) mod container; +pub(crate) mod event_constants; pub(crate) mod form_options; pub(crate) mod forms; pub(crate) mod kernel; @@ -135,6 +136,10 @@ pub(crate) struct SymfonyState { vendor_form_classes: form_options::VendorFormClasses, /// Where the project's kernels keep their configuration. kernel_config: RwLock, + /// The names of the class constants subscribers key their maps by, + /// built when first asked for and dropped when a file that keys by one + /// changes. + event_constant_names: RwLock>>, } /// Whether `map` declares a service ID, a container parameter, a route, a @@ -249,6 +254,9 @@ impl Backend { self.note_drupal_theme_suggestions(map); self.note_container_declarations(uri, declares.then_some(map)); let was_declaring = self.symfony.declaring_uris.read().contains(uri); + if was_declaring || !map.event_constant_keys.is_empty() { + self.forget_event_constant_names(); + } // Only take the write lock when the answer changes. if was_declaring == declares { return; @@ -272,6 +280,28 @@ impl Backend { self.note_container_declarations(uri, None); if self.symfony.declaring_uris.read().contains(uri) { self.symfony.declaring_uris.write().remove(uri); + self.forget_event_constant_names(); + } + } + + /// The names of the class constants the project's subscribers key their + /// maps by, of any class. + pub(crate) fn event_constant_names(&self) -> std::sync::Arc { + if let Some(names) = self.symfony.event_constant_names.read().as_ref() { + return std::sync::Arc::clone(names); + } + let mut names = crate::atom::AtomSet::default(); + self.visit_declaring_symbol_maps(|_, map| { + names.extend(map.event_constant_keys.iter().map(|key| key.constant)); + }); + let names = std::sync::Arc::new(names); + *self.symfony.event_constant_names.write() = Some(std::sync::Arc::clone(&names)); + names + } + + fn forget_event_constant_names(&self) { + if self.symfony.event_constant_names.read().is_some() { + *self.symfony.event_constant_names.write() = None; } } @@ -392,6 +422,28 @@ impl Backend { listeners } + /// Visit every event subscription keyed by a class constant that holds + /// a plain string, among the ones `wanted` keeps: the file, the key, and + /// the event name it declares. Only the keys kept load their class. + pub(crate) fn visit_event_constant_keys( + &self, + wanted: impl Fn(&crate::symbol_map::EventConstantKey) -> bool, + mut visit: impl FnMut(&str, &crate::symbol_map::EventConstantKey, &str), + ) { + if !self.string_key_kind_enabled(StringKeyKind::SymfonyEvent) { + return; + } + let loader = self.resolved_class_loader_with(&[]); + let mut classes = EventConstantClasses::default(); + self.visit_declaring_symbol_maps(|uri, map| { + for key in map.event_constant_keys.iter().filter(|key| wanted(key)) { + if let Some(value) = self.event_constant_value(key, &loader, &mut classes) { + visit(uri, key, &value); + } + } + }); + } + /// The event name a subscription keyed by a class constant declares: /// the constant's value, when it is a plain string literal. The class /// is read with what it inherits, so a constant from a parent or an diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 0bca2be18..fe107b756 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -83,7 +83,6 @@ pub(crate) fn resolve_laravel_string_key( | StringKeyKind::TranslationDomain | StringKeyKind::TwigTemplate | StringKeyKind::SymfonyConfigKey - | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::SerializerGroup | StringKeyKind::YiiRoute @@ -92,6 +91,18 @@ pub(crate) fn resolve_laravel_string_key( | StringKeyKind::DrupalRouteParameter | StringKeyKind::DrupalPermission | StringKeyKind::DrupalLibrary => backend.symfony_declaration_locations(*kind, key), + // A constant a subscriber keys its map by defines the event name it + // holds, ahead of the subscriptions. + StringKeyKind::SymfonyEvent => { + let mut locations: Vec = backend + .symfony_event_constants_named(key) + .constants + .into_iter() + .map(|constant| constant.declaration) + .collect(); + locations.extend(backend.symfony_declaration_locations(*kind, key)); + locations + } StringKeyKind::DrupalHook => backend.drupal_hook_documentation(key), StringKeyKind::DrupalConfigName => backend.drupal_config_locations(key), StringKeyKind::DrupalConfigKey => backend.drupal_config_key_locations(key), diff --git a/tests/integration/drupal_events.rs b/tests/integration/drupal_events.rs index fb56552a3..14e2bc36e 100644 --- a/tests/integration/drupal_events.rs +++ b/tests/integration/drupal_events.rs @@ -363,21 +363,28 @@ async fn a_dispatched_event_goes_to_every_subscription_on_each_dispatcher_typing let order = uri_for(&dir, ORDER_PATH); let shop = uri_for(&dir, SHOP_SUBSCRIBER_PATH); - for (needle, uri, line) in [ + // A constant the subscription is keyed by comes first: the event's + // definition. + let opened = uri_for(&dir, "web/core/modules/shop/src/ShopEvents.php"); + let closed = uri_for(&dir, "web/core/modules/shop/src/ShopEventsInterface.php"); + for (needle, expected) in [ ( "'acme.order_placed'", - &order, - line_of(ORDER_SUBSCRIBER, "'acme.order_placed'"), + vec![(&order, line_of(ORDER_SUBSCRIBER, "'acme.order_placed'"))], ), ( "'shop.opened'", - &shop, - line_of(SHOP_SUBSCRIBER, "ShopEvents::OPENED"), + vec![ + (&opened, 3), + (&shop, line_of(SHOP_SUBSCRIBER, "ShopEvents::OPENED")), + ], ), ( "'shop.closed'", - &shop, - line_of(SHOP_SUBSCRIBER, "ShopEvents::CLOSED"), + vec![ + (&closed, 3), + (&shop, line_of(SHOP_SUBSCRIBER, "ShopEvents::CLOSED")), + ], ), ] { let found = definitions_at(&backend, &checkout, position_in(CHECKOUT, needle, 2)).await; @@ -385,7 +392,7 @@ async fn a_dispatched_event_goes_to_every_subscription_on_each_dispatcher_typing .iter() .map(|location| (&location.uri, location.range.start.line)) .collect(); - assert_eq!(found, vec![(uri, line)], "{needle}"); + assert_eq!(found, expected, "{needle}"); let position = position_in(CHECKOUT, needle, 2); let hover = hover_text_at(&backend, &checkout, position.line, position.character) @@ -418,6 +425,7 @@ async fn references_of_an_event_name_include_the_subscriptions_keyed_by_a_consta let mut expected = vec![ (checkout.clone(), line_of(CHECKOUT, "'shop.opened'")), (shop, line_of(SHOP_SUBSCRIBER, "ShopEvents::OPENED")), + (uri_for(&dir, "web/core/modules/shop/src/ShopEvents.php"), 3), ]; expected.sort(); assert_eq!(found, expected); @@ -474,3 +482,286 @@ async fn event_names_complete_in_each_dispatcher_call() { assert_eq!(labels, expected, "{needle}"); } } + +/// A module's events class, a subscriber keyed by it, and a dispatch of +/// the name its constant holds. +const ACME_EVENTS: &str = r#"dispatch($order, 'acme.order_shipped'); + \Drupal::service('event_dispatcher')->dispatch($order, 'acme.order_drafted'); + } +} +"#; + +const ACME_EVENTS_PATH: &str = "web/modules/custom/acme/src/Event/ShippingEvents.php"; +const SHIPPING_SUBSCRIBER_PATH: &str = + "web/modules/custom/acme/src/EventSubscriber/ShippingSubscriber.php"; +const SHIPPING_PATH: &str = "web/modules/custom/acme/src/Shipping.php"; + +#[tokio::test] +async fn an_event_constant_is_the_definition_of_its_event_name() { + let (backend, dir) = site(&[ + (CHECKOUT_PATH, CHECKOUT), + (ACME_EVENTS_PATH, ACME_EVENTS), + (SHIPPING_SUBSCRIBER_PATH, SHIPPING_SUBSCRIBER), + (SHIPPING_PATH, SHIPPING), + ]) + .await; + let checkout = uri_for(&dir, CHECKOUT_PATH); + let events = uri_for(&dir, ACME_EVENTS_PATH); + let subscriber = uri_for(&dir, SHIPPING_SUBSCRIBER_PATH); + let shipping = uri_for(&dir, SHIPPING_PATH); + let at = |locations: Vec| -> Vec<(Uri, u32)> { + locations + .into_iter() + .map(|location| (location.uri, location.range.start.line)) + .collect() + }; + + // The constant first, where core's interface declares it, then the + // subscription keyed by it. + let found = definitions_at( + &backend, + &checkout, + position_in(CHECKOUT, "'shop.closed'", 2), + ) + .await; + assert_eq!( + at(found), + vec![ + ( + uri_for(&dir, "web/core/modules/shop/src/ShopEventsInterface.php"), + 3 + ), + ( + uri_for(&dir, SHOP_SUBSCRIBER_PATH), + line_of(SHOP_SUBSCRIBER, "ShopEvents::CLOSED") + ), + ] + ); + + // One event, from the string, the constant, its use, and the string + // it is initialized with. + let mut everything = vec![ + (events.clone(), line_of(ACME_EVENTS, "SHIPPED =")), + ( + subscriber.clone(), + line_of(SHIPPING_SUBSCRIBER, "ShippingEvents::SHIPPED"), + ), + (shipping.clone(), line_of(SHIPPING, "'acme.order_shipped'")), + ]; + everything.sort(); + for (uri, content, needle, inside) in [ + (&shipping, SHIPPING, "'acme.order_shipped'", 2), + (&events, ACME_EVENTS, "SHIPPED =", 2), + ( + &subscriber, + SHIPPING_SUBSCRIBER, + "ShippingEvents::SHIPPED", + 17, + ), + (&events, ACME_EVENTS, "'acme.order_shipped'", 2), + ] { + let found = references_at(&backend, uri, position_in(content, needle, inside)).await; + assert_eq!(found, everything, "from {needle}"); + } + + // Just past the initializer's string is no longer on it. + let found = references_at( + &backend, + &events, + position_in(ACME_EVENTS, "'acme.order_shipped';", 20), + ) + .await; + assert!(found.is_empty(), "{found:?}"); + + // A constant no subscriber keys by is a plain constant. + let found = references_at(&backend, &events, position_in(ACME_EVENTS, "DRAFTED =", 2)).await; + assert_eq!( + found, + vec![(events.clone(), line_of(ACME_EVENTS, "DRAFTED ="))] + ); + let found = definitions_at( + &backend, + &shipping, + position_in(SHIPPING, "'acme.order_drafted'", 2), + ) + .await; + assert!(found.is_empty(), "{found:?}"); + + // Renaming the constant leaves the event strings alone. + let position = position_in(ACME_EVENTS, "SHIPPED =", 2); + let edit = rename(&backend, &events, position.line, position.character, "SENT") + .await + .expect("the rename should produce edits"); + let changes = edit.changes.expect("a change map"); + assert!(!changes.contains_key(&shipping), "{changes:?}"); +} + +/// An events class a Composer package installs, a module's subscriber +/// keyed by it, and a dispatch of the name its constant holds. +const VENDOR_FILES: &[(&str, &str)] = &[ + ( + "vendor/composer/installed.json", + r#"{ "packages": [ { "name": "acme/order-events", "install-path": "../acme/order-events", "autoload": { "psr-4": { "Acme\\OrderEvents\\": "src/" } } } ] }"#, + ), + ( + "vendor/acme/order-events/src/OrderEvents.php", + "dispatch($order, 'acme.order_settled'); + } +} +"#; + +const PAYMENT_PATH: &str = "web/modules/custom/acme/src/Payment.php"; + +/// A fresh site holding [`VENDOR_FILES`], none of them open, and the +/// dispatch open. +async fn vendor_site() -> (phpantom_lsp::Backend, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in STUBS.iter().chain(VENDOR_FILES) { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let (backend, dir) = workspace_in(dir, &[(PAYMENT_PATH, PAYMENT)]).await; + backend.ensure_workspace_indexed(); + (backend, dir) +} + +#[tokio::test] +async fn the_first_definition_of_an_event_name_finds_a_constant_composer_installs() { + let (backend, dir) = vendor_site().await; + let payment = uri_for(&dir, PAYMENT_PATH); + let found = definitions_at( + &backend, + &payment, + position_in(PAYMENT, "'acme.order_settled'", 2), + ) + .await; + let found: Vec<(Uri, u32)> = found + .into_iter() + .map(|location| (location.uri, location.range.start.line)) + .collect(); + assert_eq!( + found, + vec![ + ( + Uri::from_file_path( + dunce::canonicalize( + dir.path() + .join("vendor/acme/order-events/src/OrderEvents.php") + ) + .unwrap() + ) + .unwrap(), + 4 + ), + ( + uri_for( + &dir, + "web/modules/custom/acme/src/EventSubscriber/PaymentSubscriber.php" + ), + 7 + ), + ] + ); +} + +#[tokio::test] +async fn references_of_an_event_name_reach_a_constant_composer_installs() { + let (backend, dir) = vendor_site().await; + let payment = uri_for(&dir, PAYMENT_PATH); + let events = Uri::from_file_path( + dunce::canonicalize( + dir.path() + .join("vendor/acme/order-events/src/OrderEvents.php"), + ) + .unwrap(), + ) + .unwrap(); + let subscriber = uri_for( + &dir, + "web/modules/custom/acme/src/EventSubscriber/PaymentSubscriber.php", + ); + let dispatched = (payment.clone(), line_of(PAYMENT, "'acme.order_settled'")); + let position = position_in(PAYMENT, "'acme.order_settled'", 2); + + let found = references_at(&backend, &payment, position).await; + let mut expected = vec![(events, 4), (subscriber, 7), dispatched.clone()]; + expected.sort(); + assert_eq!(found, expected); + + // A subscription is a declaration of the event whatever its spelling, + // so without declarations only the dispatch is left. + let found = references_without_declaration(&backend, &payment, position).await; + assert_eq!(found, vec![dispatched]); +} + +/// [`references_at`] with the declaration left out. +async fn references_without_declaration( + backend: &phpantom_lsp::Backend, + uri: &Uri, + position: Position, +) -> Vec<(Uri, u32)> { + use tower_lsp_server::LanguageServer; + let mut found: Vec<(Uri, u32)> = backend + .references(ReferenceParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: false, + }, + }) + .await + .unwrap() + .unwrap_or_default() + .into_iter() + .map(|location| (location.uri, location.range.start.line)) + .collect(); + found.sort(); + found +} diff --git a/tests/integration/symfony_events.rs b/tests/integration/symfony_events.rs index f0db8912e..0da0177f6 100644 --- a/tests/integration/symfony_events.rs +++ b/tests/integration/symfony_events.rs @@ -182,6 +182,16 @@ async fn definitions(backend: &Backend, uri: &Uri, position: Position) -> Vec<(U } async fn references(backend: &Backend, uri: &Uri, position: Position) -> Vec<(Uri, u32)> { + references_with(backend, uri, position, true).await +} + +/// [`references`], with the declaration included or not. +async fn references_with( + backend: &Backend, + uri: &Uri, + position: Position, + include_declaration: bool, +) -> Vec<(Uri, u32)> { let mut found: Vec<(Uri, u32)> = backend .references(ReferenceParams { text_document_position_params: TextDocumentPositionParams { @@ -191,7 +201,7 @@ async fn references(backend: &Backend, uri: &Uri, position: Position) -> Vec<(Ur work_done_progress_params: WorkDoneProgressParams::default(), partial_result_params: PartialResultParams::default(), context: ReferenceContext { - include_declaration: true, + include_declaration, }, }) .await @@ -204,6 +214,35 @@ async fn references(backend: &Backend, uri: &Uri, position: Position) -> Vec<(Ur found } +/// The definitions of the name at `position`, in the order given. +async fn definitions_in_order(backend: &Backend, uri: &Uri, position: Position) -> Vec<(Uri, u32)> { + let response = backend + .goto_definition(DefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .unwrap(); + match response { + None => Vec::new(), + Some(DefinitionResponse::Definition(Definition::Location(location))) => vec![location], + Some(DefinitionResponse::Definition(Definition::LocationList(locations))) => locations, + Some(DefinitionResponse::DefinitionLinkList(_)) => panic!("unexpected location links"), + } + .into_iter() + .map(|location| (location.uri, location.range.start.line)) + .collect() +} + +/// The line `needle` first appears on. +fn line_of(content: &str, needle: &str) -> u32 { + position_in(content, needle, 0).line +} + fn sorted(mut locations: Vec<(Uri, u32)>) -> Vec<(Uri, u32)> { locations.sort(); locations @@ -426,6 +465,7 @@ final class OrderEvents extends BaseEvents implements StoreEvents public const JOINED = 'app.order.' . 'joined'; public const ALIAS = self::PLACED; public const PRICED = "app.order.\$priced"; + public const DRAFTED = 'app.order.drafted'; } "#; @@ -535,7 +575,11 @@ async fn a_subscription_keyed_by_a_constant_declares_its_value() { position_in(DISPATCHER_PHP, "'app.order.placed'", 3), ) .await; - assert_eq!(found, vec![(subscriber.clone(), 10)]); + let events = uri_for(&dir, "src/Event/OrderEvents.php"); + assert_eq!( + found, + sorted(vec![(events.clone(), 5), (subscriber.clone(), 10)]) + ); let position = position_in(DISPATCHER_PHP, "'app.order.placed'", 3); let hover = @@ -545,7 +589,7 @@ async fn a_subscription_keyed_by_a_constant_declares_its_value() { assert!(hover.contains("1 listener in the project"), "{hover}"); // The subscription the constant declares is among the references, as - // it is among the definitions. + // it is among the definitions, with the constant and its other use. let found = references( &backend, &checkout, @@ -554,7 +598,12 @@ async fn a_subscription_keyed_by_a_constant_declares_its_value() { .await; assert_eq!( found, - sorted(vec![(checkout.clone(), 11), (subscriber.clone(), 10)]) + sorted(vec![ + (checkout.clone(), 11), + (events.clone(), 5), + (events.clone(), 7), + (subscriber.clone(), 10) + ]) ); // The value is read when the names are asked for. @@ -586,6 +635,234 @@ async fn a_subscription_keyed_by_a_constant_declares_its_value() { ); } +#[tokio::test] +async fn an_event_constant_is_the_definition_of_its_event_name() { + // A constant no subscriber keys by, and a dispatch of the name it holds. + let drafts = r#"dispatch($order, 'app.order.drafted'); + $events->dispatch($order, OrderEvents::DRAFTED); +} +"#; + let (backend, dir) = workspace(&[ + ("src/Event/OrderEvents.php", ORDER_EVENTS_PHP), + ("src/Event/BaseEvents.php", BASE_EVENTS_PHP), + ("src/Event/StoreEvents.php", STORE_EVENTS_PHP), + ("src/Event/PlaceOrder.php", PLACE_ORDER_PHP), + ( + "src/EventSubscriber/StoreSubscriber.php", + CONSTANT_SUBSCRIBER_PHP, + ), + ("src/Service/Checkout.php", DISPATCHER_PHP), + ("src/Service/draft.php", drafts), + ]) + .await; + let events = uri_for(&dir, "src/Event/OrderEvents.php"); + let base = uri_for(&dir, "src/Event/BaseEvents.php"); + let subscriber = uri_for(&dir, "src/EventSubscriber/StoreSubscriber.php"); + let checkout = uri_for(&dir, "src/Service/Checkout.php"); + let draft = uri_for(&dir, "src/Service/draft.php"); + let declared = (events.clone(), line_of(ORDER_EVENTS_PHP, "PLACED =")); + let key = ( + subscriber.clone(), + line_of(CONSTANT_SUBSCRIBER_PHP, "OrderEvents::PLACED"), + ); + let dispatched = ( + checkout.clone(), + line_of(DISPATCHER_PHP, "'app.order.placed'"), + ); + let aliased = (events.clone(), line_of(ORDER_EVENTS_PHP, "self::PLACED")); + + // The constant first, then every subscription; a constant inherited + // from a parent is declared there. + let found = definitions_in_order( + &backend, + &checkout, + position_in(DISPATCHER_PHP, "'app.order.placed'", 3), + ) + .await; + assert_eq!(found, vec![declared.clone(), key.clone()]); + let closed = ( + subscriber.clone(), + line_of(CONSTANT_SUBSCRIBER_PHP, "OrderEvents::CLOSED"), + ); + let announce = DISPATCH_ANY_PHP.replace("''", "'app.order.closed'"); + let announce_uri = uri_for(&dir, "src/Service/announce.php"); + open_document(&backend, &announce_uri, "php", &announce).await; + let found = definitions_in_order( + &backend, + &announce_uri, + position_in(&announce, "'app.order.closed'", 3), + ) + .await; + assert_eq!( + found, + vec![(base.clone(), line_of(BASE_EVENTS_PHP, "CLOSED =")), closed] + ); + + // The same event from the string, the constant's declaration, its + // use as a key, and the string it is initialized with. + let everything = sorted(vec![ + declared.clone(), + aliased.clone(), + key.clone(), + dispatched.clone(), + ]); + for (uri, content, needle, inside) in [ + (&checkout, DISPATCHER_PHP, "'app.order.placed'", 3), + (&events, ORDER_EVENTS_PHP, "PLACED =", 2), + ( + &subscriber, + CONSTANT_SUBSCRIBER_PHP, + "OrderEvents::PLACED", + 15, + ), + (&events, ORDER_EVENTS_PHP, "'app.order.placed'", 3), + ] { + let found = references(&backend, uri, position_in(content, needle, inside)).await; + assert_eq!(found, everything, "from {needle} in {}", uri.as_str()); + } + // Without declarations, the constant's own and the subscription, + // which declares the event whatever its spelling, are left out; the + // constant's other uses stay. + let found = references_with( + &backend, + &checkout, + position_in(DISPATCHER_PHP, "'app.order.placed'", 3), + false, + ) + .await; + assert_eq!(found, sorted(vec![aliased, dispatched])); + + // Definition on the constant's name stays PHP's own. + let found = definitions_in_order( + &backend, + &subscriber, + position_in(CONSTANT_SUBSCRIBER_PHP, "OrderEvents::PLACED", 15), + ) + .await; + assert_eq!(found, vec![declared]); + + // Renaming the constant leaves the event strings alone. + let position = position_in(ORDER_EVENTS_PHP, "PLACED =", 2); + let edit = crate::common::rename( + &backend, + &events, + position.line, + position.character, + "SUBMITTED", + ) + .await + .expect("the rename should produce edits"); + let changes = edit.changes.expect("a change map"); + assert!(!changes.contains_key(&checkout), "{changes:?}"); + assert!( + changes + .values() + .flatten() + .all(|edit| edit.new_text == "SUBMITTED"), + "{changes:?}" + ); + + // A constant no subscriber keys by is a plain constant. + let drafted = (events.clone(), line_of(ORDER_EVENTS_PHP, "DRAFTED =")); + let found = references( + &backend, + &events, + position_in(ORDER_EVENTS_PHP, "DRAFTED =", 2), + ) + .await; + assert_eq!( + found, + sorted(vec![ + drafted, + (draft.clone(), line_of(drafts, "OrderEvents::DRAFTED")) + ]) + ); + let found = definitions_in_order( + &backend, + &draft, + position_in(drafts, "'app.order.drafted'", 3), + ) + .await; + assert!(found.is_empty(), "{found:?}"); +} + +/// Where `rel` is on disk, spelled the way the index stores it. +fn canonical_uri(dir: &tempfile::TempDir, rel: &str) -> Uri { + Uri::from_file_path(dunce::canonicalize(dir.path().join(rel)).unwrap()).unwrap() +} + +#[tokio::test] +async fn the_first_definition_of_an_event_name_loads_a_constant_composer_installs() { + let vendored = r#" 'onPlaced']; + } + + public function onPlaced(object $event): void {} +} +"#; + let installed = r#"{ "packages": [ { "name": "acme/order-events", "install-path": "../acme/order-events", "autoload": { "psr-4": { "Acme\\OrderEvents\\": "src/" } } } ] }"#; + let all: Vec<(&str, &str)> = STUBS + .iter() + .copied() + .chain([ + ("vendor/composer/installed.json", installed), + ("vendor/acme/order-events/src/OrderEvents.php", vendored), + ("src/EventSubscriber/VendorSubscriber.php", subscriber), + ("src/Service/Checkout.php", DISPATCHER_PHP), + ]) + .collect(); + let (backend, dir) = create_psr4_workspace(COMPOSER, &all); + // `initialized` reads the packages Composer installed. + backend.initialized(InitializedParams {}).await; + for (path, content) in [ + ("src/EventSubscriber/VendorSubscriber.php", subscriber), + ("src/Service/Checkout.php", DISPATCHER_PHP), + ] { + open_document(&backend, &uri_for(&dir, path), "php", content).await; + } + let checkout = uri_for(&dir, "src/Service/Checkout.php"); + let found = definitions_in_order( + &backend, + &checkout, + position_in(DISPATCHER_PHP, "'app.order.placed'", 3), + ) + .await; + assert_eq!( + found, + vec![ + ( + canonical_uri(&dir, "vendor/acme/order-events/src/OrderEvents.php"), + 5 + ), + (uri_for(&dir, "src/EventSubscriber/VendorSubscriber.php"), 9), + ] + ); +} + #[tokio::test] async fn hover_counts_each_listener_whatever_the_map_shape() { let listed = r#" Date: Wed, 11 Nov 2026 15:31:30 +0100 Subject: [PATCH 6/6] Log Drupal event subscribers and file listener method checks Assisted-by: claude-code/claude-opus-5-5 --- docs/ARCHITECTURE.md | 1 + docs/CHANGELOG.md | 2 ++ docs/todo.md | 2 +- docs/todo/drupal.md | 52 ++++++++++++++++++++------------------- docs/todo/lsp-features.md | 32 +++++++++++++----------- 5 files changed, 48 insertions(+), 41 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cdd6fcc5c..bc64a05fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -256,6 +256,7 @@ The symbol map also stores: - **Yii Active Record relations** (`yii_relations`): each relation a record's `relation()` family or a query's `with()` and `joinWith()` names, one per segment of a path (`'author.profile'`), with the call's receiver and the segments before it (`symbol_map/extraction/yii_active_record.rs`). A relation is not a member of the record, so it is kept beside the spans: the parser reads the arms of a record's `relationQuery()` onto the method (`MethodExtras::record_relations`), and `yii/active_record.rs` lists them with the `get{Name}Query()` getters of a record that uses `MagicRelationsTrait`. An attribute the record's `get()` family names is an ordinary `MemberAccess` span on the receiver (`DocblockMemberRef::YiiAttribute`), so the member machinery navigates, renames, and checks it on the records among the receiver's classes. A record that declares no properties reads its attributes from its table's columns instead: `yii/migrations.rs` applies the `up()` methods of the migrations the `yiisoft/db-migration` params point at, in file name order, into a schema cached on the backend, and the parser keeps a `tableName()` that returns a literal on the method (`MethodExtras::record_table`). The record a query is for comes from the type engine: a third-party patch makes the package's queries generic over their model (`stub_patches/active_record.rs`). - **Yii queries** (`yii_queries`, `yii_query_names`): each `yiisoft/db` query chain that names tables or columns, read whole from its outermost call (`symbol_map/extraction/yii_queries.rs`), with the receiver of its first such call and the tables its `from()` and joins name, aliases included, plus those the earlier chains on the same variable in the same function named, and each table and column name it writes as a string, keyed to its chain. The extractor cannot see types, so `yii/queries.rs` resolves the receiver, keeps only a query, adds the table of the record an Active Record query is for, and reads the names against the schema `yii/migrations.rs` builds. A table named by a class constant (`Table::POST . ' p'`) is kept as the constant and read from the class when the chain is resolved; the migrations read theirs the same way, and the schema is forgotten when the class's file changes. - **Messenger handlers** (`messenger_handlers`): each method a Symfony `#[AsMessageHandler]` registers, with the message class it receives, read from the attribute's `handles:` or the method's first parameter type. A handler is a fact about a class rather than a string at a cursor, so it is a side table rather than a span. The files holding one are among the Symfony declaring files (`symfony/mod.rs`), which is how the CodeLens on a message class finds the handlers that receive it. +- **Event subscriptions keyed by a constant** (`event_constant_keys`, `event_listener_lists`): each key of a subscriber's `getSubscribedEvents()` map written as a class constant (`KernelEvents::REQUEST`), with the class resolved at extraction and the constant's name. The event name is the constant's value, which only loading the class gives, so the Symfony declaration lookup (`symfony/mod.rs`) reads it, inherited constants included, when names are asked for, and the reference index never does. Beside them, the subscriptions whose value lists other than one listener are kept by where their key starts, for the listener count hover shows. ### Tier 2: Stored Byte Offsets (cross-file jumps) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 57e8c44cb..54c13b59b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -108,6 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Symfony services and parameters are typed.** In a Symfony project, `$container->get('app.mailer')` is the class the service is declared with, through any aliases, and `getParameter('app.sender')` has the type of the value the parameter is declared with. A query bus's `HandleTrait::handle()` returns what the message's handler returns. - **Decorated services.** A service that decorates another, in a Symfony services file, a Drupal `*.services.yml`, or a PHP configurator, takes over its ID, so a lookup of that ID is typed as the decorator's class as well as its own. The ID a YAML `decorates:` names jumps to its declaration and counts among its references. - **Methods a service definition calls.** A method named under a service's `calls:` in a Symfony services file or a Drupal `*.services.yml`, or in XML's ``, jumps to its declaration, completes from the public methods of the service's class, and counts among the method's references. +- **Event subscriber maps built in code or keyed by constants.** A subscriber's `getSubscribedEvents()` is read when it builds its map by assignment (`$events[KernelEvents::REQUEST][] = ['onRequest', 10];`) as well as when it returns one, and a key written as a class constant declares the event name the constant holds, which makes the constant that event's definition for navigation and Find References. Contributed by @giuseppe-esposito-87. #### Doctrine @@ -149,6 +150,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Drupal annotations without imports.** In a Drupal project, an annotation on a plugin class that no `use` imports, such as `@Block` or `@Translation`, jumps to its class, shows it on hover, and is found by Find References, wherever Drupal's plugin discovery would find it. - **Controller methods of a Drupal service.** In a routing file, the method of a `_controller` or `_title_callback` written as `service:method` jumps to the method of the service's class, completes from its public methods, and is listed among the method's references. - **Drupal form and render callbacks.** In a Drupal project, a callback named in a form or render array, such as a `#submit`, `#validate`, `#pre_render`, or `#ajax` callback, jumps to its method or function, completes, shows it on hover, is renamed with it, and lists its references. A callback that names nothing, or that cannot be called, is reported. Contributed by @giuseppe-esposito-87. +- **Drupal event subscribers.** In a Drupal project, event subscribers are read as they are in Symfony: the methods a subscriber's map names navigate and are checked, and an event name passed to the event dispatcher completes and leads to the constant that names it, if any, and to every subscription that listens to it. Contributed by @giuseppe-esposito-87. #### WordPress diff --git a/docs/todo.md b/docs/todo.md index 7f304213a..a6221bce7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -133,7 +133,6 @@ ships, remove every row that names it. | DR13 | [Module names and `*.info.yml`](todo/drupal.md#dr13-module-names-and-infoyml) | Low-Medium | Low-Medium | | DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium | | DR4 | [Entity field access](todo/drupal.md#dr4-entity-field-access) | Medium-High | High | -| DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium | | DR18 | [Warn on unknown entity type IDs](todo/drupal.md#dr18-warn-on-unknown-entity-type-ids) | Medium | Low-Medium | | DR19 | [Warn on unknown hook names](todo/drupal.md#dr19-warn-on-unknown-hook-names) | Low-Medium | Medium | | DR20 | [Complete route names built in code](todo/drupal.md#dr20-complete-route-names-built-in-code) | Low-Medium | Medium | @@ -148,6 +147,7 @@ ships, remove every row that names it. | DR34 | [Callback parameter checks](todo/drupal.md#dr34-callback-parameter-checks) | Low | Low-Medium | | DR35 | [Invokable classes and services as callbacks](todo/drupal.md#dr35-invokable-classes-and-services-as-callbacks) | Low | Low-Medium | | DR36 | [Array callables that name their class in a string](todo/drupal.md#dr36-array-callables-that-name-their-class-in-a-string) | Low | Low | +| DR37 | [Listener methods in subscriber maps](todo/drupal.md#dr37-listener-methods-in-subscriber-maps) | Low-Medium | Low-Medium | ## Yii diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 95007cc21..9eebd1aea 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -12,8 +12,9 @@ the plugins of each, the permissions the `*.permissions.yml` files declare, the configuration objects and keys the default configuration and the configuration schema declare, the theme hooks `hook_theme()` implementations declare, the render elements element -classes declare, the libraries the `*.libraries.yml` files declare, and -the callbacks form and render arrays name are read. Nothing reads fields yet. +classes declare, the libraries the `*.libraries.yml` files declare, the +callbacks form and render arrays name, and the event names and listener +methods event subscribers declare are read. Nothing reads fields yet. The requests in [#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the @@ -128,29 +129,6 @@ in the README as not ported. --- -## DR16. Event subscribers - -**Impact: Medium · Complexity: Low-Medium** - -```php -final class AcmeSubscriber implements EventSubscriberInterface { - public static function getSubscribedEvents(): array { - return [KernelEvents::REQUEST => 'onRequest']; - } -} -``` - -Drupal's event subscribers are Symfony's: a service tagged -`event_subscriber` maps event names to its own methods in -`getSubscribedEvents()`. The Symfony support reads that map, jumps from -a listener name to its method, and reports one that does not exist, -but only in a Symfony project, and a Drupal site's `composer.json` -requires none of the packages that make it one. Read the map in a -Drupal site too, so the listener methods navigate and are checked, and -the event names complete where Drupal's event dispatcher takes one. - ---- - ## DR18. Warn on unknown entity type IDs **Impact: Medium · Complexity: Low-Medium** @@ -428,3 +406,27 @@ An array callable whose class is a string rather than `OrderForm::class` names no method, so it neither navigates nor renames, and a method the class lacks is not reported. Read the string as the fully qualified class name PHP reads it as, wherever an array callable is read. + +--- + +## DR37. Listener methods in subscriber maps + +**Impact: Low-Medium · Complexity: Low-Medium** + +```php +public static function getSubscribedEvents(): array { + $events[KernelEvents::REQUEST][] = ['onRequest', 100]; + return $events; +} + +private function onRequest(RequestEvent $event): void {} +``` + +A subscriber's `getSubscribedEvents()` names its listener methods as +strings, which jump to the method, follow a rename, and are reported +when the class has no such method. Complete the subscriber's public +methods inside a map value, the way a services file's `calls:` completes +the service's methods. Report a listener method that is private or +protected too: the event dispatcher calls it from outside the class, so +it fails at run time. Symfony projects read the same maps, so this +applies to them as well. diff --git a/docs/todo/lsp-features.md b/docs/todo/lsp-features.md index 6763d6e94..0adc10e2e 100644 --- a/docs/todo/lsp-features.md +++ b/docs/todo/lsp-features.md @@ -801,31 +801,33 @@ PHPStan's Symfony extension (`Rules/Symfony/Undefined*Rule`, ```php $dispatcher->dispatch(new OrderPlaced($order)); // which listeners? +$dispatcher->dispatch($event, KernelEvents::REQUEST); #[AsEventListener] public function onOrderPlaced(OrderPlaced $event): void {} public static function getSubscribedEvents(): array { - return [KernelEvents::REQUEST => 'onRequest']; + return [OrderPlaced::class => 'onOrderPlaced']; } ``` An event name written as a string navigates between its dispatchers and -its listeners. Most events are not: an event dispatched without a name is -named by its class, an `#[AsEventListener]` without an `event:` subscribes -to the class its method's first parameter is typed with, and a -subscriber's map is usually keyed by `OrderPlaced::class` or a constant -such as `KernelEvents::REQUEST`. None of these link a dispatch to its -listeners. Symfony's own language server shows a CodeLens over an event -class listing its listeners, and one over a listener listing its events, -the way the Messenger lenses link a message and its handlers. - -A constant key resolves to the string it holds, which also gives -completion the framework's own events (`kernel.request`, -`console.command`, `security.interactive_login`): only the names a -project's listeners subscribe to are offered today, since nothing the -installed bundles declare is read. +its listeners, and so does a subscription keyed by a constant that holds +one (`KernelEvents::REQUEST`). Many events are named another way: an +event dispatched without a name is named by its class, an +`#[AsEventListener]` without an `event:` subscribes to the class its +method's first parameter is typed with, and a subscriber's map may be +keyed by `OrderPlaced::class`. None of these, nor a `dispatch()` that +names its event by a constant, link a dispatch to its listeners. +Symfony's own language server shows a CodeLens over an event class +listing its listeners, and one over a listener listing its events, the +way the Messenger lenses link a message and its handlers. + +Only the event names a project's own subscribers and listeners declare +complete: the framework's events (`kernel.request`, `console.command`, +`security.interactive_login`) are declared by the installed bundles' +subscribers, which are not read. **Where to look:** `src/symbol_map/extraction/symfony.rs` (`emit_symfony_listener_spans`), `src/symfony/messenger.rs` (the handler