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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions benches/drupal_completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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" },
Expand All @@ -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",
Expand Down Expand Up @@ -90,6 +93,10 @@ const STUBS: &[(&str, &str)] = &[
"web/core/lib/Drupal/Core/Hook/Attribute/Hook.php",
"<?php\nnamespace Drupal\\Core\\Hook\\Attribute;\n#[\\Attribute]\nclass Hook {\n public function __construct(public string $hook, public string $method = '', public ?string $module = NULL) {}\n}\n",
),
(
"web/core/lib/Symfony/Contracts/EventDispatcher/EventDispatcherInterface.php",
"<?php\nnamespace Symfony\\Contracts\\EventDispatcher;\ninterface EventDispatcherInterface {\n public function dispatch(object $event, ?string $eventName = null): object;\n}\n",
),
];

const CONSUMER_PATH: &str = "web/modules/custom/acme/src/Consumer.php";
Expand All @@ -103,6 +110,7 @@ use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final class Consumer
{
Expand All @@ -111,6 +119,7 @@ final class Consumer
private ModuleHandlerInterface $modules,
private AccountInterface $account,
private BlockManagerInterface $blocks,
private EventDispatcherInterface $events,
) {}

#[Hook('mod1_')]
Expand All @@ -128,6 +137,7 @@ final class Consumer
$build = ['#attached' => ['library' => ['mod1/']]];
$build = ['#submit' => ['mod1_']];
$build = ['#pre_render' => ['Drupal\acme\Consumer::r']];
$this->events->dispatch($this, 'mod1.');
}
}

Expand All @@ -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();

Expand Down Expand Up @@ -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!("<?php\nnamespace Drupal\\mod{m}\\Event;\n\nfinal class Events {{\n");
let mut subscriber = format!(
"<?php\nnamespace Drupal\\mod{m}\\EventSubscriber;\n\nuse Drupal\\mod{m}\\Event\\Events;\n\nfinal class Subscriber {{\n public static function getSubscribedEvents(): array {{\n $events['mod{m}.custom'][] = 'onCustom';\n"
);
let mut listeners =
String::from("\n public function onCustom(object $event): void {}\n");
for e in 0..EVENTS {
let _ = writeln!(events, " public const EVENT{e} = 'mod{m}.event{e}';");
let _ = writeln!(
subscriber,
" $events[Events::EVENT{e}][] = ['onEvent{e}', {e}];"
);
let _ = writeln!(
listeners,
" public function onEvent{e}(object $event): void {{}}"
);
}
events.push_str("}\n");
subscriber.push_str(" return $events;\n }\n");
subscriber.push_str(&listeners);
subscriber.push_str("}\n");
files.push((format!("{dir}/src/Event/Events.php"), events));
files.push((
format!("{dir}/src/EventSubscriber/Subscriber.php"),
subscriber,
));

if m > 0 {
let previous = m - 1;
let mut module = String::from("<?php\n");
Expand Down Expand Up @@ -463,6 +503,13 @@ fn bench_drupal_name_completion(c: &mut Criterion) {
"['#pre_render' => ['Drupal\\acme\\Consumer::r'",
"run",
),
(
"event_name",
&consumer,
CONSUMER_PHP,
"dispatch($this, 'mod1.'",
"mod1.event3",
),
(
"hook_implementation",
&module,
Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<call method="…">`, 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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
52 changes: 27 additions & 25 deletions docs/todo/drupal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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.
32 changes: 17 additions & 15 deletions docs/todo/lsp-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading