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 @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Drupal entity types.** In a Drupal project, an entity type ID declared by an entity class's attribute or annotation completes, jumps to its declaration, and lists its references wherever the entity type manager is asked for it, and `getStorage()` is typed as that entity type's storage, so `load()`, `loadMultiple()`, `create()`, and the other loads return the entity class. An entity query started by `\Drupal::entityQuery()` or a storage's `getQuery()` gives the IDs of that entity type, or their number after `count()`. Contributed by @giuseppe-esposito-87.
- **Drupal hooks.** In a Drupal project, a hook implementation, procedural or by `#[Hook]`, jumps to the hook's `*.api.php` documentation and to the calls that fire it, core's computed hook names included, and an invocation jumps to the implementations it reaches. Hook names and implementations complete with their documented signature, and an implementation's untyped parameters take the documented types, narrowed to the entity class for a hook of one entity type. Contributed by @giuseppe-esposito-87.
- **Drupal route names.** In a Drupal project, a route declared by a routing file, a `#[Route]` attribute, or a route builder completes, jumps to its declaration, and lists its references wherever code, a links file, or a template names it, from `Url::fromRoute()`, `$this->redirect()`, and `$form_state->setRedirect()` to `route_name:` and `path()`. A route name nothing declares is reported. The parameter keys those calls, links files, and templates pass jump to the placeholder of the route's path and complete from it, and a controller's parameter is typed as the entity Drupal upcasts it to, with a warning when a controller's type hint unrelated to the entity keeps Drupal from upcasting it. 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.
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 |
| 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 |
| DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High |
| | **Release 1.0.0 + IDE extensions** | | |
Expand Down Expand Up @@ -126,7 +125,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 |
| DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium |
| DR10 | [Plugin IDs](todo/drupal.md#dr10-plugin-ids) | Medium | Medium |
| DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium |
| DR8 | [Permission and configuration names](todo/drupal.md#dr8-permission-and-configuration-names) | Low-Medium | Medium |
Expand All @@ -136,6 +134,7 @@ ships, remove every row that names it.
| 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 |
| 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 |
| DR21 | [Return types core's docblocks leave wide](todo/drupal.md#dr21-return-types-cores-docblocks-leave-wide) | Medium | Low-Medium |

Expand Down
24 changes: 24 additions & 0 deletions docs/todo/bugs.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,27 @@ 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.

### B734. Passing a variable to a typed by-reference parameter leaves its old type

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

```php
function mail(array &$message): void {
$message['body'][] = new Markup();
}

$message = ['body' => []];
mail($message);
$message['body'][0]; // null, should be mixed (or Markup)
```

After a call, a variable passed to a by-reference parameter keeps the
type it had before the call when the parameter declares a type, native
or `@param`, and no `@param-out`. An untyped by-reference parameter
takes the type the callee's body writes into it, so only the typed case
is wrong. PHPStan gives the variable the parameter's declared type when
there is no `@param-out`. A test of Drupal's update module reports
`Cannot access method 'render' on type 'never'` after calling a
`hook_mail()` implementation, which reads `$message` as `array` from the
hook's documentation.
43 changes: 18 additions & 25 deletions docs/todo/drupal.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ 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, the entity types
entity classes declare, and the routes the routing files, route
attributes, and route builders declare are read. Nothing reads Drupal's
other declarations yet: fields or hooks.
entity classes declare, the routes the routing files, route attributes,
and route builders declare, and the hooks the `*.api.php` files document
and the extensions implement 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 @@ -54,28 +54,6 @@ names and report a misspelt one.

---

## DR6. Hooks

**Impact: Medium-High · Complexity: Medium**

```php
function acme_form_alter(&$form, FormStateInterface $form_state, $form_id) { … }

#[Hook('form_alter')]
public function formAlter(array &$form, FormStateInterface $form_state, $form_id): void { … }
```

A hook is implemented by naming a function `<module>_<hook>` or, since
Drupal 11.1, with a `#[Hook]` attribute on a method. Core and modules
document each hook as a `hook_<name>()` function in a `*.api.php` file.
Link an implementation to that documentation: hover shows it,
go-to-definition reaches it, find references lists the implementations,
and completing `acme_` in a `.module` file offers the hooks with their
documented signatures. Parameter types for an implementation that
omits them can come from the documented signature.

---

## DR7. Render arrays, theme hooks, and libraries

**Impact: Medium · Complexity: Medium-High**
Expand Down Expand Up @@ -309,6 +287,21 @@ site where an implementation of that hook may add one stays silent.

---

## DR19. Warn on unknown hook names

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

```php
\Drupal::moduleHandler()->invokeAll('acme_order_placd', [$order]);
```

Any module may invoke any hook name, and many hooks have no `*.api.php`
documentation, so a name nothing documents is not wrong on that alone.
Report only a hook name that nothing in the site documents, implements,
or invokes anywhere else, which is most likely a typo.

---

## DR20. Complete route names built in code

**Impact: Low-Medium · Complexity: 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 @@ -65,6 +65,15 @@ Run `composer install` here first. Composer puts core and contrib into
the same route's title callback: both are a `Note`. A controller hint
unrelated to the entity class, which leaves Drupal passing the raw ID, is
reported.
- Hooks navigate between their documentation, the calls that fire them, and
their implementations. In `web/modules/custom/acme/acme.module`, hover
`$form_state` in `acme_form_alter()`, typed by core's documentation, or go
to the definition of `form_alter` in the function's name, which lists the
`alter()` call in core's `FormBuilder` and `hook_form_alter()`. In
`src/Greeter.php`, go to the definition of `'acme_greeting'`, which lists
the implementation in `src/Hook/AcmeHooks.php` and the documentation in
`acme.api.php`. Type `function acme_` on a new line of `acme.module` to
complete a hook with its documented signature.

## What the Symfony support already reads

Expand Down
12 changes: 12 additions & 0 deletions examples/drupal/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string
check('acme_greeting has the template its name implies', is_file("$module/templates/acme-greeting.html.twig"));
check('acme_theme preprocesses acme_greeting', function_exists('acme_theme_preprocess_acme_greeting'));

// The module documents, invokes, and implements its own hook, and
// implements core's form alter hooks.
require_once "$module/acme.api.php";
$greetingAlter = new ReflectionMethod(AcmeHooks::class, 'greetingAlter');
check('acme.api.php documents hook_acme_greeting_alter()', function_exists('hook_acme_greeting_alter'));
check('Greeter invokes the acme_greeting alter hook', str_contains((string) file_get_contents("$module/src/Greeter.php"), "->alter('acme_greeting', \$greeting, \$name)"));
check("AcmeHooks::greetingAlter() implements it through #[Hook('acme_greeting_alter')]", attributeArguments($greetingAlter, Hook::class) === ['acme_greeting_alter']);
check("greetingAlter()'s untyped name is the documented string", $greetingAlter->getParameters()[1]->getType() === null && (string) (new ReflectionFunction('hook_acme_greeting_alter'))->getParameters()[1]->getType() === 'string');
check('acme_form_alter() implements hook_form_alter procedurally', function_exists('acme_form_alter'));
check("core documents hook_form_alter() with the FormStateInterface acme_form_alter() leaves untyped", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Form/form.api.php"), 'function hook_form_alter(&$form, \\Drupal\\Core\\Form\\FormStateInterface $form_state, $form_id)') && (new ReflectionFunction('acme_form_alter'))->getParameters()[1]->getType() === null);
check("core's FormBuilder::prepareForm() fires the form alter hooks", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Form/FormBuilder.php"), '$this->moduleHandler->alter($hooks, $form, $form_state, $form_id);'));

// ─── Summary ────────────────────────────────────────────────────────────────

echo "\n";
Expand Down
19 changes: 19 additions & 0 deletions examples/drupal/web/modules/custom/acme/acme.api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/**
* @file
* Hooks the acme module invokes.
*/

/**
* Alter a greeting before the greeting page shows it.
*
* @param string $greeting
* The greeting, to alter in place.
* @param string $name
* The name being greeted.
*/
function hook_acme_greeting_alter(string &$greeting, string $name): void
{
$greeting .= ' Nice to meet you, ' . $name . '.';
}
14 changes: 14 additions & 0 deletions examples/drupal/web/modules/custom/acme/acme.module
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ declare(strict_types=1);
use Drupal\acme\Greeter;
use Drupal\Core\Routing\RouteMatchInterface;

/**
* Implements hook_form_alter().
*/
function acme_form_alter(array &$form, $form_state, string $form_id): void
{
// Try: hover `$form_state`, typed by hook_form_alter()'s documentation
// in core. Go to the definition of `form_alter` in the function's name,
// which lists the alter() call in core's FormBuilder and the
// documentation. Type `function acme_` on a new line to complete a hook.
if ($form_id === 'user_login_form') {
$form_state->disableCache();
}
}

/**
* Implements hook_help().
*/
Expand Down
8 changes: 7 additions & 1 deletion examples/drupal/web/modules/custom/acme/src/Greeter.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,15 @@ public function greet(string $name): string
// from core's ConfigFactoryInterface.
$siteName = $this->configFactory->get('system.site')->get('name');

return strtr($this->format, [
$greeting = strtr($this->format, [
'@name' => $name,
'@site' => is_string($siteName) ? $siteName : 'Drupal',
]);
// Try: go to the definition of 'acme_greeting', which lists the
// implementation in AcmeHooks and then the documentation in
// acme.api.php. Complete a hook inside `alter('')`.
\Drupal::moduleHandler()->alter('acme_greeting', $greeting, $name);

return $greeting;
}
}
14 changes: 14 additions & 0 deletions examples/drupal/web/modules/custom/acme/src/Hook/AcmeHooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,18 @@ public function theme(): array
],
];
}

/**
* Implements hook_acme_greeting_alter().
*/
#[Hook('acme_greeting_alter')]
public function greetingAlter(string &$greeting, $name): void
{
// Try: hover `$name`, typed `string` by the hook's documentation in
// acme.api.php, or go to the definition of 'acme_greeting_alter',
// which lists the alter() call in Greeter and the documentation.
if ($name === 'Ada') {
$greeting .= ' Welcome back.';
}
}
}
2 changes: 1 addition & 1 deletion src/blade/typed_receiver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ impl Backend {

/// Resolve the receiver of every candidate site and keep the spans of
/// the ones that turn out to be renders.
fn confirm_typed_receiver_sites(
pub(crate) fn confirm_typed_receiver_sites(
&self,
uri: &str,
content: &str,
Expand Down
3 changes: 3 additions & 0 deletions src/classmap_scanner/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,9 @@ pub fn scan_drupal_directories(
}
if is_drupal_php_file(path) || filters.is_php_file(path) {
php_files.push((path.to_path_buf(), crate::ClassCompletionOrigin::Project));
if crate::drupal::is_hook_php_file(path, web_root) {
declaration_files.push(path.to_path_buf());
}
} else if crate::drupal::is_declaration_file(path, web_root) {
declaration_files.push(path.to_path_buf());
}
Expand Down
3 changes: 2 additions & 1 deletion src/classmap_scanner/discovery_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ fn drupal_scan_honors_exclude_globs() {
}

#[test]
fn scan_drupal_directories_collects_services_files_outside_tests() {
fn scan_drupal_directories_collects_declaration_files_outside_tests() {
let dir = tempfile::tempdir().unwrap();
let web_root = dir.path();
for (path, content) in [
Expand All @@ -735,6 +735,7 @@ fn scan_drupal_directories_collects_services_files_outside_tests() {
files,
vec![
web_root.join("core/core.services.yml"),
web_root.join("modules/contrib/token/token.info.yml"),
web_root.join("modules/contrib/token/token.services.yml"),
web_root.join("sites/default/services.yml"),
]
Expand Down
Loading
Loading