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
59 changes: 53 additions & 6 deletions benches/drupal_completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ use tower_lsp_server::LanguageServer;
use tower_lsp_server::gen_lsp_types::*;

/// Custom modules in the generated site, each declaring `SERVICES`
/// services, `ROUTES` routes, `ENTITY_TYPES` entity types, and documenting
/// `HOOKS` hooks, and implementing the hooks the module before it
/// documents.
/// services, `ROUTES` routes, `ENTITY_TYPES` entity types, `PERMISSIONS`
/// permissions, and `CONFIGS` configuration objects, with their default
/// configuration and schema, and documenting `HOOKS` hooks, and
/// implementing the hooks the module before it documents.
const MODULES: usize = 30;
const SERVICES: usize = 10;
const ROUTES: usize = 10;
const ENTITY_TYPES: usize = 3;
const PERMISSIONS: usize = 10;
const CONFIGS: usize = 10;
const HOOKS: usize = 10;

const COMPOSER: &str = r#"{
Expand All @@ -22,12 +25,12 @@ 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, and a
/// URL from any other receiver.
/// tell a container, the entity type manager, the module handler, an
/// account, and a URL from any other receiver.
const STUBS: &[(&str, &str)] = &[
(
"web/core/lib/Drupal.php",
"<?php\nclass Drupal {\n public static function service(string $id): object {}\n public static function hasService(string $id): bool {}\n}\n",
"<?php\nclass Drupal {\n public static function service(string $id): object {}\n public static function hasService(string $id): bool {}\n public static function config($name) {}\n}\n",
),
(
"web/core/lib/Drupal/Core/Url.php",
Expand All @@ -45,6 +48,10 @@ const STUBS: &[(&str, &str)] = &[
"web/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php",
"<?php\nnamespace Drupal\\Core\\Extension;\ninterface ModuleHandlerInterface {\n public function invokeAll($hook, array $args = []);\n public function invoke($module, $hook, array $args = []);\n public function alter($type, &$data, &$context1 = NULL, &$context2 = NULL);\n}\n",
),
(
"web/core/lib/Drupal/Core/Session/AccountInterface.php",
"<?php\nnamespace Drupal\\Core\\Session;\ninterface AccountInterface {\n public function hasPermission(string $permission);\n}\n",
),
(
"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",
Expand All @@ -59,13 +66,15 @@ namespace Drupal\acme;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;

final class Consumer
{
public function __construct(
private EntityTypeManagerInterface $manager,
private ModuleHandlerInterface $modules,
private AccountInterface $account,
) {}

#[Hook('mod1_')]
Expand All @@ -75,6 +84,8 @@ final class Consumer
Url::fromRoute('mod1.');
$this->manager->getStorage('mod1_');
$this->modules->invokeAll('mod1_');
$this->account->hasPermission('administer mod1 ');
\Drupal::config('mod1.');
}
}
"#;
Expand Down Expand Up @@ -121,6 +132,28 @@ fn generate_site() -> Vec<(String, String)> {
}
files.push((format!("{dir}/mod{m}.routing.yml"), routing));

let mut permissions = String::new();
for p in 0..PERMISSIONS {
let _ = writeln!(
permissions,
"administer mod{m} thing{p}:\n title: 'Administer thing {p} of module {m}'"
);
}
files.push((format!("{dir}/mod{m}.permissions.yml"), permissions));

let mut schema = String::new();
for c in 0..CONFIGS {
files.push((
format!("{dir}/config/install/mod{m}.setting{c}.yml"),
"enabled: true\n".to_string(),
));
let _ = writeln!(
schema,
"mod{m}.setting{c}:\n type: config_object\n label: 'Setting {c} of module {m}'"
);
}
files.push((format!("{dir}/config/schema/mod{m}.schema.yml"), schema));

for e in 0..ENTITY_TYPES {
files.push((
format!("{dir}/src/Entity/Entity{e}.php"),
Expand Down Expand Up @@ -276,6 +309,20 @@ fn bench_drupal_name_completion(c: &mut Criterion) {
"getStorage('mod1_'",
"mod1_entity2",
),
(
"permission_name",
&consumer,
CONSUMER_PHP,
"hasPermission('administer mod1 '",
"administer mod1 thing7",
),
(
"config_name",
&consumer,
CONSUMER_PHP,
"config('mod1.'",
"mod1.setting7",
),
(
"hook_name_invoke_all",
&consumer,
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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. Hover on an implementation's name or `#[Hook]` shows the hook's documented signature and docblock. Contributed by @giuseppe-esposito-87.
- **Drupal sites in any layout.** Modules and themes grouped in folders of their own, kept directly under `modules/` or `themes/`, or shipped inside an install profile are now indexed even when `.gitignore` leaves them out, so they work like those in `contrib/` and `custom/`. A site that keeps `core/` beside its `composer.json`, or a Drupal tree opened without any `composer.json`, is now recognized as a Drupal site. 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.
- **Drupal permissions and configuration.** In a Drupal project, a permission name completes from every permissions file, jumps to its declaration, and shows its title and description on hover, wherever an access check, a route's `_permission` requirement, or an entity type's `admin_permission` names it. A configuration name in `\Drupal::config()`, the config factory, a config form, or a `#config_target` completes and jumps to the default configuration and the schema entry that declare it, and the keys a configuration object's `get()`, `set()`, and `clear()` read complete from its schema one level at a time and jump to their schema line. 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
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ phpantom_lsp analyze --project-root examples/wordpress --no-colour
phpantom_lsp analyze --project-root examples/drupal --no-colour
```

Note that clippy runs twice, once for library code and once including test code. The `php -l` checks keep the PHP and framework playgrounds valid. The `php -d zend.assertions=1` run executes `assertions.php`'s `runDemoAssertions()` to verify that `scaffolding/scaffolding.php`'s stubs actually return what their docblocks claim. Each framework playground has an `assertions.php` of its own that checks the demo against the real framework: the Laravel one boots Eloquent on an in-memory database, the Symfony one loads the routes, services, translations, templates, forms, and validation mappings with Symfony's own loaders, the Yii one builds the configuration and DI container the way Yii's web runner does, the WordPress one installs WordPress into a throwaway SQLite database and loads the demo plugin and theme, and the Drupal one reads the demo module's services, routes, entity type, and hooks with Drupal's own loaders and reflection. The framework `assertions.php` and `phpantom_lsp analyze` runs need `composer install` in each playground first; the `analyze` runs check the playgrounds for diagnostic regressions. `examples/symfony/`, `examples/yii/`, `examples/wordpress/` and `examples/drupal/` must report `[OK] No errors`. In `examples/laravel/`, `app/Demo.php` carries four deliberate mistakes: `Artisan::call('does:not-exist')` demonstrates `invalid_laravel_command`, `->withoutMiddleware('bakery.closed')` demonstrates `invalid_laravel_middleware`, and one `view('welcome', …)` call both leaves out a variable the template declares and passes a misspelled key, demonstrating `missing_view_variable` and `unused_view_variable`. So the analyze run must report exactly `[ERROR] Found 4 errors` on those three lines, not `[OK] No errors`; any other count, or an error on a different line, is a regression.
Note that clippy runs twice, once for library code and once including test code. The `php -l` checks keep the PHP and framework playgrounds valid. The `php -d zend.assertions=1` run executes `assertions.php`'s `runDemoAssertions()` to verify that `scaffolding/scaffolding.php`'s stubs actually return what their docblocks claim. Each framework playground has an `assertions.php` of its own that checks the demo against the real framework: the Laravel one boots Eloquent on an in-memory database, the Symfony one loads the routes, services, translations, templates, forms, and validation mappings with Symfony's own loaders, the Yii one builds the configuration and DI container the way Yii's web runner does, the WordPress one installs WordPress into a throwaway SQLite database and loads the demo plugin and theme, and the Drupal one reads the demo module's services, routes, entity type, hooks, permissions, and configuration with Drupal's own loaders and reflection. The framework `assertions.php` and `phpantom_lsp analyze` runs need `composer install` in each playground first; the `analyze` runs check the playgrounds for diagnostic regressions. `examples/symfony/`, `examples/yii/`, `examples/wordpress/` and `examples/drupal/` must report `[OK] No errors`. In `examples/laravel/`, `app/Demo.php` carries four deliberate mistakes: `Artisan::call('does:not-exist')` demonstrates `invalid_laravel_command`, `->withoutMiddleware('bakery.closed')` demonstrates `invalid_laravel_middleware`, and one `view('welcome', …)` call both leaves out a variable the template declares and passes a misspelled key, demonstrating `missing_view_variable` and `unused_view_variable`. So the analyze run must report exactly `[ERROR] Found 4 errors` on those three lines, not `[OK] No errors`; any other count, or an error on a different line, is a regression.

## Code Style

Expand Down
4 changes: 2 additions & 2 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ contributor even though it's short.
| D41 | [A thrown exception nothing catches or documents is not reported](todo/diagnostics.md#d41-a-thrown-exception-nothing-catches-or-documents-is-not-reported) | Medium | Medium |
| L66 | [Related-file navigation for models, events, and listeners](todo/laravel.md#l66-related-file-navigation-for-models-events-and-listeners) | Medium | Medium |
| L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (assets, Inertia) | Medium | 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 |
| T66 | [A DQL aggregate or function is typed `mixed`](todo/type-inference.md#t66-a-dql-aggregate-or-function-is-typed-mixed) | Low-Medium | Medium |
| T65 | [The expression builder's fragments are not literal strings](todo/type-inference.md#t65-the-expression-builders-fragments-are-not-literal-strings) | Low | Low-Medium |
Expand Down Expand Up @@ -133,7 +132,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 |
| 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 |
| 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) | Medium-High | High |
| DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium |
Expand All @@ -142,6 +140,8 @@ ships, remove every row that names it.
| 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 |
| DR22 | [Complete permissions built in code](todo/drupal.md#dr22-complete-permissions-built-in-code) | Low-Medium | Medium |
| DR23 | [Warn on unknown permission names](todo/drupal.md#dr23-warn-on-unknown-permission-names) | Medium | Low-Medium |

## Yii

Expand Down
Loading
Loading