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
69 changes: 66 additions & 3 deletions benches/drupal_completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ use tower_lsp_server::gen_lsp_types::*;

/// Custom modules in the generated site, each declaring `SERVICES`
/// services, `ROUTES` routes, `ENTITY_TYPES` entity types, `PERMISSIONS`
/// permissions, and `CONFIGS` configuration objects, with their default
/// configuration and schema, and documenting `HOOKS` hooks, and
/// permissions, `CONFIGS` configuration objects, with their default
/// configuration and schema, `ELEMENTS` render elements, `THEME_HOOKS`
/// theme hooks, and `LIBRARIES` libraries, 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 ELEMENTS: usize = 3;
const HOOKS: usize = 10;
const THEME_HOOKS: usize = 5;
const LIBRARIES: usize = 10;

const COMPOSER: &str = r#"{
"require": { "drupal/core-recommended": "^11" },
Expand All @@ -44,6 +48,10 @@ const STUBS: &[(&str, &str)] = &[
"web/core/lib/Drupal/Core/Entity/Attribute/ContentEntityType.php",
"<?php\nnamespace Drupal\\Core\\Entity\\Attribute;\n#[\\Attribute(\\Attribute::TARGET_CLASS)]\nclass ContentEntityType {\n public function __construct(public readonly string $id) {}\n}\n",
),
(
"web/core/lib/Drupal/Core/Render/Attribute/RenderElement.php",
"<?php\nnamespace Drupal\\Core\\Render\\Attribute;\n#[\\Attribute(\\Attribute::TARGET_CLASS)]\nclass RenderElement {\n public function __construct(public readonly string $id) {}\n}\n",
),
(
"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",
Expand Down Expand Up @@ -86,6 +94,9 @@ final class Consumer
$this->modules->invokeAll('mod1_');
$this->account->hasPermission('administer mod1 ');
\Drupal::config('mod1.');
$build = ['#type' => 'mod1_'];
$themed = ['#theme' => 'mod1_'];
$build = ['#attached' => ['library' => ['mod1/']]];
}
}
"#;
Expand All @@ -103,7 +114,8 @@ fn rt() -> tokio::runtime::Runtime {
}

/// The files of a site with many custom modules: their services, routes,
/// entity types, documented hooks, and hook implementations.
/// entity types, render elements, documented hooks, hook implementations,
/// and theme hooks.
fn generate_site() -> Vec<(String, String)> {
let mut files = Vec::new();

Expand Down Expand Up @@ -154,6 +166,15 @@ fn generate_site() -> Vec<(String, String)> {
}
files.push((format!("{dir}/config/schema/mod{m}.schema.yml"), schema));

let mut libraries = String::new();
for l in 0..LIBRARIES {
let _ = writeln!(
libraries,
"library{l}:\n version: 1.x\n js:\n js/library{l}.js: {{}}\n dependencies:\n - core/drupal"
);
}
files.push((format!("{dir}/mod{m}.libraries.yml"), libraries));

for e in 0..ENTITY_TYPES {
files.push((
format!("{dir}/src/Entity/Entity{e}.php"),
Expand All @@ -163,6 +184,15 @@ fn generate_site() -> Vec<(String, String)> {
));
}

for e in 0..ELEMENTS {
files.push((
format!("{dir}/src/Element/Element{e}.php"),
format!(
"<?php\nnamespace Drupal\\mod{m}\\Element;\n\nuse Drupal\\Core\\Render\\Attribute\\RenderElement;\n\n#[RenderElement('mod{m}_element{e}')]\nclass Element{e} {{}}\n"
),
));
}

let mut api = String::from("<?php\n");
for h in 0..HOOKS {
let _ = write!(
Expand All @@ -172,6 +202,18 @@ fn generate_site() -> Vec<(String, String)> {
}
files.push((format!("{dir}/mod{m}.api.php"), api));

let mut theme = format!(
"<?php\nnamespace Drupal\\mod{m}\\Hook;\n\nuse Drupal\\Core\\Hook\\Attribute\\Hook;\n\nfinal class ThemeHooks {{\n #[Hook('theme')]\n public function theme(): array {{\n return [\n"
);
for t in 0..THEME_HOOKS {
let _ = writeln!(
theme,
" 'mod{m}_widget{t}' => ['variables' => ['label' => NULL, 'items' => []]],"
);
}
theme.push_str(" ];\n }\n}\n");
files.push((format!("{dir}/src/Hook/ThemeHooks.php"), theme));

if m > 0 {
let previous = m - 1;
let mut module = String::from("<?php\n");
Expand Down Expand Up @@ -323,6 +365,13 @@ fn bench_drupal_name_completion(c: &mut Criterion) {
"config('mod1.'",
"mod1.setting7",
),
(
"render_element",
&consumer,
CONSUMER_PHP,
"'#type' => 'mod1_'",
"mod1_element2",
),
(
"hook_name_invoke_all",
&consumer,
Expand All @@ -337,6 +386,20 @@ fn bench_drupal_name_completion(c: &mut Criterion) {
"#[Hook('mod1_'",
"mod1_event7",
),
(
"theme_hook_name",
&consumer,
CONSUMER_PHP,
"'#theme' => 'mod1_'",
"mod1_widget4",
),
(
"library_name",
&consumer,
CONSUMER_PHP,
"['library' => ['mod1/'",
"mod1/library7",
),
(
"hook_implementation",
&module,
Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Drupal render arrays, theme hooks, and libraries.** In a Drupal project, theme hook, render element, and library names complete, jump to their declaration, and show it on hover wherever render arrays, templates, and libraries or theme info files write them, and a theme hook also leads to its templates and each theme's override. A theme hook's templates receive its variables, typed by what the render arrays pass. A render element or library nothing declares is reported. 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, 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.
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's services, routes, entity type, hooks, permissions, configuration, theme hooks, render elements, and libraries 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
3 changes: 1 addition & 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 |
| DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High |
| T67 | [A DQL expression whose type depends on the driver is `mixed`](todo/type-inference.md#t67-a-dql-expression-whose-type-depends-on-the-driver-is-mixed) | Low-Medium | Medium |
| T68 | [A DQL field is typed by its property, not its column's type](todo/type-inference.md#t68-a-dql-field-is-typed-by-its-property-not-its-columns-type) | Low | 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 @@ -132,7 +131,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 |
| 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 |
| DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium |
Expand All @@ -142,6 +140,7 @@ ships, remove every row that names it.
| 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 |
| DR24 | [Warn on unknown theme hook names](todo/drupal.md#dr24-warn-on-unknown-theme-hook-names) | Low-Medium | Medium-High |

## Yii

Expand Down
Loading
Loading