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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Doctrine entities and repositories link to each other.** In a Doctrine project, a CodeLens on an entity opens its repository, and one on a repository opens its entity, found through the YAML or XML mapping or the usual naming convention. Contributed by @sidux.
- **Symfony service IDs and container parameters.** In a Symfony project, a service ID or parameter declared in YAML, XML, or a PHP configurator completes, jumps to its declaration, and lists its references wherever configuration or code names it, from `@app.mailer` and `%app.sender%` to `$container->get()` and `#[Autowire]`. An application's own `app.` name that nothing declares is reported. Contributed by @sidux.
- **Symfony services that bundles and the container builder declare.** The service IDs and parameters an installed bundle declares in its own configuration complete, jump to their declaration, and type `$container->get()`, and so do the ones a kernel, an extension, or a compiler pass declares with `register()`, `setAlias()`, `setDefinition()`, `setParameter()`, or `getKernelParameters()`. A parameter set under a name built at runtime, like `'app.' . $key`, leaves the names it can produce unreported. A configurator's `parent()` and the `%name%` placeholders in its arguments are read as the service and parameter they name.
- **Drupal services.** In a Drupal project, the service IDs and container parameters that core, contributed, and custom modules declare in their `*.services.yml` complete, jump to their declaration, and list their references wherever a services file, `$container->get()`, or `\Drupal::service()` names them, and a lookup is typed by the class the service is built from, or by the class or interface a lookup names by `::class`. Rename leaves core and contributed extensions alone, as it does vendor code. Contributed by @giuseppe-esposito-87.
- **Symfony route names.** In a Symfony project, a route name declared by a `#[Route]` attribute, the routing YAML or XML, or a PHP routing configurator completes, jumps to its declaration, and lists its references wherever a controller or a Twig template builds a URL to it, from `generateUrl()` and `redirectToRoute()` to `path()` and `url()`. Names are followed through the imports that load them, bundles' own routing files included, along with the ones Symfony derives from a route, such as `contact.en` for a localized one. A route name nothing declares is reported, unless the router is configured to load its routes some other way. Contributed by @sidux.
- **Symfony translations.** In a Symfony project, a translation key declared in a YAML or XLIFF catalogue completes, jumps to its entry in every locale, and lists its references wherever PHP or a Twig template translates it, from `trans()`, `TranslatableMessage`, and `t()` to the `trans` filter. A key is looked up in the domain the call names, and a domain jumps to its catalogues. A key spelled like one (`demo.title`) that nothing declares is reported, in a domain the project's own catalogues hold and no installed bundle ships. Contributed by @sidux.
- **Symfony Twig templates.** In a Symfony project, a template name completes, jumps to its file, and lists its references wherever a controller, an email, or another template names it, from `render()` and `#[Template]` to `extends` and `include`. A template the application's `templates/` directory does not hold is reported, with a quick fix that creates it, unless the project has a Twig loader of its own that could serve it. Contributed by @sidux.
Expand Down
3 changes: 1 addition & 2 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ contributor even though it's short.
| # | Item | Impact | Complexity |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- |
| DR9 | [Drupal directories outside `contrib` and `custom`](todo/drupal.md#dr9-drupal-directories-outside-contrib-and-custom) | Medium-High | Low-Medium |
| DR2 | [Service IDs from `*.services.yml`](todo/drupal.md#dr2-service-ids-from-servicesyml) | High | Medium |
| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium |
| DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium |
| DR8 | [Permission and configuration names](todo/drupal.md#dr8-permission-and-configuration-names) | Low-Medium | Medium |
Expand Down Expand Up @@ -128,7 +127,6 @@ ships, remove every row that names it.
| DR11 | [Form and render callbacks](todo/drupal.md#dr11-form-and-render-callbacks) | Low-Medium | Low-Medium |
| DR12 | [Translation placeholders](todo/drupal.md#dr12-translation-placeholders) | Low-Medium | Low-Medium |
| DR13 | [Module names and `*.info.yml`](todo/drupal.md#dr13-module-names-and-infoyml) | Low-Medium | Low-Medium |
| DR2 | [Service IDs from `*.services.yml`](todo/drupal.md#dr2-service-ids-from-servicesyml) | High | Medium |
| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium |
| DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium |
| DR10 | [Plugin IDs](todo/drupal.md#dr10-plugin-ids) | Medium | Medium |
Expand All @@ -139,6 +137,7 @@ ships, remove every row that names it.
| DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High |
| DR4 | [Entity field access](todo/drupal.md#dr4-entity-field-access) (depends on DR3) | Medium-High | High |
| DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium |
| DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium |
| DR21 | [Return types core's docblocks leave wide](todo/drupal.md#dr21-return-types-cores-docblocks-leave-wide) | Medium | Low-Medium |

## Yii
Expand Down
42 changes: 42 additions & 0 deletions docs/todo/bugs.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,45 @@ the body binds `new static()` to the declaring class instead of keeping
it `static`, so a call through a subclass gives the parent. Declaring
`@return static` on the method or on an interface it implements gives
the right type.

### B732. `#[\AllowDynamicProperties]` does not allow dynamic properties

**Impact: Medium · Complexity: Low**

```php
#[\AllowDynamicProperties]
class Extension {}

function f(Extension $extension): int {
return $extension->sort; // Property 'sort' not found on class 'Extension'
}
```

A class marked `#[\AllowDynamicProperties]` (or one extending such a
class) takes any property, so reading or writing one it does not declare
is no unknown member. The attribute is only read by class-name
completion today. PHPStan's `ClassReflection::allowsDynamicProperties()`
walks the parent chain for the attribute. Drupal core's `Extension` is
the common case: `$module_data[$name]->sort` and `->requires` are
reported about thirty times on core once its services are typed.

### B733. A Drupal service a `ServiceProvider` alters keeps the class its YAML names

**Impact: Medium · Complexity: Medium**

```php
// core.services.yml: language_manager: { class: Drupal\Core\Language\LanguageManager }
// language module: LanguageServiceProvider::alter() calls
// $container->getDefinition('language_manager')->setClass(ConfigurableLanguageManager::class)
\Drupal::service('language_manager')->getLanguageConfigOverride('fr', 'system.site');
// Method 'getLanguageConfigOverride' not found on class 'Drupal\Core\Language\LanguageManager'
```

A module's `<Module>ServiceProvider` class can replace the class of a
service another module declares, and the service is then built from the
replacement whenever that module is enabled. `\Drupal::service()` and
`$container->get()` are typed by the YAML class alone, so a call that
only the replacement declares is reported: about ninety times on core,
all for `language_manager`. Read the literal `setClass()` calls in
`ServiceProvider::alter()` methods and type the service as the union of
the declared class and every replacement.
66 changes: 21 additions & 45 deletions docs/todo/drupal.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# PHPantom — Drupal

Known gaps and missing features in PHPantom's Drupal support. Today
Drupal support is project discovery only: the web root is detected, and
core, contrib, and the Drupal file extensions (`.module`, `.install`,
`.theme`, …) are indexed past `.gitignore`. Nothing reads Drupal's own
declarations: services, entity types, fields, routes, or hooks.
Known gaps and missing features in PHPantom's Drupal support. The web
root is detected, core, contrib, and the Drupal file extensions
(`.module`, `.install`, `.theme`, …) are indexed past `.gitignore`, and
the services the `*.services.yml` files declare are read. Nothing reads
Drupal's other declarations yet: entity types, fields, routes, or hooks.

The requests in
[#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the
Expand All @@ -28,46 +28,6 @@ within the same impact tier.

---

## DR2. Service IDs from `*.services.yml`

**Impact: High · Complexity: Medium**

```php
$manager = \Drupal::service('acme.order_manager');

public static function create(ContainerInterface $container): static {
return new static($container->get('entity_type.manager'));
}
```

A module declares its services in `<module>.services.yml`, in Symfony's
service format. Type `\Drupal::service()` and `$container->get()` by the
class the declaration names, and complete and navigate the service IDs,
including `@service_id` arguments in the YAML. Newer code registers a
service under its interface and fetches it with
`\Drupal::service(OrderManagerInterface::class)`; that ID is a class
name and types as that class without any lookup.

The Symfony container reading already parses this format and follows
aliases and parameter placeholders. Start by checking what it picks up from a
Drupal module unchanged, then teach it where Drupal keeps the files
(every enabled module's and core's `*.services.yml`) instead of
`config/services.yaml`.

Most of Drupal's navigation depends on this. On Drupal core, Find
References on `EntityStorageInterface::load()` misses about 240 calls
of the shape
`$this->container->get('entity_type.manager')->getStorage('node')->load(1)`.
They are the bulk of what a tool that types service lookups finds and
we don't.

**Where to look:** `src/symfony/container.rs`, `src/symfony/resources.rs`,
PHPStan's Drupal extension (its service map), and PHPlumb
(`crates/phplumb-index/src/frameworks/drupal/services.rs`, MIT), which
reads the same files.

---

## DR3. Entity storage and entity type IDs

**Impact: High · Complexity: Medium-High**
Expand Down Expand Up @@ -373,6 +333,22 @@ the event names complete where Drupal's event dispatcher takes one.

---

## DR17. Warn on unknown service IDs

**Impact: Medium · Complexity: Low-Medium**

```php
$manager = \Drupal::service('acme.order_manger');
```

A service ID no `*.services.yml` declares is a typo or a module the site
lacks, and the container throws for it at run time. Report one where
`\Drupal::service()` and `$container->get()` take it. A module's
`ServiceProvider` class can register services in PHP, which no YAML file
lists, so an ID such a provider may register must not warn.

---

## DR21. Return types core's docblocks leave wide

**Impact: Medium · Complexity: Low-Medium**
Expand Down
9 changes: 9 additions & 0 deletions examples/drupal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ Run `composer install` here first. Composer puts core and contrib into
definition, so the chain keeps completing.
- In `web/modules/custom/acme/acme.module`, complete after `$route_match->`
inside the procedural `acme_help()` hook.
- Service IDs and container parameters navigate between the services files
and the code that looks them up, core's included. In
`web/modules/custom/acme/acme.module`, hover `$greeter`, typed by the class
`acme.services.yml` declares for `acme.greeter`, or go to the definition of
`'acme.greeter'`. In `web/modules/custom/acme/src/NoteCounter.php`, go to
the definition of `'entity_type.manager'`, which lands in core's
`core.services.yml`. Complete an ID inside `\Drupal::service('')`,
`$container->get('')`, or after `@` in `acme.services.yml`, and find the
references of a service from its declaration.

## What the Symfony support already reads

Expand Down
16 changes: 16 additions & 0 deletions examples/drupal/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@
use Drupal\acme\Entity\Note;
use Drupal\acme\Greeter;
use Drupal\acme\Hook\AcmeHooks;
use Drupal\acme\NoteCounter;
use Drupal\Component\FileCache\FileCacheFactory;
use Drupal\Component\Serialization\Yaml;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\DependencyInjection\YamlFileLoader;
use Drupal\Core\Entity\Attribute\ContentEntityType;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Extension\InfoParser;
use Drupal\Core\Hook\Attribute\Hook;

Expand Down Expand Up @@ -91,6 +94,17 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string
check('The class acme.services.yml names exists', class_exists((string) $container->getDefinition('acme.greeter')->getClass()));
check('The Greeter class name is an alias of acme.greeter', $container->hasAlias(Greeter::class) && (string) $container->getAlias(Greeter::class) === 'acme.greeter');
check("acme.greeter receives core's config.factory", (string) $container->getDefinition('acme.greeter')->getArgument(0) === 'config.factory');
check('acme.greeter receives the acme.greeting_format parameter', $container->getDefinition('acme.greeter')->getArgument(1) === '%acme.greeting_format%');
check('acme.greeting_format is a parameter declared in acme.services.yml', is_string($container->getParameter('acme.greeting_format')));
check('Greeter takes the format as its second argument', (new ReflectionMethod(Greeter::class, '__construct'))->getParameters()[1]->getType()?->getName() === 'string');

// Core declares the services the module looks up by ID.
$core = new ContainerBuilder();
(new YamlFileLoader($core))->load("$web/core/core.services.yml");
check('core.services.yml declares config.factory', $core->hasDefinition('config.factory'));
check('entity_type.manager, which NoteCounter::create() looks up, is built from EntityTypeManager', $core->getDefinition('entity_type.manager')->getClass() === EntityTypeManager::class);
check('NoteCounter takes what entity_type.manager is', is_subclass_of(EntityTypeManager::class, (string) (new ReflectionMethod(NoteCounter::class, '__construct'))->getParameters()[0]->getType()));
check('NoteCounter is built from the container', is_subclass_of(NoteCounter::class, ContainerInjectionInterface::class));

// ─── Routes ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -120,6 +134,8 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string
$themeHook = new ReflectionMethod(AcmeHooks::class, 'theme');
$themeHooks = (new AcmeHooks())->theme();
check('acme_help() implements hook_help procedurally', function_exists('acme_help'));
check('acme_help() also looks the greeter up by its class name, which acme.services.yml registers', str_contains((string) file_get_contents("$module/acme.module"), '\\Drupal::service(Greeter::class)') && $container->hasAlias(Greeter::class));
check("acme_help() looks up acme.greeter, which acme.services.yml declares", str_contains((string) file_get_contents("$module/acme.module"), "\\Drupal::service('acme.greeter')") && $container->hasDefinition('acme.greeter'));
check("AcmeHooks::theme() implements hook_theme through #[Hook('theme')]", attributeArguments($themeHook, Hook::class) === ['theme']);
check('hook_theme declares the acme_greeting theme hook', isset($themeHooks['acme_greeting']));
check('acme_greeting declares the variables the controller passes', array_keys($themeHooks['acme_greeting']['variables'] ?? []) === ['name', 'greeting']);
Expand Down
12 changes: 11 additions & 1 deletion examples/drupal/web/modules/custom/acme/acme.module
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

declare(strict_types=1);

use Drupal\acme\Greeter;
use Drupal\Core\Routing\RouteMatchInterface;

/**
Expand All @@ -17,7 +18,16 @@ function acme_help(string $route_name, RouteMatchInterface $route_match): ?strin
// Try: complete after `$route_match->`. The methods come from core's
// RouteMatchInterface.
if ($route_name === 'help.page.acme' && $route_match->getParameter('name') === 'acme') {
return '<p>Greets the name in the path, for example /acme/greeting/Ada.</p>';
// Try: hover `$greeter`, typed by the class acme.services.yml
// declares, or go to the definition of 'acme.greeter'. Complete a
// service ID inside `\Drupal::service('')`.
$greeter = \Drupal::service('acme.greeter');
// Try: hover `$sameGreeter`. acme.services.yml also registers the
// service under its class name, and a lookup by class name is typed
// as that class.
$sameGreeter = \Drupal::service(Greeter::class);

return '<p>' . $greeter->greet('Ada') . ' ' . $sameGreeter->greet('Grace') . '</p>';
}

return null;
Expand Down
7 changes: 6 additions & 1 deletion examples/drupal/web/modules/custom/acme/acme.services.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
parameters:
acme.greeting_format: 'Hello @name, welcome to @site.'

services:
acme.greeter:
# Try: go to the definition of Greeter, or find its references: this
# line is one of them.
class: Drupal\acme\Greeter
arguments: ['@config.factory']
# Try: go to the definition of `@config.factory`, which core declares,
# or of `%acme.greeting_format%`. Complete a service ID after `@`.
arguments: ['@config.factory', '%acme.greeting_format%']
# Lets controllers and hook classes autowire the greeter by its class.
Drupal\acme\Greeter: '@acme.greeter'
6 changes: 5 additions & 1 deletion examples/drupal/web/modules/custom/acme/src/Greeter.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class Greeter
{
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
private readonly string $format,
) {}

public function greet(string $name): string
Expand All @@ -21,6 +22,9 @@ public function greet(string $name): string
// from core's ConfigFactoryInterface.
$siteName = $this->configFactory->get('system.site')->get('name');

return sprintf('Hello %s, welcome to %s.', $name, is_string($siteName) ? $siteName : 'Drupal');
return strtr($this->format, [
'@name' => $name,
'@site' => is_string($siteName) ? $siteName : 'Drupal',
]);
}
}
37 changes: 37 additions & 0 deletions examples/drupal/web/modules/custom/acme/src/NoteCounter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Drupal\acme;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
* Counts the notes, built the way most Drupal classes get their services.
*/
final class NoteCounter implements ContainerInjectionInterface
{
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}

public static function create(ContainerInterface $container): static
{
// Try: go to the definition of 'entity_type.manager', which lands in
// core's core.services.yml. Complete a service ID inside
// `$container->get('')`.
return new static($container->get('entity_type.manager'));
}

public function count(): int
{
return (int) $this->entityTypeManager
->getStorage('acme_note')
->getQuery()
->accessCheck(false)
->count()
->execute();
}
}
2 changes: 1 addition & 1 deletion src/analyse/stages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ pub(super) fn index_project(
// only by that walk, and a Yii project's routes are declared in its
// `config/`, which no autoloader names.
if backend.resolved_class_cache.read().laravel().is_enabled()
|| backend.is_symfony_project()
|| backend.reads_service_container()
|| backend.is_twig_project()
|| backend.is_yii_project()
{
Expand Down
Loading
Loading