diff --git a/benches/drupal_completion.rs b/benches/drupal_completion.rs index 0e7bccf25..45657c9c1 100644 --- a/benches/drupal_completion.rs +++ b/benches/drupal_completion.rs @@ -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#"{ @@ -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", - "manager->getStorage('mod1_'); $this->modules->invokeAll('mod1_'); + $this->account->hasPermission('administer mod1 '); + \Drupal::config('mod1.'); } } "#; @@ -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"), @@ -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, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index acc5cf0a4..633c6a400 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 616494530..906ba0601 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -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 diff --git a/docs/todo.md b/docs/todo.md index ec8b36e61..e267e6fb9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 | @@ -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 | @@ -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 diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 19a2d80bb..158c2acc6 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -6,8 +6,11 @@ module, theme, and profile, with 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, 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. +and route builders declare, the hooks the `*.api.php` files document +and the extensions implement, the permissions the `*.permissions.yml` +files declare, and the configuration objects and keys the default +configuration and the configuration schema declare are read. Nothing +reads fields yet. The requests in [#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the @@ -24,9 +27,9 @@ is what they expect from an editor. **Benchmarks.** `benches/memory_usage.py` measures memory on a site that installs core into `web/core` through Composer (`memory_drupal_module`), next to the other framework scenarios. `benches/drupal_completion.rs` -measures completion of service IDs, route names, entity type IDs, hook -names, and hook implementations over a generated site with many custom -modules. Every item that adds a name Drupal declares adds its case to +measures completion of service IDs, route names, entity type IDs, +permission names, configuration names, hook names, and hook +implementations over a generated site with many custom modules. Every item that adds a name Drupal declares adds its case to it. Items are ordered by **impact** (descending), then **complexity** (ascending) @@ -89,22 +92,6 @@ variables would be one more source of them (see `src/twig/variables.rs`). --- -## DR8. Permission and configuration names - -**Impact: Low-Medium · Complexity: Medium** - -```php -$account->hasPermission('administer acme orders'); -\Drupal::config('acme.settings')->get('notify_email'); -``` - -Permissions are declared in `.permissions.yml` (and by -permission callbacks), configuration objects in `config/install` and -`config/schema`. Complete and navigate both, and complete the keys -`get()` reads from the schema. - ---- - ## DR10. Plugin IDs **Impact: Medium · Complexity: Medium** @@ -319,3 +306,50 @@ type. Give both the precise type, through the same mechanism as the other framework corrections ([E7](external-stubs.md#e7-stub-based-framework-patches) if it has shipped), so a `string` parameter takes the plain `toString()` and the extender's methods resolve. + +--- + +## DR22. Complete permissions built in code + +**Impact: Low-Medium · Complexity: Medium** + +```php +$account->hasPermission('create article content'); +$account->hasPermission('use text format basic_html'); +``` + +Permissions a permissions file builds through its +`permission_callbacks` neither complete nor go to a definition, since no +key of the file declares them. Ten of core's permissions files do, among +them node, taxonomy, media, block content, and filter, which build one +set of permissions per bundle or text format. Read them from where +Drupal builds them: each callback returns an array keyed by names such as +`"create $type_id content"`, and the bundles and formats the names are +built from are configuration entities (`node.type.*`, +`filter.format.*`). Their names come from the default configuration, +and on most sites from the exported configuration, which +[DR4](#dr4-entity-field-access) reads. A name built from something the +site's files do not hold could be kept as a pattern, as route builders' +names are. + +--- + +## DR23. Warn on unknown permission names + +**Impact: Medium · Complexity: Low-Medium** + +```php +$account->hasPermission('administer acme ordres'); +$account->hasPermission('administer acme+access content'); +``` + +A permission name no `*.permissions.yml` declares is a typo or a module +the site lacks, and Drupal does not throw for it: the check fails, and the +mistake shows only as access denied. `hasPermission()` checks one exact +name, so `'administer acme+access content'` is a name of its own; `+` and +`,` only combine permissions in a route's `_permission`. Report a name +nothing declares wherever permission names are read: the access checks, +the access result helpers, the user module's role functions, each part of +a route's `_permission`, and an entity type's `admin_permission`. This +depends on [DR22](#dr22-complete-permissions-built-in-code): the names +permission callbacks build would otherwise be reported. diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 32d4d22e2..9b1297c21 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -3,7 +3,8 @@ A standalone editor playground for PHPantom on Drupal 11. It is a `drupal/recommended-project` site with one custom module, `acme`, and one theme, `acme_theme`, declaring a service, a route, a content entity type, -hooks, and a theme hook the way a real Drupal module declares them. +hooks, a theme hook, permissions, and configuration the way a real Drupal +module declares them. Run `composer install` here first. Composer puts core and contrib into `web/`, which is git-ignored apart from the custom module and theme. @@ -75,6 +76,30 @@ Run `composer install` here first. Composer puts core and contrib into 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. +- Permission names navigate between the permissions file that declares + them and the code and routes that require them. In + `web/modules/custom/acme/src/NoteAccess.php`, hover `'administer acme'` to + read its title and description from `acme.permissions.yml`, go to the + definition of `'access content'`, which lands in core's + `system.permissions.yml`, or complete a permission inside + `hasPermission('')`. Each part of the `_permission` of `acme.note` in + `acme.routing.yml` and the `admin_permission` of `Note` navigate and + complete too. +- Configuration names navigate to the default configuration that ships them + and the schema entry that describes them. In + `web/modules/custom/acme/src/Form/SettingsForm.php`, go to the definition + of `'acme.settings'` in `getEditableConfigNames()` or in the + `#config_target`, which lists `config/install/acme.settings.yml` and the + entry in `config/schema/acme.schema.yml`, or complete a configuration name + inside `['']`. In `web/modules/custom/acme/src/Greeter.php`, go to the + definition of `'system.site'`, which core ships. +- Configuration keys complete and navigate from the schema of the object + they belong to. In `SettingsForm`, complete a key inside `get('')` or + `set('')` on `$this->config('acme.settings')`, one level at a time after + `notifications.`, or after the `:` of the `#config_target`, and go to the + definition of `'notify_email'`, which lands on its line in + `acme.schema.yml`. Hover `$settings`, whose type carries the name it was + read by. - A module's service provider may build a service from a class of its own. In `acme_french_site_name()` in `web/modules/custom/acme/acme.module`, hover `$languageManager`: core declares `language_manager` as diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 1468a6fd6..095d9cca3 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -16,11 +16,14 @@ use Drupal\acme\Controller\GreetingController; use Drupal\acme\Controller\NoteController; use Drupal\acme\Entity\Note; +use Drupal\acme\Form\SettingsForm; 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\Access\AccessResult; +use Drupal\Core\Config\ConfigBase; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; @@ -33,8 +36,11 @@ use Drupal\Core\Entity\EntityTypeManager; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\Extension\InfoParser; +use Drupal\Core\Form\ConfigFormBase; +use Drupal\Core\Form\ConfigTarget; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Language\LanguageManager; +use Drupal\Core\Session\AccountInterface; use Drupal\Core\Link; use Drupal\Core\Url; use Drupal\language\ConfigurableLanguageManager; @@ -209,6 +215,44 @@ protected function getDefaultLanguageValues() check('NoteController::view() hints the parameter with an interface Note implements, so Drupal upcasts it', $noteParameter->getType()?->getName() === EntityInterface::class && is_subclass_of(Note::class, EntityInterface::class)); check("acme.note's title callback is NoteController::title(), which receives the same parameter", ltrim($routes['acme.note']['defaults']['_title_callback'] ?? '', '\\') === NoteController::class . '::title' && (new ReflectionMethod(NoteController::class, 'title'))->getParameters()[0]->getName() === 'acme_note'); +// ─── Permissions ──────────────────────────────────────────────────────────── + +$permissions = Yaml::decode((string) file_get_contents("$module/acme.permissions.yml")); +$corePermissions = Yaml::decode((string) file_get_contents("$web/core/modules/system/system.permissions.yml")); +$noteAccessSource = (string) file_get_contents("$module/src/NoteAccess.php"); +check('acme.permissions.yml declares administer acme with a title and a description', isset($permissions['administer acme']['title'], $permissions['administer acme']['description'])); +check('administer acme is restricted to trusted roles', ($permissions['administer acme']['restrict access'] ?? false) === true); +check('acme.permissions.yml declares view acme notes', isset($permissions['view acme notes']['title'])); +check("core's system.permissions.yml declares access content", isset($corePermissions['access content'])); +check('NoteAccess::canManage() checks administer acme', str_contains($noteAccessSource, "\$account->hasPermission('administer acme')")); +check('AccountInterface::hasPermission() takes the permission first', (new ReflectionMethod(AccountInterface::class, 'hasPermission'))->getParameters()[0]->getName() === 'permission'); +check('NoteAccess::view() lists access content and view acme notes', str_contains($noteAccessSource, "AccessResult::allowedIfHasPermissions(\$account, ['access content', 'view acme notes'], 'OR')")); +check('AccessResult::allowedIfHasPermissions() takes the permissions second', (new ReflectionMethod(AccessResult::class, 'allowedIfHasPermissions'))->getParameters()[1]->getName() === 'permissions'); +$noteRequirement = $routes['acme.note']['requirements']['_permission'] ?? ''; +check('acme.note requires either of two acme permissions', explode('+', $noteRequirement) === ['view acme notes', 'administer acme'] && array_diff(explode('+', $noteRequirement), array_keys($permissions)) === []); +check("PermissionAccessCheck reads a route's `+` as any of its permissions", str_contains((string) file_get_contents("$web/core/modules/user/src/Access/PermissionAccessCheck.php"), "explode('+', \$permission);")); +check("acme_note's admin permission is administer acme, which acme.permissions.yml declares", $noteDefinition->getAdminPermission() === 'administer acme' && isset($permissions['administer acme'])); + +// ─── Configuration ────────────────────────────────────────────────────────── + +$acmeSchema = Yaml::decode((string) file_get_contents("$module/config/schema/acme.schema.yml")); +$systemSchema = Yaml::decode((string) file_get_contents("$web/core/modules/system/config/schema/system.schema.yml")); +$editableNames = new ReflectionMethod(SettingsForm::class, 'getEditableConfigNames'); +$settingsSource = (string) file_get_contents("$module/src/Form/SettingsForm.php"); +check('acme ships default configuration for acme.settings', is_file("$module/config/install/acme.settings.yml")); +check('acme.schema.yml describes acme.settings as a configuration object', ($acmeSchema['acme.settings']['type'] ?? null) === 'config_object'); +check('acme.settings is a valid configuration name', ConfigBase::validateName('acme.settings') === null); +check('SettingsForm edits acme.settings', $editableNames->invoke((new ReflectionClass(SettingsForm::class))->newInstanceWithoutConstructor()) === ['acme.settings']); +check('SettingsForm is a config form', is_subclass_of(SettingsForm::class, ConfigFormBase::class)); +check("SettingsForm's #config_target names acme.settings before its key", str_contains($settingsSource, "'#config_target' => 'acme.settings:notify_email'") && ConfigTarget::fromString('acme.settings:notify_email')->configName === 'acme.settings'); +check('acme.settings declares the notify_email key the form targets', isset($acmeSchema['acme.settings']['mapping']['notify_email']) && array_key_exists('notify_email', Yaml::decode((string) file_get_contents("$module/config/install/acme.settings.yml")))); +check('acme.schema.yml declares the notifications.email key SettingsForm reads and sets', ($acmeSchema['acme.settings']['mapping']['notifications']['mapping']['email']['type'] ?? null) === 'boolean' && str_contains($settingsSource, "->get('notifications.email')") && str_contains($settingsSource, "->set('notifications.email', ")); +check('the default acme.settings sets notifications.email', (Yaml::decode((string) file_get_contents("$module/config/install/acme.settings.yml"))['notifications']['email'] ?? null) === true); +check('ConfigFormBaseTrait::config() gives an editable Config for the names getEditableConfigNames() lists', str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Form/ConfigFormBaseTrait.php"), '@return \\Drupal\\Core\\Config\\Config|\\Drupal\\Core\\Config\\ImmutableConfig')); +check('Only an ImmutableConfig refuses set()', (new ReflectionMethod(\Drupal\Core\Config\ImmutableConfig::class, 'set'))->getDeclaringClass()->getName() === \Drupal\Core\Config\ImmutableConfig::class); +check('Greeter reads system.site', str_contains((string) file_get_contents("$module/src/Greeter.php"), "->get('system.site')")); +check("core's system module ships and describes system.site", is_file("$web/core/modules/system/config/install/system.site.yml") && ($systemSchema['system.site']['type'] ?? null) === 'config_object'); + // ─── Hooks and theme hooks ────────────────────────────────────────────────── require_once "$module/acme.module"; diff --git a/examples/drupal/web/modules/custom/acme/acme.permissions.yml b/examples/drupal/web/modules/custom/acme/acme.permissions.yml new file mode 100644 index 000000000..07d5cd1b0 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/acme.permissions.yml @@ -0,0 +1,8 @@ +# Try: find the references of a permission to see the route, the entity +# type, and the access checks that require it. +administer acme: + title: 'Administer acme' + description: 'Change the greeting and manage every note.' + restrict access: true +view acme notes: + title: 'View acme notes' diff --git a/examples/drupal/web/modules/custom/acme/acme.routing.yml b/examples/drupal/web/modules/custom/acme/acme.routing.yml index 7f9f9f3f1..34a4e8485 100644 --- a/examples/drupal/web/modules/custom/acme/acme.routing.yml +++ b/examples/drupal/web/modules/custom/acme/acme.routing.yml @@ -19,4 +19,6 @@ acme.note: _controller: '\Drupal\acme\Controller\NoteController::view' _title_callback: '\Drupal\acme\Controller\NoteController::title' requirements: - _permission: 'access content' + # Try: go to the definition of either permission. A visitor needs one + # of them; `,` would ask for both. + _permission: 'view acme notes+administer acme' diff --git a/examples/drupal/web/modules/custom/acme/config/install/acme.settings.yml b/examples/drupal/web/modules/custom/acme/config/install/acme.settings.yml new file mode 100644 index 000000000..4f8636f12 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/config/install/acme.settings.yml @@ -0,0 +1,3 @@ +notify_email: '' +notifications: + email: true diff --git a/examples/drupal/web/modules/custom/acme/config/schema/acme.schema.yml b/examples/drupal/web/modules/custom/acme/config/schema/acme.schema.yml new file mode 100644 index 000000000..56f7e14aa --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/config/schema/acme.schema.yml @@ -0,0 +1,16 @@ +# Try: go to the definition of 'acme.settings' in SettingsForm, which lists +# config/install/acme.settings.yml and then this entry. +acme.settings: + type: config_object + label: 'Acme settings' + mapping: + notify_email: + type: email + label: 'Notification email' + notifications: + type: mapping + label: 'Notifications' + mapping: + email: + type: boolean + label: 'Send notifications by email' diff --git a/examples/drupal/web/modules/custom/acme/src/Entity/Note.php b/examples/drupal/web/modules/custom/acme/src/Entity/Note.php index e82bc3777..fa361f540 100644 --- a/examples/drupal/web/modules/custom/acme/src/Entity/Note.php +++ b/examples/drupal/web/modules/custom/acme/src/Entity/Note.php @@ -23,6 +23,8 @@ 'label' => 'title', ], base_table: 'acme_note', + // Try: complete a permission inside `admin_permission: ''`. + admin_permission: 'administer acme', )] final class Note extends ContentEntityBase { diff --git a/examples/drupal/web/modules/custom/acme/src/Form/SettingsForm.php b/examples/drupal/web/modules/custom/acme/src/Form/SettingsForm.php new file mode 100644 index 000000000..4dc34b20c --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/Form/SettingsForm.php @@ -0,0 +1,66 @@ + + */ + protected function getEditableConfigNames(): array + { + // Try: go to the definition of 'acme.settings', which lists its + // default configuration and its schema entry, or complete a + // configuration name inside `['']`; core's are offered too. + return ['acme.settings']; + } + + /** + * @param array $form + * @return array + */ + public function buildForm(array $form, FormStateInterface $form_state): array + { + // Try: go to the definition of 'acme.settings' before the `:`, or + // of 'notify_email' after it, which lands on its schema line. + // Complete a key after the `:`. + $form['notify_email'] = [ + '#type' => 'email', + '#title' => $this->t('Notification email'), + '#config_target' => 'acme.settings:notify_email', + ]; + // Try: complete a key inside `get('')`, read from acme.schema.yml; + // after `notifications.`, the keys under it are offered. Hover + // `$settings`, whose type carries the name 'acme.settings'. + $settings = $this->config('acme.settings'); + $form['notify_email']['#access'] = $settings->get('notifications.email') === true; + + return parent::buildForm($form, $form_state); + } + + /** + * @param array $form + */ + public function submitForm(array &$form, FormStateInterface $form_state): void + { + // Try: complete a key inside `set('')`. An ImmutableConfig, which + // `\Drupal::config()` returns, offers none: it cannot be saved. + $this->config('acme.settings') + ->set('notifications.email', $form_state->getValue('notify_email') !== '') + ->save(); + parent::submitForm($form, $form_state); + } +} diff --git a/examples/drupal/web/modules/custom/acme/src/Greeter.php b/examples/drupal/web/modules/custom/acme/src/Greeter.php index 66048f794..8e203effa 100644 --- a/examples/drupal/web/modules/custom/acme/src/Greeter.php +++ b/examples/drupal/web/modules/custom/acme/src/Greeter.php @@ -19,7 +19,10 @@ public function __construct( public function greet(string $name): string { // Try: complete after `$this->configFactory->`. The methods come - // from core's ConfigFactoryInterface. + // from core's ConfigFactoryInterface. Go to the definition of + // 'system.site', which lists core's default configuration and schema + // entry, or complete a configuration name inside `get('')`. Complete + // a key of system.site inside the second `get('')`. $siteName = $this->configFactory->get('system.site')->get('name'); $greeting = strtr($this->format, [ diff --git a/examples/drupal/web/modules/custom/acme/src/NoteAccess.php b/examples/drupal/web/modules/custom/acme/src/NoteAccess.php new file mode 100644 index 000000000..41bd4fa7d --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/NoteAccess.php @@ -0,0 +1,30 @@ +hasPermission('administer acme'); + } + + public function view(AccountInterface $account): AccessResultInterface + { + // Try: go to the definition of 'access content', which lands in core's + // system.permissions.yml. + return AccessResult::allowedIfHasPermissions($account, ['access content', 'view acme notes'], 'OR'); + } +} diff --git a/src/blade/typed_receiver.rs b/src/blade/typed_receiver.rs index 31864e6ba..79c39bc29 100644 --- a/src/blade/typed_receiver.rs +++ b/src/blade/typed_receiver.rs @@ -170,6 +170,25 @@ impl Backend { site.receiver, loaders.resolved_class_loader(), ) { + if kind == StringKeyKind::DrupalConfigKey { + // The key is one of the object's whose name its + // type carries. + let editable = site.receiver == ReceiverClass::EditableConfigKeys; + for name in self.drupal_config_names_of( + &ty, + editable, + loaders.resolved_class_loader(), + ) { + let mut span = site.to_span(kind); + if let crate::symbol_map::SymbolKind::StringKey { key, .. } = + &mut span.kind + { + *key = crate::drupal::config_keys::config_key(&name, &site.key); + } + confirmed.push(span); + } + continue; + } confirmed.push(site.to_span(kind)); } } @@ -191,13 +210,25 @@ impl Backend { receiver: ReceiverClass, class_loader: &ResolvedClassLoader<'_>, ) -> Option { - if let Some((_, kind)) = receiver - .targets() - .iter() - .find(|(fqn, _)| crate::class_lookup::is_subtype_of_named(ty, fqn, class_loader)) - { + // A Drupal target is not worth a subtype check outside Drupal: a + // Symfony project's `get()` is never asked about the config factory. + if let Some((_, kind)) = receiver.targets().iter().find(|(fqn, kind)| { + (kind.framework() != crate::symbol_map::KeyFramework::Drupal + || self.is_drupal_project()) + && crate::class_lookup::is_subtype_of_named(ty, fqn, class_loader) + }) { return Some(*kind); } + // A class that uses core's `ConfigFormBaseTrait` has its `config()`, + // and a trait is no type a target can name. + if receiver == ReceiverClass::ConfigShortcut && self.is_drupal_project() { + return ty + .top_level_class_names() + .iter() + .filter_map(|class| class_loader(crate::class_lookup::Fqn::new(class))) + .any(|class| crate::drupal::config::uses_config_form_trait(&class, class_loader)) + .then_some(StringKeyKind::DrupalConfigName); + } if !receiver.follows_wrappers() { return None; } diff --git a/src/classmap_scanner/discovery.rs b/src/classmap_scanner/discovery.rs index 9719625ce..d838cc55d 100644 --- a/src/classmap_scanner/discovery.rs +++ b/src/classmap_scanner/discovery.rs @@ -688,6 +688,10 @@ pub fn scan_drupal_directories( if crate::drupal::is_hook_php_file(path, web_root) { declaration_files.declarations.push(path.to_path_buf()); } + } else if crate::drupal::config::is_default_config_file(path, web_root) { + declaration_files.default_config.push(path.to_path_buf()); + } else if crate::drupal::config::is_schema_file(path, web_root) { + declaration_files.config_schemas.push(path.to_path_buf()); } else if crate::drupal::is_declaration_file(path, web_root) { declaration_files.declarations.push(path.to_path_buf()); } @@ -711,6 +715,11 @@ pub struct DrupalDeclarationFiles { /// The service providers whose `alter()` may give a service another /// class (see `drupal::service_providers`). pub service_providers: Vec, + /// The default configuration files, which are never parsed (see + /// `drupal::config`). + pub default_config: Vec, + /// The configuration schema files. + pub config_schemas: Vec, } /// The extensions besides `.php` that Drupal treats as PHP source. diff --git a/src/completion/drupal_config.rs b/src/completion/drupal_config.rs new file mode 100644 index 000000000..de6045c5f --- /dev/null +++ b/src/completion/drupal_config.rs @@ -0,0 +1,270 @@ +//! Completion for Drupal configuration names in PHP, where the string is +//! not a plain argument the Symfony completion reads: an element of the +//! list a config factory's `loadMultiple()` takes or a config form's +//! `getEditableConfigNames()` returns, and a form element's +//! `#config_target` before its `:`. +//! +//! `\Drupal::config('|')`, `new ConfigTarget('|')`, and the config +//! factory's and the `config()` shortcut's calls are read by the Symfony +//! completion, which confirms a method call's receiver. +//! +//! A configuration key is completed one level at a time from the schema of +//! the object it belongs to (see `drupal::config_keys`): in a config +//! object's `get()`, `set()`, and `clear()`, whose receiver's type carries +//! the object's name (see `drupal::types`), after the `:` of a +//! `#config_target`, and in a `ConfigTarget`'s property path. A `get()` +//! is a container lookup's too, so the Symfony completion reads it, +//! resolving its receiver once, and hands its keys to +//! [`Backend::drupal_config_key_response`]. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::completion::eloquent_string::detect_string_call_context; +use crate::completion::laravel_string_keys::string_key_response; +use crate::completion::source::code_context::CodeContext; +use crate::completion::symfony_string_keys::{is_instantiation, trailing_name}; +use crate::symbol_map::StringKeyKind; +use crate::text_position::position_to_offset; +use crate::types::{AccessKind, FileContext}; + +impl Backend { + /// Complete the configuration name the string at the cursor names. + pub(crate) fn try_drupal_config_name_completion( + &self, + uri: &str, + content: &str, + position: Position, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let (quote, _) = code.open_string?; + let literal_start = quote + 1; + let names = match code.last_code_byte()? { + // `'#config_target' => '|'`: the name comes before the `:`, and + // the key after it. + b'>' => { + ends_with_key(code.code_before, "#config_target") + && !content[literal_start..cursor].contains(':') + } + b'[' | b',' => { + let [.., inner] = &code.open_brackets[..] else { + return None; + }; + inner.byte == b'[' + && (returns_editable_names(content, inner.offset) + || self.is_config_list(content, cursor, code, ctx)) + } + _ => false, + }; + if !names { + return None; + } + self.symfony_key_response( + uri, + StringKeyKind::DrupalConfigName, + content, + literal_start, + cursor, + position, + ) + } + + /// Complete the configuration key the string at the cursor names. + pub(crate) fn try_drupal_config_key_completion( + &self, + content: &str, + position: Position, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let (quote, _) = code.open_string?; + let literal_start = quote + 1; + let typed = &content[literal_start..cursor]; + let (names, key_start) = match code.last_code_byte()? { + // `'#config_target' => 'acme.settings:|'`. + b'>' if ends_with_key(code.code_before, "#config_target") => { + let (name, _) = typed.split_once(':')?; + (vec![name.to_string()], literal_start + name.len() + 1) + } + b'(' | b',' | b'[' => ( + self.config_key_owners(content, cursor, code, ctx)?, + literal_start, + ), + _ => return None, + }; + self.drupal_config_key_response(&names, content, key_start, position) + } + + /// Offer the keys of the configuration objects `names` at the level the + /// key typed from `key_start` to the cursor has reached. + pub(crate) fn drupal_config_key_response( + &self, + names: &[String], + content: &str, + key_start: usize, + position: Position, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let path = content.get(key_start..cursor)?; + let (parent, partial) = path.rsplit_once('.').unwrap_or(("", path)); + let mut keys: Vec = names + .iter() + .flat_map(|name| self.drupal_config_keys(name, parent)) + .map(|key| key.name) + .filter(|key| key.starts_with(partial)) + .collect(); + keys.sort_unstable(); + keys.dedup(); + string_key_response( + keys, + CompletionItemKind::Field, + content, + cursor - partial.len(), + position, + ) + } + + /// The configuration objects whose keys the string at the cursor names, + /// as an argument or an element of one: the names the receiver's type + /// of a config object's `set()` or `clear()` carries, or the name a + /// `new ConfigTarget()` takes before its property path. + fn config_key_owners( + &self, + content: &str, + cursor: usize, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option> { + let call = detect_string_call_context(content, cursor, code)?; + let paren = code.enclosing_paren()?; + let in_list = match &code.open_brackets[..] { + [.., outer, inner] if inner.byte == b'[' && outer.byte == b'(' => true, + [.., inner] if inner.byte == b'(' => false, + _ => return None, + }; + let Some(subject) = call.subject.as_deref() else { + let written = trailing_name(&content[..paren.code_before]); + let offset = paren.code_before - written.len(); + let class = ctx.resolve_name_at(written, offset as u32); + if !is_instantiation(&content[..offset]) + || !crate::drupal::is_config_target_class(&class) + || call.arg_index != 1 + { + return None; + } + let name = + crate::completion::drupal_route_params::string_argument(content, paren.offset, 0)?; + return Some(vec![name]); + }; + // A `get()` is a container lookup's too, which the Symfony names + // read, resolving its receiver once for both. + let editable = call.method_name.eq_ignore_ascii_case("set") + || call.method_name.eq_ignore_ascii_case("clear"); + if in_list || call.is_static || call.arg_index != 0 || !editable { + return None; + } + let loaders = self.file_loaders(ctx); + let rctx = loaders.resolution_ctx_at(content, cursor as u32); + let mut names: Vec = Vec::new(); + for resolved in + crate::type_engine::resolver::resolve_target_classes(subject, AccessKind::Arrow, &rctx) + { + for name in self.drupal_config_names_of( + &resolved.type_string, + editable, + loaders.resolved_class_loader(), + ) { + if !names.contains(&name) { + names.push(name); + } + } + } + (!names.is_empty()).then_some(names) + } + + /// Whether the array at the cursor is the list of names a config + /// factory's `loadMultiple()` takes. + fn is_config_list( + &self, + content: &str, + cursor: usize, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> bool { + let Some((_, paren)) = code.nested_pair(b'[', b'(') else { + return false; + }; + let Some(call) = detect_string_call_context(content, cursor, code) else { + return false; + }; + let Some(subject) = call.subject.as_deref() else { + return false; + }; + let opens_call = code + .enclosing_paren() + .is_some_and(|enclosing| enclosing.offset == paren); + opens_call + && !call.is_static + && call.arg_index == 0 + && call.method_name.eq_ignore_ascii_case("loadMultiple") + && self.receiver_call_key(subject, &call.method_name, 0, content, cursor, ctx) + == Some(StringKeyKind::DrupalConfigName) + } +} + +/// Whether the code before the cursor ends with `'' =>`. +fn ends_with_key(code_before: &str, key: &str) -> bool { + let Some(before) = code_before.strip_suffix("=>") else { + return false; + }; + let before = before.trim_end(); + b"'\"".iter().any(|quote| { + before + .strip_suffix(*quote as char) + .and_then(|rest| rest.strip_suffix(key)) + .is_some_and(|rest| rest.ends_with(*quote as char)) + }) +} + +/// Whether the array opened at `bracket` is returned by a method named +/// `getEditableConfigNames`: `return [` right inside it. +fn returns_editable_names(content: &str, bracket: usize) -> bool { + let before = content[..bracket].trim_end(); + if !before.ends_with("return") { + return false; + } + let Some(at) = before.rfind("function") else { + return false; + }; + let name = content[at + "function".len()..].trim_start(); + name.starts_with("getEditableConfigNames") + && name["getEditableConfigNames".len()..] + .trim_start() + .starts_with('(') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_key_is_read_before_the_arrow() { + assert!(ends_with_key("['#config_target' =>", "#config_target")); + assert!(ends_with_key("[\"#config_target\"=>", "#config_target")); + assert!(!ends_with_key("['x#config_target' =>", "#config_target")); + assert!(!ends_with_key("['#title' =>", "#config_target")); + } + + #[test] + fn only_the_editable_names_method_returns_names() { + let php = "protected function getEditableConfigNames(): array {\n return ['"; + assert!(returns_editable_names(php, php.len() - 2)); + let php = "protected function other(): array {\n return ['"; + assert!(!returns_editable_names(php, php.len() - 2)); + let php = "protected function getEditableConfigNames(): array {\n $x = ['"; + assert!(!returns_editable_names(php, php.len() - 2)); + } +} diff --git a/src/completion/drupal_permissions.rs b/src/completion/drupal_permissions.rs new file mode 100644 index 000000000..fefbb38b8 --- /dev/null +++ b/src/completion/drupal_permissions.rs @@ -0,0 +1,173 @@ +//! Completion for Drupal permission names in PHP, where the call or array +//! that reads one is not a plain argument the Symfony completion reads: the +//! permissions `AccessResult::allowedIfHasPermission()` takes second, the +//! lists `allowedIfHasPermissions()` and the user module's +//! `user_role_grant_permissions()` and `user_role_revoke_permissions()` +//! take, a route's `_permission` requirement (each part of `a+b` and `a,b`), +//! and an entity type annotation's `admin_permission = "…"`. +//! +//! An account's or a role's `hasPermission('|')` is a call on a typed +//! receiver, which the Symfony completion confirms (see +//! `symfony_receiver_method`), and an entity type attribute's +//! `admin_permission:` an attribute argument, which it reads too. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::completion::eloquent_string::detect_string_call_context; +use crate::completion::source::code_context::CodeContext; +use crate::completion::symfony_string_keys::{is_instantiation, trailing_name}; +use crate::symbol_map::StringKeyKind; +use crate::text_position::position_to_offset; +use crate::types::FileContext; + +impl Backend { + /// Complete the permission the string at the cursor names. + pub(crate) fn try_drupal_permission_completion( + &self, + uri: &str, + content: &str, + position: Position, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let (quote, _) = code.open_string?; + let literal_start = quote + 1; + let start = match code.last_code_byte()? { + // `'_permission' => '|'`, in a route's requirements array. + b'>' if names_permission_requirement(code.code_before) => { + requirement_part_start(content, literal_start, cursor) + } + b'(' | b',' | b'[' => { + self.permission_argument_start(content, cursor, literal_start, code, ctx)? + } + _ => return None, + }; + self.symfony_key_response( + uri, + StringKeyKind::DrupalPermission, + content, + start, + cursor, + position, + ) + } + + /// Where the permission being typed starts, when the string at the + /// cursor is an argument, or an element of an array argument, of a call + /// that reads one. + fn permission_argument_start( + &self, + content: &str, + cursor: usize, + literal_start: usize, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let call = detect_string_call_context(content, cursor, code)?; + let paren = code.enclosing_paren()?; + // An array's element, right inside the call's argument. + let in_list = match &code.open_brackets[..] { + [.., outer, inner] if inner.byte == b'[' && outer.byte == b'(' => true, + [.., inner] if inner.byte == b'(' => false, + _ => return None, + }; + if call.arg_index != 1 { + return None; + } + let Some(subject) = call.subject.as_deref() else { + let written = trailing_name(&content[..paren.code_before]); + let offset = paren.code_before - written.len(); + return (in_list + && !is_instantiation(&content[..offset]) + && crate::drupal::permissions::is_role_permissions_function(written)) + .then_some(literal_start); + }; + if call.is_static { + let class = ctx.resolve_name_at(subject, cursor as u32); + return (crate::drupal::permissions::static_permission_call(&class, &call.method_name) + == Some(in_list)) + .then_some(literal_start); + } + // `setRequirement('_permission', '|')` on a route. + (!in_list + && call.method_name.eq_ignore_ascii_case("setRequirement") + && first_argument_is_permission_key(content, paren.offset)) + .then(|| requirement_part_start(content, literal_start, cursor)) + } + + /// Complete the permission an entity type annotation's + /// `admin_permission = "…"` names, in the docblock at the cursor. + pub(crate) fn try_drupal_annotation_permission_completion( + &self, + uri: &str, + content: &str, + position: Position, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let line_start = content[..cursor].rfind('\n').map_or(0, |at| at + 1); + let before = &content[line_start..cursor]; + let at = before.rfind("admin_permission")?; + let rest = before[at + "admin_permission".len()..] + .trim_start() + .strip_prefix('=')? + .trim_start() + .strip_prefix('"')?; + if rest.contains('"') { + return None; + } + let start = cursor - rest.len(); + self.symfony_key_response( + uri, + StringKeyKind::DrupalPermission, + content, + start, + cursor, + position, + ) + } +} + +/// Whether the code before the cursor ends with `'_permission' =>`. +fn names_permission_requirement(code_before: &str) -> bool { + let Some(key) = code_before.strip_suffix("=>") else { + return false; + }; + let key = key.trim_end(); + key.ends_with("'_permission'") || key.ends_with("\"_permission\"") +} + +/// Where the part of a `_permission` requirement being typed starts: after +/// the last `,` or `+` typed in the literal. +fn requirement_part_start(content: &str, literal_start: usize, cursor: usize) -> usize { + content[literal_start..cursor] + .rfind([',', '+']) + .map_or(literal_start, |at| literal_start + at + 1) +} + +/// Whether the call whose `(` is at `paren` passes `'_permission'` first. +fn first_argument_is_permission_key(content: &str, paren: usize) -> bool { + let after = content[paren + 1..].trim_start(); + after.starts_with("'_permission'") || after.starts_with("\"_permission\"") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_requirement_key_is_read_before_the_arrow() { + assert!(names_permission_requirement("['_permission' =>")); + assert!(names_permission_requirement("[\"_permission\"=>")); + assert!(!names_permission_requirement("['_access' =>")); + assert!(!names_permission_requirement("['_permission',")); + } + + #[test] + fn a_requirement_part_starts_after_its_separator() { + let php = "'access content+adm"; + assert_eq!(requirement_part_start(php, 1, php.len()), 16); + assert_eq!(requirement_part_start(php, 1, 5), 1); + } +} diff --git a/src/completion/drupal_route_params.rs b/src/completion/drupal_route_params.rs index 33e0b2775..de1ff4c06 100644 --- a/src/completion/drupal_route_params.rs +++ b/src/completion/drupal_route_params.rs @@ -115,7 +115,7 @@ impl Backend { /// The string literal the call whose `(` is at `paren` passes as its /// argument `index`, when the argument is one. -fn string_argument(content: &str, paren: usize, index: usize) -> Option { +pub(crate) fn string_argument(content: &str, paren: usize, index: usize) -> Option { let bytes = content.as_bytes(); let mut argument = 0; let mut depth = 0usize; diff --git a/src/completion/handler/phpdoc.rs b/src/completion/handler/phpdoc.rs index 6f4bbb6dc..dd35993f9 100644 --- a/src/completion/handler/phpdoc.rs +++ b/src/completion/handler/phpdoc.rs @@ -85,6 +85,16 @@ impl Backend { )); } + // ── Drupal entity type annotations ────────────────────── + // `admin_permission = "|"` names a permission. + if in_docblock + && self.is_drupal_project() + && let Some(response) = + self.try_drupal_annotation_permission_completion(uri, content, position) + { + return ControlFlow::Break(Some(response)); + } + // ── Docblock type / variable completion ───────────────── // Always short-circuits when inside a docblock. if in_docblock { diff --git a/src/completion/handler/string_arguments.rs b/src/completion/handler/string_arguments.rs index 75db01be6..6dd0c32a6 100644 --- a/src/completion/handler/string_arguments.rs +++ b/src/completion/handler/string_arguments.rs @@ -108,6 +108,36 @@ impl Backend { return Some(response); } + // ── Drupal permissions ────────────────────────────────── + // The lists and requirement arrays that name permissions, which + // the Symfony names below do not read. An account's + // `hasPermission('|')` is left to them, which confirm its receiver. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = + self.try_drupal_permission_completion(uri, content, position, code, ctx) + { + return Some(response); + } + + // ── Drupal configuration names and keys ───────────────── + // The lists that name configuration objects, a form element's + // `#config_target`, and the keys a config object's `set()` and + // `clear()` write. A `get()`'s keys come with the Symfony names + // below, which resolve its receiver as a container lookup's. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = self + .try_drupal_config_key_completion(content, position, code, ctx) + .or_else(|| { + self.try_drupal_config_name_completion(uri, content, position, code, ctx) + }) + { + return Some(response); + } + // ── Symfony form options ──────────────────────────────── // `$builder->add('email', EmailType::class, ['|' => …])`, wherever // the form component is used. diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index a202ffe70..b52452662 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -35,6 +35,9 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { CompletionItemKind::File } StringKeyKind::Trans | StringKeyKind::SymfonyTranslation => CompletionItemKind::Text, + StringKeyKind::DrupalPermission => CompletionItemKind::Constant, + StringKeyKind::DrupalConfigName => CompletionItemKind::File, + StringKeyKind::DrupalConfigKey => CompletionItemKind::Field, StringKeyKind::TranslationDomain => CompletionItemKind::Module, StringKeyKind::MorphAlias => CompletionItemKind::EnumMember, StringKeyKind::Middleware => CompletionItemKind::Class, @@ -191,7 +194,9 @@ impl Backend { | StringKeyKind::SerializerGroup | StringKeyKind::YiiRoute | StringKeyKind::DrupalEntityType - | StringKeyKind::DrupalRoute => self.symfony_declared_names(*kind).into(), + | StringKeyKind::DrupalRoute + | StringKeyKind::DrupalPermission + | StringKeyKind::DrupalConfigName => self.symfony_declared_names(*kind).into(), StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => { self.twig_callable_names(*kind).into() } @@ -210,6 +215,7 @@ impl Backend { | StringKeyKind::SymfonyConfigKey | StringKeyKind::YiiRouteArgument | StringKeyKind::DrupalRouteParameter + | StringKeyKind::DrupalConfigKey | StringKeyKind::Section | StringKeyKind::Stack | StringKeyKind::TwigBlock diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 3229355e1..1e8287ec9 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -79,8 +79,10 @@ pub mod array_shape; pub(crate) mod builder; pub(crate) mod command_params; mod doctrine_fields; +mod drupal_config; pub(crate) mod drupal_hooks; -mod drupal_route_params; +mod drupal_permissions; +pub(crate) mod drupal_route_params; pub(crate) mod eloquent_model_attributes; pub(crate) mod eloquent_string; pub(crate) mod handler; diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index 8e5678588..ce508aad1 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -87,11 +87,22 @@ impl Backend { return Some(response); } + let mut receiver_type = None; let (kind, prefix_start) = self - .symfony_call_key(content, cursor, code, ctx) + .symfony_call_key(content, cursor, code, ctx, &mut receiver_type) .map(|kind| (kind, literal_start)) .or_else(|| attribute_key(content, code, literal, literal_start, ctx)) .filter(|(kind, _)| self.string_key_kind_enabled(*kind))?; + // A config object's `get()`: its keys come from the name its type, + // resolved once above, carries. + if kind == StringKeyKind::DrupalConfigKey { + let names = self.drupal_config_names_of( + receiver_type.as_ref()?, + false, + self.file_loaders(ctx).resolved_class_loader(), + ); + return self.drupal_config_key_response(&names, content, prefix_start, position); + } if kind == StringKeyKind::YiiRouteArgument { let arguments = handler_at(ctx, literal_start as u32) .and_then(|handler| self.yii_handler_arguments(&handler)) @@ -347,6 +358,7 @@ impl Backend { cursor: usize, code: &CodeContext<'_>, ctx: &FileContext, + receiver_type: &mut Option, ) -> Option { // A name is the argument itself, not an element of an array one. if !matches!(code.last_code_byte(), Some(b'(' | b',')) { @@ -365,6 +377,9 @@ impl Backend { if call.arg_index == 0 && crate::drupal::is_url_class(&class) { return Some(StringKeyKind::DrupalRoute); } + if call.arg_index == 0 && crate::drupal::is_config_target_class(&class) { + return Some(StringKeyKind::DrupalConfigName); + } is_translatable_message(&class) } else { let function = resolve_function_name(ctx, written, offset); @@ -393,6 +408,9 @@ impl Backend { if crate::drupal::is_entity_query(&class, &call.method_name) { return Some(StringKeyKind::DrupalEntityType); } + if crate::drupal::is_config_lookup(&class, &call.method_name) { + return Some(StringKeyKind::DrupalConfigName); + } } return (crate::drupal::static_route_argument(&class, &call.method_name) == Some(call.arg_index)) @@ -410,14 +428,18 @@ impl Backend { ctx, ); } - self.receiver_call_key( + // The receiver's type is kept for what it carries besides its + // class: a config object's name. + let (receiver, _) = symfony_receiver_method(&call.method_name, call.arg_index)?; + let (kind, ty) = self.receiver_key_and_type( subject, - &call.method_name, - call.arg_index, + (receiver, &call.method_name, call.arg_index), content, cursor, ctx, - ) + )?; + *receiver_type = Some(ty); + Some(kind) } /// What the argument `index` of a call of `method` on `subject`, written @@ -445,13 +467,31 @@ impl Backend { cursor: usize, ctx: &FileContext, ) -> Option { + self.receiver_key_and_type(subject, (receiver, method, index), content, cursor, ctx) + .map(|(kind, _)| kind) + } + + /// [`Self::receiver_key_for`], with the type the receiver resolved to. + fn receiver_key_and_type( + &self, + subject: &str, + (receiver, method, index): (crate::symbol_map::ReceiverClass, &str, usize), + content: &str, + cursor: usize, + ctx: &FileContext, + ) -> Option<(StringKeyKind, crate::php_type::PhpType)> { + // Resolving the receiver is the costly part, and is wasted on a + // call whose every kind of key the project does not read. + if !receiver.names_any(|kind| self.string_key_kind_enabled(kind)) { + return None; + } let loaders = self.file_loaders(ctx); let class_loader = loaders.resolved_class_loader(); let rctx = loaders.resolution_ctx_at(content, cursor as u32); crate::type_engine::resolver::resolve_target_classes(subject, AccessKind::Arrow, &rctx) .into_iter() - .filter_map(|resolved| resolved.class_info) - .find_map(|class| { + .find_map(|resolved| { + let class = resolved.class_info?; self.typed_receiver_key_kind( &crate::php_type::PhpType::named(class.fqn()), method, @@ -459,10 +499,11 @@ impl Backend { receiver, class_loader, ) + .map(|kind| (kind, resolved.type_string)) }) } - fn symfony_key_response( + pub(crate) fn symfony_key_response( &self, uri: &str, kind: StringKeyKind, @@ -626,7 +667,11 @@ impl Backend { StringKeyKind::DrupalRoute, crate::drupal::links::links_route_name_start(content, cursor)?, ), - crate::drupal::DeclarationFile::Routing => return None, + crate::drupal::DeclarationFile::Routing => ( + StringKeyKind::DrupalPermission, + crate::drupal::permissions::routing_permission_start(content, cursor)?, + ), + crate::drupal::DeclarationFile::Permissions => return None, } } else if !is_symfony { return None; @@ -728,6 +773,11 @@ fn attribute_key( ) .then_some((StringKeyKind::DrupalHook, literal_start)); } + if matches!(slot, ArgumentSlot::Named("admin_permission")) + && crate::symbol_map::extraction::is_entity_type_attribute(&attribute) + { + return Some((StringKeyKind::DrupalPermission, literal_start)); + } if is_message_handler_attribute(&attribute) { return matches!( slot, diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index ba36feccd..64ea605e9 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -97,11 +97,17 @@ impl CheckedStringKind { // an entity type in PHP, through `hook_entity_type_build()`. // Drupal sends a route parameter no path declares as the query // string. A serializer group nothing puts a member in selects - // nothing, which is no error either. + // nothing, which is no error either. A module can declare + // permissions in PHP, through a permissions file's + // `permission_callbacks`. Configuration is created at runtime + // too, by the configuration a site exports and by code. StringKeyKind::WordPressHook | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRouteParameter | StringKeyKind::DrupalHook + | StringKeyKind::DrupalPermission + | StringKeyKind::DrupalConfigName + | StringKeyKind::DrupalConfigKey | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack diff --git a/src/drupal/config.rs b/src/drupal/config.rs new file mode 100644 index 000000000..05ac79106 --- /dev/null +++ b/src/drupal/config.rs @@ -0,0 +1,637 @@ +//! Drupal's configuration names: the configuration objects a site's +//! extensions ship default configuration for, and the ones their +//! configuration schema describes. +//! +//! An extension's default configuration is one file per configuration +//! object under `config/install` or `config/optional`, named after the +//! object. The files are never read: their names are the names. Core +//! alone ships hundreds of them. +//! +//! The configuration schema, `config/schema/*.schema.yml`, describes each +//! configuration object under its name, with a `type` of `config_object` or +//! of a type built on it, next to reusable data types and the configuration +//! entities' wildcard entries (`node.type.*`). It is read when a name is +//! first asked for, and kept until a schema file or the list of default +//! configuration files changes. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tower_lsp_server::gen_lsp_types::{Location, Range}; + +use crate::Backend; + +/// The schema type every configuration object is built on. +const CONFIG_OBJECT: &str = "config_object"; + +/// Whether `path` is a default configuration file of an extension under +/// `web_root`: a `*.yml` right inside a `config/install` or +/// `config/optional` folder. +pub(crate) fn is_default_config_file(path: &Path, web_root: &Path) -> bool { + has_default_config_shape(path) && is_loaded(path, web_root) +} + +/// Whether `path` is a `*.yml` right inside a `config/install` or +/// `config/optional` folder. +fn has_default_config_shape(path: &Path) -> bool { + path.extension().is_some_and(|extension| extension == "yml") + && path + .parent() + .and_then(|folder| { + let name = folder.file_name()?; + (name == "install" || name == "optional").then_some(folder) + }) + .and_then(Path::parent) + .is_some_and(|config| config.file_name().is_some_and(|name| name == "config")) +} + +/// Whether `path` is a configuration schema file of an extension, or of +/// core, under `web_root`: a `*.schema.yml` right inside a `config/schema` +/// folder. +pub(crate) fn is_schema_file(path: &Path, web_root: &Path) -> bool { + has_schema_shape(path) && is_loaded(path, web_root) +} + +/// Whether `path` is a `*.schema.yml` right inside a `config/schema` +/// folder. +fn has_schema_shape(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".schema.yml")) + && path + .parent() + .filter(|folder| folder.file_name().is_some_and(|name| name == "schema")) + .and_then(Path::parent) + .is_some_and(|config| config.file_name().is_some_and(|name| name == "config")) +} + +/// Whether `class`, or a class it extends, uses core's +/// `ConfigFormBaseTrait`, whose `config()` reads a configuration object as +/// a config form's does. A trait is no type, so the subtype checks that +/// find the `config()` of controllers and forms do not find it. +pub(crate) fn uses_config_form_trait( + class: &crate::types::ClassInfo, + class_loader: &crate::class_lookup::ResolvedClassLoader<'_>, +) -> bool { + const CONFIG_FORM_TRAIT: &str = "Drupal\\Core\\Form\\ConfigFormBaseTrait"; + let uses = |class: &crate::types::ClassInfo| { + class.used_traits.iter().any(|used| { + used.trim_start_matches('\\') + .eq_ignore_ascii_case(CONFIG_FORM_TRAIT) + }) + }; + uses(class) + || crate::inheritance::ancestors(class, class_loader).any(|(_, parent)| uses(&parent)) +} + +/// Whether Drupal reads `path`: it is not a test module's, and not in a +/// folder Drupal never loads. +fn is_loaded(path: &Path, web_root: &Path) -> bool { + !super::is_test_fixture(path, web_root) && !super::is_unloaded(path, web_root) +} + +/// The configuration name a default configuration file ships. +fn default_config_name(path: &Path) -> Option<&str> { + path.file_name()?.to_str()?.strip_suffix(".yml") +} + +/// The names to look a configuration name's schema up under, in the order +/// Drupal's `TypedConfigManager::getFallbackName()` tries them: the name +/// itself, then the name with its last part that is not a wildcard +/// replaced by `*`, each followed by the same with its trailing wildcards +/// collapsed into one (`rest.resource.entity.node`, `rest.resource.entity.*`, +/// `rest.resource.*.*`, `rest.resource.*`, …). +pub(crate) fn schema_lookup_names(name: &str) -> impl Iterator { + static LAST_PART: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex::Regex::new(r"([^.:]+)([.:*]*)$").expect("valid")); + static TRAILING: std::sync::LazyLock = + std::sync::LazyLock::new(|| regex::Regex::new(r"\.([:.*]*)$").expect("valid")); + let mut names = vec![name.to_string()]; + let mut current = name.to_string(); + loop { + let replaced = LAST_PART.replace(¤t, "*${2}").into_owned(); + if replaced == current { + break; + } + let one_star = TRAILING.replace(&replaced, ".*").into_owned(); + names.push(replaced.clone()); + if one_star != replaced { + names.push(one_star); + } + current = replaced; + } + names.into_iter() +} + +/// One top-level entry of a schema file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SchemaEntry { + pub uri: String, + /// The byte range of the entry's key. + pub start: u32, + pub end: u32, + /// The `type` it gives. + pub ty: Option, + pub label: Option, +} + +/// The configuration names of a site, and where each is declared. +#[derive(Debug, Default)] +pub(crate) struct ConfigIndex { + /// Every configuration name, sorted. + names: Vec, + /// The default configuration files of each name. + defaults: HashMap>, + /// Each top-level schema entry, by its key, the first one read kept. + schema: HashMap, + /// The schema files read for their keys, by URI (see + /// [`super::config_keys`]). + pub(super) trees: parking_lot::Mutex>>>, + /// The test modules' files that were open when it was read, which + /// count only while they are. + open_test_files: Vec<(PathBuf, bool)>, +} + +impl ConfigIndex { + /// The index of the default configuration files `defaults` and the + /// schema files `schemas`, each with its URI and content. + pub(crate) fn build<'a>( + defaults: &[PathBuf], + schemas: impl IntoIterator, + ) -> Self { + let mut index = Self::default(); + for (uri, content) in schemas { + for entry in schema_entries(uri, content) { + index.schema.entry(entry.0).or_insert(entry.1); + } + } + for path in defaults { + if let Some(name) = default_config_name(path) { + index + .defaults + .entry(name.to_string()) + .or_default() + .push(path.clone()); + } + } + let mut names: Vec = index + .schema + .iter() + .filter(|(key, entry)| { + is_config_name(key) && index.derives_from_config_object(entry.ty.as_deref()) + }) + .map(|(key, _)| key.clone()) + .chain(index.defaults.keys().cloned()) + .collect(); + names.sort_unstable(); + names.dedup(); + index.names = names; + index + } + + /// Every configuration name, sorted. + pub(crate) fn names(&self) -> &[String] { + &self.names + } + + /// The schema entry under the key `key` itself. + pub(crate) fn schema_named(&self, key: &str) -> Option<&SchemaEntry> { + self.schema.get(key) + } + + /// The default configuration files that ship `name`. + pub(crate) fn default_files(&self, name: &str) -> &[PathBuf] { + self.defaults.get(name).map_or(&[], Vec::as_slice) + } + + /// The schema entry that describes `name`, and the key it is under: + /// its own, or the first wildcard entry that matches it. + pub(crate) fn schema_entry(&self, name: &str) -> Option<(String, &SchemaEntry)> { + schema_lookup_names(name).find_map(|key| { + let entry = self.schema.get(&key)?; + Some((key, entry)) + }) + } + + /// Whether the schema type `ty` is `config_object` or built on it, + /// following named types through the schema. + fn derives_from_config_object(&self, ty: Option<&str>) -> bool { + let mut seen: Vec<&str> = Vec::new(); + let mut current = ty; + while let Some(ty) = current { + if ty == CONFIG_OBJECT { + return true; + } + // A placeholder (`[%parent.type]`) is only known per value. + if ty.contains(['[', '%']) || seen.contains(&ty) { + return false; + } + seen.push(ty); + current = self.schema.get(ty).and_then(|entry| entry.ty.as_deref()); + } + false + } +} + +/// Whether a schema key can be a configuration name: Drupal requires a +/// namespace (a `.`), and a `*` stands for a configuration entity's +/// names. +fn is_config_name(key: &str) -> bool { + key.contains('.') && !key.contains(['*', ':', '?', '<', '>', '\'', '"', '/', '\\']) +} + +/// The top-level entries of a schema file, each with its key. +fn schema_entries(uri: &str, content: &str) -> Vec<(String, SchemaEntry)> { + let mut entries: Vec<(String, SchemaEntry)> = Vec::new(); + // The indentation of the current entry's settings, once seen. + let mut child_indent: Option = None; + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + let indent = crate::framework::leading_spaces(semantic); + let text = &semantic[indent..]; + if text.trim().is_empty() || text.starts_with('-') { + continue; + } + let Some(colon) = yaml_key_end(text) else { + continue; + }; + let (key, (quote, _)) = crate::framework::strip_yaml_quotes(text[..colon].trim_end()); + if indent == 0 { + child_indent = None; + let start = (line_start + quote) as u32; + entries.push(( + key.to_string(), + SchemaEntry { + uri: uri.to_string(), + start, + end: start + key.len() as u32, + ty: None, + label: None, + }, + )); + continue; + } + let Some((_, entry)) = entries.last_mut() else { + continue; + }; + if *child_indent.get_or_insert(indent) != indent { + continue; + } + let value = crate::framework::strip_yaml_quotes(text[colon + 1..].trim()) + .0 + .to_string(); + match key { + "type" if !value.is_empty() => entry.ty = Some(value), + "label" if !value.is_empty() => entry.label = Some(value), + _ => {} + } + } + entries +} + +/// Where the key of a YAML `key: value` line ends: at the first `:` +/// followed by a space or the end of the line, outside quotes. A key such +/// as `block.settings.system_menu_block:*` holds a `:` of its own. +pub(super) fn yaml_key_end(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut quote = None; + for (at, byte) in bytes.iter().enumerate() { + match (quote, byte) { + (None, b'\'' | b'"') if at == 0 => quote = Some(*byte), + (Some(open), _) if *byte == open => quote = None, + (None, b':') if bytes.get(at + 1).is_none_or(|next| *next == b' ') => { + return Some(at); + } + _ => {} + } + } + None +} + +impl Backend { + /// Remember the default configuration files and schema files the + /// Drupal walk found. + pub(crate) fn note_drupal_config_files(&self, defaults: Vec, schemas: Vec) { + self.drupal.default_config_files.write().extend(defaults); + self.drupal.config_schema_files.write().extend(schemas); + *self.drupal.config_index.write() = None; + } + + /// The configuration names of the site, and where each is declared, + /// read on first use. + pub(crate) fn drupal_config_index(&self) -> Arc { + if let Some(index) = self.drupal.config_index.read().as_ref() { + return Arc::clone(index); + } + let mut defaults = self.drupal.default_config_files.read().clone(); + let mut schema_files = self.drupal.config_schema_files.read().clone(); + // A test module's files count while they are open, as its other + // declaration files do. + let open_test_files = self.open_drupal_test_config_files(); + for (path, is_schema) in open_test_files.iter().cloned() { + let files = if is_schema { + &mut schema_files + } else { + &mut defaults + }; + if !files.contains(&path) { + files.push(path); + } + } + let schemas: Vec<(String, Arc)> = schema_files + .iter() + .filter_map(|path| { + let uri = crate::util::path_to_uri(path); + let content = self.get_file_content_arc(&uri)?; + Some((uri, content)) + }) + .collect(); + let mut index = ConfigIndex::build( + &defaults, + schemas + .iter() + .map(|(uri, content)| (uri.as_str(), content.as_str())), + ); + index.open_test_files = open_test_files; + let index = Arc::new(index); + *self.drupal.config_index.write() = Some(Arc::clone(&index)); + index + } + + /// The open default configuration and schema files of test modules, + /// each with whether it is a schema file, sorted. + fn open_drupal_test_config_files(&self) -> Vec<(PathBuf, bool)> { + let roots = self.drupal.web_roots.read().clone(); + let mut files: Vec<(PathBuf, bool)> = self + .open_files + .read() + .keys() + .filter(|uri| uri.ends_with(".yml") && uri.contains("/config/")) + .filter_map(|uri| crate::util::uri_to_path(uri)) + .filter_map(|path| { + let root = roots.iter().find(|root| path.starts_with(root))?; + let in_test_module = + super::is_test_fixture(&path, root) && !super::is_unloaded(&path, root); + let is_schema = has_schema_shape(&path); + (in_test_module && (is_schema || has_default_config_shape(&path))) + .then_some((path, is_schema)) + }) + .collect(); + files.sort_unstable(); + files + } + + /// Every configuration name of the site, sorted. + pub(crate) fn drupal_config_names(&self) -> Vec { + self.drupal_config_index().names().to_vec() + } + + /// Where the configuration name `name` is declared: its default + /// configuration files, then the schema entry that describes it. + pub(crate) fn drupal_config_locations(&self, name: &str) -> Vec { + let index = self.drupal_config_index(); + let mut locations: Vec = index + .default_files(name) + .iter() + .filter_map(|path| { + Some(Location { + uri: crate::util::parse_url(&crate::util::path_to_uri(path))?, + range: Range::default(), + }) + }) + .collect(); + if let Some((_, entry)) = index.schema_entry(name) + && let (Some(uri), Some(content)) = ( + crate::util::parse_url(&entry.uri), + self.get_file_content_arc(&entry.uri), + ) + { + let lines = crate::text_position::LineIndex::new(&content); + locations.push(Location { + uri, + range: Range { + start: lines.position(entry.start as usize), + end: lines.position(entry.end as usize), + }, + }); + } + locations + } + + /// The hover text of the configuration name `name`: its schema label, + /// and the files that declare it. + pub(crate) fn drupal_config_detail(&self, name: &str) -> String { + let index = self.drupal_config_index(); + let mut parts = Vec::new(); + let schema = index.schema_entry(name); + if let Some(label) = schema.as_ref().and_then(|(_, entry)| entry.label.as_ref()) { + parts.push(format!("**{label}**")); + } + for path in index.default_files(name) { + if let Some(relative) = self.workspace_relative_path(&crate::util::path_to_uri(path)) { + parts.push(format!("Default configuration in `{relative}`")); + } + } + if let Some((key, entry)) = &schema + && let Some(relative) = self.workspace_relative_path(&entry.uri) + { + parts.push(format!("Schema `{key}` in `{relative}`")); + } + if parts.is_empty() { + return "Not declared in the site's default configuration or schema".to_string(); + } + parts.join("\n\n") + } + + /// Keep the configuration files in step with files created, changed, + /// or deleted on disk: a default configuration file created or deleted + /// adds or removes its name, and any change to a schema file is read + /// again. + pub(crate) fn note_drupal_config_changes( + &self, + changes: &[tower_lsp_server::gen_lsp_types::FileEvent], + ) { + use tower_lsp_server::gen_lsp_types::FileChangeType; + let roots = self.drupal.web_roots.read().clone(); + let mut changed = false; + for change in changes { + let path_str = change.uri.path(); + if !path_str.ends_with(".yml") || !path_str.contains("/config/") { + continue; + } + let Some(path) = crate::util::url_to_path(&change.uri) else { + continue; + }; + let Some(root) = roots.iter().find(|root| path.starts_with(root)) else { + continue; + }; + let list = if is_default_config_file(&path, root) { + if change.kind == FileChangeType::Changed { + continue; + } + &self.drupal.default_config_files + } else if is_schema_file(&path, root) { + &self.drupal.config_schema_files + } else { + continue; + }; + let mut files = list.write(); + files.retain(|known| *known != path); + if change.kind != FileChangeType::Deleted { + files.push(path); + } + changed = true; + } + if changed { + *self.drupal.config_index.write() = None; + } + } + + /// Read the configuration again the next time a name is asked for when + /// a document change can change it: a schema file is read from its + /// buffer while it is open, and a test module's files count while they + /// are. A default configuration file gives its name by its path + /// alone, so editing one changes nothing. + pub(crate) fn note_drupal_config_document(&self, uri: &str) { + if !self.is_drupal_project() + || !uri.ends_with(".yml") + || !["/config/install/", "/config/optional/", "/config/schema/"] + .iter() + .any(|folder| uri.contains(folder)) + { + return; + } + let stale = match self.drupal.config_index.read().as_ref() { + None => false, + Some(_) if uri.contains("/config/schema/") => true, + Some(index) => index.open_test_files != self.open_drupal_test_config_files(), + }; + if stale { + *self.drupal.config_index.write() = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCHEMA: &str = "# Comment: here\nacme.settings:\n type: config_object\n label: 'Acme settings'\n mapping:\n notify:\n type: string\nacme_theme_settings:\n type: config_object\nacme.theme.global:\n type: acme_theme_settings\nacme.preset.*:\n type: config_entity\nacme.data:\n type: mapping\nacme.loop:\n type: acme.loop_b\nacme.loop_b:\n type: acme.loop\nacme.menu_block:*:\n type: config_object\nacme.dynamic:\n type: acme.[%parent.type]\n"; + + fn index(defaults: &[&str]) -> ConfigIndex { + let defaults: Vec = defaults.iter().map(PathBuf::from).collect(); + ConfigIndex::build(&defaults, [("file:///acme.schema.yml", SCHEMA)]) + } + + #[test] + fn names_are_config_objects_and_default_files() { + let index = index(&[ + "/web/modules/acme/config/install/acme.other.yml", + "/web/modules/acme/config/optional/acme.settings.yml", + ]); + // A data type, a configuration entity's wildcard, a plain mapping, + // a cycle, a key holding a `:`, and a placeholder are not names. + assert_eq!( + index.names(), + ["acme.other", "acme.settings", "acme.theme.global"] + ); + assert_eq!(index.default_files("acme.settings").len(), 1); + } + + #[test] + fn a_key_holding_a_colon_is_read_whole() { + let entries = schema_entries("u", SCHEMA); + assert!(entries.iter().any(|(key, _)| key == "acme.menu_block:*")); + let (_, settings) = entries + .iter() + .find(|(key, _)| key == "acme.settings") + .unwrap(); + assert_eq!( + &SCHEMA[settings.start as usize..settings.end as usize], + "acme.settings" + ); + assert_eq!(settings.label.as_deref(), Some("Acme settings")); + assert_eq!(settings.ty.as_deref(), Some("config_object")); + } + + /// Drupal collapses the trailing wildcards of a candidate that has no + /// entry into one, for an entry that stands for any depth. + #[test] + fn trailing_wildcards_collapse_to_one() { + let schema = "rest.resource.*:\n type: config_entity\nrest.resource.entity.special:\n type: config_entity\n"; + let index = ConfigIndex::build(&[], [("file:///rest.schema.yml", schema)]); + assert_eq!( + index + .schema_entry("rest.resource.entity.node") + .map(|(key, _)| key), + Some("rest.resource.*".to_string()) + ); + assert_eq!( + index + .schema_entry("rest.resource.entity.special") + .map(|(key, _)| key), + Some("rest.resource.entity.special".to_string()) + ); + } + + #[test] + fn a_schema_is_looked_up_exactly_then_by_wildcards() { + assert_eq!( + schema_lookup_names("field.storage.node.body").collect::>(), + [ + "field.storage.node.body", + "field.storage.node.*", + "field.storage.*.*", + "field.storage.*", + "field.*.*.*", + "field.*", + "*.*.*.*", + "*.*", + ] + ); + let index = index(&[]); + assert_eq!( + index + .schema_entry("acme.preset.default") + .map(|(key, _)| key), + Some("acme.preset.*".to_string()) + ); + assert_eq!( + index.schema_entry("acme.settings").map(|(key, _)| key), + Some("acme.settings".to_string()) + ); + assert!(index.schema_entry("other.thing").is_none()); + } + + #[test] + fn default_and_schema_files_are_found_by_their_folders() { + let root = Path::new("/site/web"); + let yes = |path: &str| is_default_config_file(Path::new(path), root); + assert!(yes( + "/site/web/modules/custom/acme/config/install/acme.settings.yml" + )); + assert!(yes( + "/site/web/core/modules/node/config/optional/views.view.content.yml" + )); + assert!(!yes( + "/site/web/modules/custom/acme/config/install/language/fr/acme.settings.yml" + )); + assert!(!yes( + "/site/web/modules/custom/acme/config/schema/acme.schema.yml" + )); + assert!(!yes( + "/site/web/core/modules/node/tests/modules/node_test/config/install/node_test.settings.yml" + )); + let schema = |path: &str| is_schema_file(Path::new(path), root); + assert!(schema( + "/site/web/core/config/schema/core.data_types.schema.yml" + )); + assert!(schema( + "/site/web/modules/custom/acme/config/schema/acme.schema.yml" + )); + assert!(!schema( + "/site/web/modules/custom/acme/config/install/acme.schema.yml" + )); + } +} diff --git a/src/drupal/config_keys.rs b/src/drupal/config_keys.rs new file mode 100644 index 000000000..ce54f3365 --- /dev/null +++ b/src/drupal/config_keys.rs @@ -0,0 +1,338 @@ +//! The keys a configuration object's schema declares: the `mapping` of its +//! schema entry, merged with the mappings of the named types it is built +//! on (`config_object` gives every object `_core` and `langcode`), one +//! level at a time, so `notifications.` offers what `notifications` maps. +//! +//! A key's span is keyed by the configuration name as well as the key +//! (see [`config_key`]): `enabled` only means something under its object. +//! The schema files are read when a key is first asked for, and kept with +//! the configuration index (see [`super::config::ConfigIndex`]), which a +//! changed schema file drops. + +use std::sync::Arc; + +use tower_lsp_server::gen_lsp_types::{Location, Range}; + +use crate::Backend; +use crate::php_type::PhpType; + +/// The key of a [`crate::symbol_map::StringKeyKind::DrupalConfigKey`] +/// span: the key `path` of the configuration object `name`. +pub(crate) fn config_key(name: &str, path: &str) -> String { + format!("{name}#{path}") +} + +/// The configuration name and the key of a configuration key's span key. +pub(crate) fn split_config_key(key: &str) -> (&str, &str) { + key.split_once('#').unwrap_or(("", key)) +} + +/// One `key: value` line of a schema file, with the lines nested under it. +#[derive(Debug, Clone)] +pub(crate) struct YamlNode { + pub key: String, + /// Byte offset of the key, quotes excluded. + pub start: usize, + pub value: String, + pub children: Vec, +} + +impl YamlNode { + fn child(&self, key: &str) -> Option<&YamlNode> { + self.children.iter().find(|child| child.key == key) + } + + /// The scalar a setting gives, without quotes. + fn setting(&self, key: &str) -> Option<&str> { + self.child(key) + .map(|child| child.value.as_str()) + .filter(|value| !value.is_empty()) + } +} + +/// The `key: value` lines of a YAML file as a tree, by indentation. List +/// items, comments, and the text of block scalars are left out; the lines +/// nested under a list item are read as nested under the key before it, +/// which only matters to settings a schema reader never asks about. +pub(crate) fn yaml_tree(content: &str) -> Vec { + let mut lines = Vec::new(); + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + let indent = crate::framework::leading_spaces(semantic); + let text = &semantic[indent..]; + if text.trim().is_empty() || text.starts_with('-') { + continue; + } + let Some(colon) = super::config::yaml_key_end(text) else { + continue; + }; + let (key, (quote, _)) = crate::framework::strip_yaml_quotes(text[..colon].trim_end()); + let value = crate::framework::strip_yaml_quotes(text[colon + 1..].trim()).0; + lines.push((indent, key, line_start + indent + quote, value)); + } + let mut at = 0; + nodes_at(&lines, &mut at, 0) +} + +type Line<'a> = (usize, &'a str, usize, &'a str); + +/// The nodes of the lines from `at` on, at indentation `indent` or deeper +/// for the first one, until a line less indented than it. +fn nodes_at(lines: &[Line<'_>], at: &mut usize, indent: usize) -> Vec { + let mut nodes = Vec::new(); + let Some(level) = lines + .get(*at) + .map(|line| line.0) + .filter(|level| *level >= indent) + else { + return nodes; + }; + while let Some(&(line_indent, key, start, value)) = lines.get(*at) { + if line_indent < level { + break; + } + if line_indent > level { + // Nested under a list item or a block scalar. + *at += 1; + continue; + } + *at += 1; + let children = match lines.get(*at) { + Some(next) if next.0 > level => nodes_at(lines, at, next.0), + _ => Vec::new(), + }; + nodes.push(YamlNode { + key: key.to_string(), + start, + value: value.to_string(), + children, + }); + } + nodes +} + +/// A key a configuration object's schema declares. +#[derive(Debug, Clone)] +pub(crate) struct SchemaKey { + pub name: String, + pub uri: String, + /// The byte range of the key in its schema file. + pub start: u32, + pub end: u32, + pub ty: Option, + pub label: Option, + node: YamlNode, +} + +/// Whether a schema type is one a reader can follow to another entry: not +/// a mapping or a sequence spelled out in place, and not a placeholder +/// (`[%parent.type]`) only known per value. +fn is_named_type(ty: &str) -> bool { + !matches!(ty, "mapping" | "sequence") && !ty.contains(['[', '%']) +} + +impl Backend { + /// The parsed schema file at `uri`, read once per configuration index. + fn schema_tree( + &self, + index: &super::config::ConfigIndex, + uri: &str, + ) -> Option>> { + if let Some(tree) = index.trees.lock().get(uri) { + return Some(Arc::clone(tree)); + } + let content = self.get_file_content_arc(uri)?; + let tree = Arc::new(yaml_tree(&content)); + index + .trees + .lock() + .insert(uri.to_string(), Arc::clone(&tree)); + Some(tree) + } + + /// The top-level node of the schema entry `key`. + fn schema_node( + &self, + index: &super::config::ConfigIndex, + key: &str, + ) -> Option<(String, YamlNode)> { + let entry = index.schema_named(key)?; + let tree = self.schema_tree(index, &entry.uri)?; + let node = tree + .iter() + .find(|node| node.start == entry.start as usize)? + .clone(); + Some((entry.uri.clone(), node)) + } + + /// The keys `node`, written in the file at `uri`, maps: its own, then + /// the ones of the named types it is built on, which it can override. + fn keys_of( + &self, + index: &super::config::ConfigIndex, + uri: &str, + node: &YamlNode, + seen: &mut Vec, + ) -> Vec { + let mut keys: Vec = node + .child("mapping") + .map(|mapping| { + mapping + .children + .iter() + .map(|child| SchemaKey { + name: child.key.clone(), + uri: uri.to_string(), + start: child.start as u32, + end: (child.start + child.key.len()) as u32, + ty: child.setting("type").map(str::to_string), + label: child.setting("label").map(str::to_string), + node: child.clone(), + }) + .collect() + }) + .unwrap_or_default(); + if let Some(ty) = node.setting("type").filter(|ty| is_named_type(ty)) + && !seen.iter().any(|known| known == ty) + { + seen.push(ty.to_string()); + if let Some((base_uri, base)) = self.schema_node(index, ty) { + for key in self.keys_of(index, &base_uri, &base, seen) { + if !keys.iter().any(|own| own.name == key.name) { + keys.push(key); + } + } + } + } + keys + } + + /// The keys the schema of the configuration object `name` declares + /// under the key `parent` (`""` for its top level, `notifications` for + /// what `notifications.` offers), sorted by name. + pub(crate) fn drupal_config_keys(&self, name: &str, parent: &str) -> Vec { + let index = self.drupal_config_index(); + let Some((key, _)) = index.schema_entry(name) else { + return Vec::new(); + }; + let Some((uri, node)) = self.schema_node(&index, &key) else { + return Vec::new(); + }; + let mut seen = vec![key.clone()]; + let mut level = self.keys_of(&index, &uri, &node, &mut seen); + for part in parent.split('.').filter(|part| !part.is_empty()) { + let Some(found) = level.into_iter().find(|key| key.name == part) else { + return Vec::new(); + }; + let mut seen = Vec::new(); + level = self.keys_of(&index, &found.uri, &found.node, &mut seen); + } + level.sort_by(|a, b| a.name.cmp(&b.name)); + level + } + + /// The schema key a configuration key's span key names. + fn drupal_config_key(&self, key: &str) -> Option { + let (name, path) = split_config_key(key); + let (parent, last) = path.rsplit_once('.').unwrap_or(("", path)); + self.drupal_config_keys(name, parent) + .into_iter() + .find(|key| key.name == last) + } + + /// Where the schema declares the configuration key `key`. + pub(crate) fn drupal_config_key_locations(&self, key: &str) -> Vec { + let Some(found) = self.drupal_config_key(key) else { + return Vec::new(); + }; + let (Some(uri), Some(content)) = ( + crate::util::parse_url(&found.uri), + self.get_file_content_arc(&found.uri), + ) else { + return Vec::new(); + }; + let lines = crate::text_position::LineIndex::new(&content); + vec![Location { + uri, + range: Range { + start: lines.position(found.start as usize), + end: lines.position(found.end as usize), + }, + }] + } + + /// The hover text of the configuration key `key`: its label, its type, + /// and the configuration object it belongs to. + pub(crate) fn drupal_config_key_detail(&self, key: &str) -> String { + let (name, _) = split_config_key(key); + let Some(found) = self.drupal_config_key(key) else { + return format!("Not declared in the schema of `{name}`"); + }; + let mut parts = Vec::new(); + if let Some(label) = &found.label { + parts.push(format!("**{label}**")); + } + if let Some(ty) = &found.ty { + parts.push(format!("Type `{ty}`")); + } + parts.push(format!("Configuration `{name}`")); + parts.join("\n\n") + } + + /// The configuration names the type `ty` of a config object carries, + /// when it may be written to: `set()` and `clear()` throw on an + /// `ImmutableConfig`, so a type that can only be one carries none. + pub(crate) fn drupal_config_names_of( + &self, + ty: &PhpType, + editable: bool, + class_loader: &crate::class_lookup::ResolvedClassLoader<'_>, + ) -> Vec { + if editable && crate::class_lookup::is_subtype_of_named(ty, IMMUTABLE_CONFIG, class_loader) + { + return Vec::new(); + } + super::types::config_names_of(ty) + } +} + +const IMMUTABLE_CONFIG: &str = "Drupal\\Core\\Config\\ImmutableConfig"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_schema_reads_as_a_tree() { + let yaml = "acme.settings:\n type: config_object\n label: 'Acme'\n mapping:\n notifications:\n type: mapping\n mapping:\n email:\n type: email\n allowed:\n type: sequence\n sequence:\n type: string\n constraints:\n Choice:\n - a\n - b\n text:\n type: text\n label: >\n Some: text\nother:\n type: mapping\n"; + let tree = yaml_tree(yaml); + assert_eq!( + tree.iter() + .map(|node| node.key.as_str()) + .collect::>(), + ["acme.settings", "other"] + ); + let mapping = tree[0].child("mapping").unwrap(); + assert_eq!( + mapping + .children + .iter() + .map(|node| node.key.as_str()) + .collect::>(), + ["notifications", "allowed", "text"] + ); + let email = &mapping.children[0].child("mapping").unwrap().children[0]; + assert_eq!(&yaml[email.start..email.start + 5], "email"); + assert_eq!(email.setting("type"), Some("email")); + assert_eq!(tree[0].setting("label"), Some("Acme")); + } + + #[test] + fn a_key_names_its_object() { + let key = config_key("acme.settings", "notifications.email"); + assert_eq!( + split_config_key(&key), + ("acme.settings", "notifications.email") + ); + } +} diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index dfa80c699..8462b429f 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -17,8 +17,11 @@ use std::sync::atomic::Ordering; use parking_lot::RwLock; pub(crate) mod computed_hooks; +pub(crate) mod config; +pub(crate) mod config_keys; pub(crate) mod hooks; pub(crate) mod links; +pub(crate) mod permissions; pub(crate) mod routing; pub(crate) mod service_providers; pub(crate) mod types; @@ -49,6 +52,14 @@ pub(crate) struct DrupalState { /// The services each service provider's `alter()` gives another class, /// keyed by the provider's URI; see [`service_providers`]. service_replacements: RwLock>>, + /// The default configuration files of every extension, which are + /// never parsed: their names are configuration names. + default_config_files: RwLock>, + /// The configuration schema files, read when a configuration name is + /// first asked for. + config_schema_files: RwLock>, + /// The configuration names read from both, until one of them changes. + config_index: RwLock>>, } /// A kind of file Drupal reads declarations from. @@ -62,6 +73,8 @@ pub(crate) enum DeclarationFile { Routing, /// `.links.{menu,task,action,contextual}.yml`. Links, + /// `.permissions.yml`. + Permissions, } /// The kind of declaration file the file name at the end of `path` is, if @@ -73,6 +86,8 @@ fn declaration_file_kind(path: &str) -> Option { Some(DeclarationFile::Services) } else if name.ends_with(".routing.yml") { Some(DeclarationFile::Routing) + } else if name.ends_with(".permissions.yml") { + Some(DeclarationFile::Permissions) } else if [ ".links.menu.yml", ".links.task.yml", @@ -118,6 +133,22 @@ pub(crate) fn is_service_lookup(class: &str, method: &str) -> bool { && (method.eq_ignore_ascii_case("service") || method.eq_ignore_ascii_case("hasService")) } +/// Whether a static call of `method` on `class`, a resolved class name, +/// reads a configuration object by its name: `\Drupal::config()`. Only +/// the global `Drupal` class counts. +pub(crate) fn is_config_lookup(class: &str, method: &str) -> bool { + class.strip_prefix('\\').unwrap_or(class) == "Drupal" && method.eq_ignore_ascii_case("config") +} + +/// Whether `class`, a resolved class name, is `Drupal\Core\Form\ConfigTarget`, +/// whose constructor takes a configuration name first. +pub(crate) fn is_config_target_class(class: &str) -> bool { + class + .strip_prefix('\\') + .unwrap_or(class) + .eq_ignore_ascii_case("Drupal\\Core\\Form\\ConfigTarget") +} + /// Whether a static call of `method` on `class`, a resolved class name, /// starts an entity query on an entity type ID: `\Drupal::entityQuery()` or /// `\Drupal::entityQueryAggregate()`. Only the global `Drupal` class @@ -217,7 +248,7 @@ pub(crate) fn matches_route_pattern(pattern: &str, name: &str) -> bool { } /// Whether `path` is a file Drupal reads declarations from: a services, -/// routing, or links file, or an extension's `*.info.yml`. +/// routing, links, or permissions file, or an extension's `*.info.yml`. pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) @@ -291,6 +322,9 @@ impl Backend { self.drupal.hook_php_files.write().clear(); *self.drupal.extensions.write() = hooks::ExtensionIndex::default(); self.drupal.service_replacements.write().clear(); + self.drupal.default_config_files.write().clear(); + self.drupal.config_schema_files.write().clear(); + *self.drupal.config_index.write() = None; } /// Remember the declaration files the Drupal walk found: the YAML for diff --git a/src/drupal/permissions.rs b/src/drupal/permissions.rs new file mode 100644 index 000000000..dff89dbc5 --- /dev/null +++ b/src/drupal/permissions.rs @@ -0,0 +1,300 @@ +//! Drupal's permission names: the keys an extension's +//! `.permissions.yml` declares, and the parts of a route's +//! `_permission` requirement. +//! +//! A permissions file's `permission_callbacks` key names the methods that +//! build more permissions at runtime, which are not read: it declares no +//! permission of its own. + +use super::routing::{entries, lines, scalar}; +use crate::Backend; +use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; + +/// The key of a permissions file that lists callbacks, not a permission. +const CALLBACKS_KEY: &str = "permission_callbacks"; + +/// The spans of the permissions a permissions file declares, in source +/// order: each top-level key but [`CALLBACKS_KEY`]. +pub(crate) fn permissions_spans(content: &str) -> Vec { + lines(content) + .iter() + .filter(|line| line.indent == 0 && !line.key.is_empty() && line.key != CALLBACKS_KEY) + .map(|line| SymbolSpan { + start: line.key_start as u32, + end: (line.key_start + line.key.len()) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalPermission, + key: line.key.to_string(), + is_write: true, + is_optional: false, + }, + }) + .collect() +} + +/// The byte ranges of the permissions a `_permission` requirement names, +/// relative to its text: Drupal splits it on `,` when it has one (all of +/// the permissions are needed), else on `+` (any of them grants access), +/// and trims nothing. +pub(crate) fn permission_parts(value: &str) -> Vec<(usize, usize)> { + let separator = if value.contains(',') { ',' } else { '+' }; + let mut parts = Vec::new(); + let mut start = 0; + for part in value.split(separator) { + if !part.is_empty() { + parts.push((start, start + part.len())); + } + start += part.len() + 1; + } + parts +} + +/// The spans of the permissions a `_permission` requirement whose text +/// starts at `start` names. +pub(crate) fn requirement_spans(value: &str, start: usize) -> Vec { + permission_parts(value) + .into_iter() + .map(|(from, to)| SymbolSpan { + start: (start + from) as u32, + end: (start + to) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalPermission, + key: value[from..to].to_string(), + is_write: false, + is_optional: false, + }, + }) + .collect() +} + +/// Where the permission being typed at `cursor` starts, when `cursor` is +/// in the value of a `_permission:` key of a routing file: after the +/// value's opening quote, or after the last `+` or `,` before the cursor. +pub(crate) fn routing_permission_start(content: &str, cursor: usize) -> Option { + let line_start = content[..cursor].rfind('\n').map_or(0, |at| at + 1); + let before = &content[line_start..cursor]; + let indent = crate::framework::leading_spaces(before); + let rest = before[indent..].strip_prefix("_permission")?; + let rest = rest.trim_start_matches([' ', '\t']).strip_prefix(':')?; + let value = rest.trim_start_matches([' ', '\t']); + let value = value.strip_prefix(['\'', '"']).unwrap_or(value); + if value.contains(['\'', '"', '#']) { + return None; + } + let typed = value.rfind([',', '+']).map_or(0, |at| at + 1); + Some(cursor - value.len() + typed) +} + +/// Whether a static call of `method` on `class`, a resolved class name, +/// checks permissions in its second argument: `Some(false)` for +/// `AccessResult::allowedIfHasPermission()`, which takes one, and +/// `Some(true)` for `allowedIfHasPermissions()`, which takes a list. +pub(crate) fn static_permission_call(class: &str, method: &str) -> Option { + if !class + .trim_start_matches('\\') + .eq_ignore_ascii_case("Drupal\\Core\\Access\\AccessResult") + { + return None; + } + if method.eq_ignore_ascii_case("allowedIfHasPermission") { + Some(false) + } else if method.eq_ignore_ascii_case("allowedIfHasPermissions") { + Some(true) + } else { + None + } +} + +/// Whether a function written as `written` is the user module's +/// `user_role_grant_permissions()` or `user_role_revoke_permissions()`, +/// which take a list of permissions second. A name written with a +/// namespace is another function. +pub(crate) fn is_role_permissions_function(written: &str) -> bool { + let name = written.strip_prefix('\\').unwrap_or(written); + name.eq_ignore_ascii_case("user_role_grant_permissions") + || name.eq_ignore_ascii_case("user_role_revoke_permissions") +} + +/// What a permissions file says about one permission. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct PermissionDetails { + pub title: Option, + pub description: Option, + /// Whether the permission is marked `restrict access: true`: one that + /// should only go to trusted roles. + pub restrict_access: bool, +} + +/// What the permissions file `content` says about the permission `name`. +pub(crate) fn permission_details(content: &str, name: &str) -> Option { + let lines = lines(content); + let (_, block) = entries(&lines) + .into_iter() + .find(|(entry, _)| entry.indent == 0 && entry.key == name)?; + let mut details = PermissionDetails::default(); + for (setting, nested) in entries(block) { + let (value, _) = scalar(setting.value, setting.value_start); + // A block scalar (`>` or `|`) is written on the lines below its + // key, which hold no `key:` of their own once folded: read them + // from the text. + let value = if matches!(value.trim(), ">" | "|" | ">-" | "|-") { + block_scalar(content, setting.value_start) + } else if nested.is_empty() { + value + } else { + continue; + }; + match setting.key { + "title" => details.title = Some(value), + "description" => details.description = Some(value), + "restrict access" => details.restrict_access = value.eq_ignore_ascii_case("true"), + _ => {} + } + } + Some(details) +} + +/// The lines of a block scalar whose indicator ends the line that holds +/// `at`, folded into one. +fn block_scalar(content: &str, at: usize) -> String { + let line_end = content[at..] + .find('\n') + .map_or(content.len(), |end| at + end); + let key_indent = { + let line_start = content[..at].rfind('\n').map_or(0, |start| start + 1); + crate::framework::leading_spaces(&content[line_start..]) + }; + let mut folded = String::new(); + for line in content[line_end..].lines().skip(1) { + if !line.trim().is_empty() && crate::framework::leading_spaces(line) <= key_indent { + break; + } + let text = line.trim(); + if text.is_empty() { + continue; + } + if !folded.is_empty() { + folded.push(' '); + } + folded.push_str(text); + } + folded +} + +impl Backend { + /// The hover text of the permission `name`: its title, its + /// description, whether it is restricted, and the file declaring it. + pub(crate) fn drupal_permission_detail(&self, name: &str) -> String { + let mut declared: Option = None; + self.visit_symfony_declarations(StringKeyKind::DrupalPermission, |uri, _, _, _, key| { + if declared.is_none() && key == name { + declared = Some(uri.to_string()); + } + }); + let Some(uri) = declared else { + return "Not declared in the project's permissions files".to_string(); + }; + let mut parts = Vec::new(); + if let Some(details) = self + .get_file_content_arc(&uri) + .and_then(|content| permission_details(&content, name)) + { + if let Some(title) = details.title.filter(|title| !title.is_empty()) { + parts.push(format!("**{title}**")); + } + if let Some(description) = details.description.filter(|text| !text.is_empty()) { + parts.push(description); + } + if details.restrict_access { + parts.push("Restricted: give it to trusted roles only".to_string()); + } + } + if let Some(path) = self.workspace_relative_path(&uri) { + parts.push(format!("Declared in `{path}`")); + } + parts.join("\n\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn declared(yaml: &str) -> Vec<(&str, String)> { + permissions_spans(yaml) + .iter() + .map(|span| { + let SymbolKind::StringKey { key, is_write, .. } = &span.kind else { + unreachable!() + }; + assert!(is_write); + (&yaml[span.start as usize..span.end as usize], key.clone()) + }) + .collect() + } + + #[test] + fn top_level_keys_declare_permissions_but_the_callbacks() { + let yaml = "administer acme:\n title: 'Administer acme'\n restrict access: true\n'view acme notes':\n title: View notes\n# a comment: here\npermission_callbacks:\n - \\Drupal\\acme\\AcmePermissions::permissions\n"; + assert_eq!( + declared(yaml), + [ + ("administer acme", "administer acme".to_string()), + ("view acme notes", "view acme notes".to_string()), + ] + ); + } + + #[test] + fn requirements_split_the_way_drupal_splits_them() { + let parts = |value: &'static str| { + permission_parts(value) + .into_iter() + .map(|(from, to)| &value[from..to]) + .collect::>() + }; + assert_eq!(parts("access content"), ["access content"]); + assert_eq!(parts("a+b"), ["a", "b"]); + assert_eq!(parts("a,b+c"), ["a", "b+c"]); + assert_eq!(parts("a,,b"), ["a", "b"]); + assert!(parts("").is_empty()); + } + + #[test] + fn a_routing_permission_is_typed_after_its_last_separator() { + let yaml = "a:\n requirements:\n _permission: 'access content+adm"; + let start = routing_permission_start(yaml, yaml.len()).unwrap(); + assert_eq!(&yaml[start..], "adm"); + let yaml = "a:\n requirements:\n _permission: acc"; + let start = routing_permission_start(yaml, yaml.len()).unwrap(); + assert_eq!(&yaml[start..], "acc"); + let yaml = "a:\n requirements:\n _permission: "; + assert_eq!(routing_permission_start(yaml, yaml.len()), Some(yaml.len())); + let yaml = "a:\n requirements:\n _access: 'TRUE"; + assert_eq!(routing_permission_start(yaml, yaml.len()), None); + let yaml = "a:\n requirements:\n _permission: 'done' "; + assert_eq!(routing_permission_start(yaml, yaml.len()), None); + } + + #[test] + fn details_read_the_title_description_and_restriction() { + let yaml = "administer acme:\n title: 'Administer acme'\n description: >\n Change every acme\n setting.\n restrict access: true\nview acme:\n title: View acme\n"; + assert_eq!( + permission_details(yaml, "administer acme"), + Some(PermissionDetails { + title: Some("Administer acme".to_string()), + description: Some("Change every acme setting.".to_string()), + restrict_access: true, + }) + ); + assert_eq!( + permission_details(yaml, "view acme"), + Some(PermissionDetails { + title: Some("View acme".to_string()), + description: None, + restrict_access: false, + }) + ); + assert_eq!(permission_details(yaml, "missing"), None); + } +} diff --git a/src/drupal/routing.rs b/src/drupal/routing.rs index 8be113bb3..2a265651e 100644 --- a/src/drupal/routing.rs +++ b/src/drupal/routing.rs @@ -379,7 +379,8 @@ pub(crate) fn entity_type_of(value: &str) -> Option { .map(str::to_string) } -/// The entity type and service spans of a routing file, in source order. +/// The entity type, service, and permission spans of a routing file, in +/// source order. pub(crate) fn routing_value_spans(content: &str) -> Vec { let mut spans = Vec::new(); for (line_start, line) in crate::framework::line_offsets(content) { @@ -397,6 +398,13 @@ pub(crate) fn routing_value_spans(content: &str) -> Vec { let trimmed = raw.trim_start(); let (value, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); let value_start = line_start + indent + colon + 1 + (raw.len() - trimmed.len()) + quote; + if key == "_permission" { + spans.extend(crate::drupal::permissions::requirement_spans( + value, + value_start, + )); + continue; + } let found = match key { // `node.edit` is the form `edit` of the entity type `node`, and // `node.full` its view mode `full`. @@ -442,7 +450,7 @@ mod tests { #[test] fn entity_and_service_defaults_read_their_names() { - let yaml = "a:\n path: '/a'\n defaults:\n _entity_form: 'node.edit'\n _entity_view: node.full\n _entity_list: node_type\n _controller: acme.greeter:greet\n _title_callback: '\\Drupal\\acme\\Title::get'\n requirements:\n _entity_access: node.update\n"; + let yaml = "a:\n path: '/a'\n defaults:\n _entity_form: 'node.edit'\n _entity_view: node.full\n _entity_list: node_type\n _controller: acme.greeter:greet\n _title_callback: '\\Drupal\\acme\\Title::get'\n requirements:\n _entity_access: node.update\n _permission: 'access content+administer acme'\n"; let found: Vec<(&str, StringKeyKind)> = routing_value_spans(yaml) .iter() .map(|span| { @@ -459,6 +467,8 @@ mod tests { ("node", StringKeyKind::DrupalEntityType), ("node_type", StringKeyKind::DrupalEntityType), ("acme.greeter", StringKeyKind::ServiceId), + ("access content", StringKeyKind::DrupalPermission), + ("administer acme", StringKeyKind::DrupalPermission), ] ); } diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index 9d3da8564..4aae445e5 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -67,6 +67,57 @@ fn services_files_in_the_web_root_declare_services_and_parameters() { assert_eq!(classes, vec!["Drupal\\acme\\Greeter".to_string()]); } +/// Default configuration gives names by its file names alone, so editing +/// one keeps the configuration index; a schema edit, and a test module's +/// file opening or closing, drop it. +#[test] +fn only_what_changes_the_names_drops_the_configuration_index() { + let (backend, dir) = drupal_backend(&[]); + let default = dir + .path() + .join("web/modules/custom/acme/config/install/acme.settings.yml"); + let schema = "web/modules/custom/acme/config/schema/acme.schema.yml"; + let fixture = + "web/core/modules/system/tests/modules/config_test/config/install/config_test.system.yml"; + backend.note_drupal_config_files(vec![default.clone()], Vec::new()); + let first = backend.drupal_config_index(); + + backend.note_drupal_config_document(&crate::util::path_to_uri(&default)); + assert!(std::sync::Arc::ptr_eq( + &first, + &backend.drupal_config_index() + )); + + backend.note_drupal_config_document(&uri(dir.path(), schema)); + let second = backend.drupal_config_index(); + assert!(!std::sync::Arc::ptr_eq(&first, &second)); + + let fixture_uri = uri(dir.path(), fixture); + backend.open_files.write().insert( + fixture_uri.clone(), + std::sync::Arc::new("a: b\n".to_string()), + ); + backend.note_drupal_config_document(&fixture_uri); + let opened = backend.drupal_config_index(); + assert!(!std::sync::Arc::ptr_eq(&second, &opened)); + assert!(opened.names().contains(&"config_test.system".to_string())); + // Typing in it changes no name. + backend.note_drupal_config_document(&fixture_uri); + assert!(std::sync::Arc::ptr_eq( + &opened, + &backend.drupal_config_index() + )); + + backend.open_files.write().remove(&fixture_uri); + backend.note_drupal_config_document(&fixture_uri); + assert!( + !backend + .drupal_config_index() + .names() + .contains(&"config_test.system".to_string()) + ); +} + #[test] fn a_routing_file_under_a_config_directory_is_drupals_in_a_symfony_site() { // Core's `config` module puts a `config` segment on the way to its diff --git a/src/drupal/types.rs b/src/drupal/types.rs index 9135c33b9..4dc194c87 100644 --- a/src/drupal/types.rs +++ b/src/drupal/types.rs @@ -311,3 +311,116 @@ enum Query { Count, Execute, } + +const CONFIG_FACTORY: &str = crate::symbol_map::DRUPAL_CONFIG_FACTORY; +/// The classes whose `config()` shortcut reads a configuration object. +const CONFIG_SHORTCUTS: [&str; 2] = [ + "Drupal\\Core\\Controller\\ControllerBase", + "Drupal\\Core\\Form\\FormBase", +]; + +/// What a call that reads a configuration object by its name returns: +/// whatever the method declares, with the name added as a type argument +/// to each class it names (`ImmutableConfig<'acme.settings'>`, both halves +/// of a config form's `Config|ImmutableConfig`), so the keys a `get()` on +/// it reads are known wherever it goes. +/// +/// The calls are `\Drupal::config()`, a config factory's `get()` and +/// `getEditable()`, and the `config()` of a controller or a form. `None` +/// for another call, or one whose method declares no class. +pub(crate) fn config_object_type( + backend: &Backend, + owner: &ClassInfo, + method_name: &str, + name: &str, + class_loader: &ResolvedClassLoader<'_>, + cache: Option<&crate::virtual_members::ResolvedClassCache>, +) -> Option { + // Drupal refuses a configuration name without a namespace, so the far + // more common `get('field_name')` of an entity skips the subtype walks. + if !name.contains('.') + || !backend.is_drupal_project() + || !reads_config_object(owner, method_name, class_loader) + { + return None; + } + let merged = + crate::virtual_members::resolve_class_fully_maybe_cached(owner, class_loader, cache); + let declared = merged.get_method_ci(method_name)?.return_type.clone()?; + tag_config_type(&declared, name) +} + +/// Whether a call of `method_name` on `owner` reads a configuration object +/// by the name it takes first. +fn reads_config_object( + owner: &ClassInfo, + method_name: &str, + class_loader: &ResolvedClassLoader<'_>, +) -> bool { + let is = |name: &str| method_name.eq_ignore_ascii_case(name); + if is("config") { + owner.fqn().trim_start_matches('\\') == "Drupal" + || CONFIG_SHORTCUTS + .iter() + .any(|base| crate::class_lookup::is_subtype_of(owner, base, class_loader)) + || super::config::uses_config_form_trait(owner, class_loader) + } else if is("get") || is("getEditable") { + crate::class_lookup::is_subtype_of(owner, CONFIG_FACTORY, class_loader) + } else { + false + } +} + +/// `declared` with the configuration name `name` added to each class it +/// names, or `None` when it names none. +fn tag_config_type(declared: &PhpType, name: &str) -> Option { + match declared.kind() { + TypeKind::Named(class) => Some(PhpType::generic( + class.as_str(), + vec![PhpType::literal_string_value(name)], + )), + TypeKind::Union(members) => { + let mut tagged_any = false; + let members = members + .iter() + .map(|member| match tag_config_type(member, name) { + Some(tagged) => { + tagged_any = true; + tagged + } + None => member.clone(), + }) + .collect(); + tagged_any.then(|| PhpType::union(members)) + } + _ => None, + } +} + +/// The configuration names a config object's type carries, one per tagged +/// class it can be: what [`config_object_type`] added. +pub(crate) fn config_names_of(ty: &PhpType) -> Vec { + let mut names = Vec::new(); + collect_config_names(ty, &mut names); + names +} + +fn collect_config_names(ty: &PhpType, names: &mut Vec) { + match ty.kind() { + TypeKind::Generic(generic) => { + if let [argument] = generic.args.as_slice() + && let TypeKind::Literal(literal) = argument.kind() + && let Some(name) = literal.string_content() + && !names.iter().any(|known| *known == *name) + { + names.push(name.to_string()); + } + } + TypeKind::Union(members) => { + for member in members { + collect_config_names(member, names); + } + } + _ => {} + } +} diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index c209231ab..39746c125 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -113,6 +113,12 @@ impl Backend { let view_path; let (label, detail) = match kind { StringKeyKind::Section => ("Section", self.blade_block_detail(uri, kind, key)), + StringKeyKind::DrupalPermission => ("Permission", self.drupal_permission_detail(key)), + StringKeyKind::DrupalConfigName => ("Configuration", self.drupal_config_detail(key)), + StringKeyKind::DrupalConfigKey => { + shown = crate::drupal::config_keys::split_config_key(key).1; + ("Configuration key", self.drupal_config_key_detail(key)) + } StringKeyKind::Stack => ("Stack", self.blade_block_detail(uri, kind, key)), StringKeyKind::TwigBlock => ( "Block", diff --git a/src/indexing/init.rs b/src/indexing/init.rs index 3e1a8c506..546d027c6 100644 --- a/src/indexing/init.rs +++ b/src/indexing/init.rs @@ -499,6 +499,10 @@ impl Backend { classmap_scanner::scan_drupal_directories(web_root, filters, progress); self.note_drupal_declaration_files(declaration_files.declarations); self.note_drupal_service_providers(declaration_files.service_providers); + self.note_drupal_config_files( + declaration_files.default_config, + declaration_files.config_schemas, + ); let drupal_count = drupal_result.classmap.len() + drupal_result.function_index.len() + drupal_result.constant_index.len(); diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index 55b3339f9..a01f190ad 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -92,6 +92,11 @@ impl Backend { ) -> bool { let is_laravel = self.resolved_class_cache.read().laravel().is_enabled(); let changes = self.spell_changes_as_indexed(¶ms.changes); + // Default configuration files are never indexed, and an open schema + // file is skipped below, so both are followed before that. + if self.is_drupal_project() { + self.note_drupal_config_changes(&changes); + } if self.is_symfony_project() && changes.iter().any(|change| { crate::symfony::translations::is_translation_setup_uri(change.uri.as_str()) diff --git a/src/reference_index.rs b/src/reference_index.rs index a1a7b886b..28a7d7cf5 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -65,6 +65,15 @@ pub(crate) fn string_key_reference_key(kind: StringKeyKind, key: &str) -> Refere key: crate::symbol_map::laravel_resources::config_key(resource, key), } } + // A config object's `get()` holds the key alone, its object's name + // coming with the receiver's type, so a key is filed and found + // without the name. + StringKeyKind::DrupalConfigKey => ReferenceIndexKey::StringKey { + kind, + key: crate::drupal::config_keys::split_config_key(key) + .1 + .to_string(), + }, _ => ReferenceIndexKey::StringKey { kind, key: key.to_string(), @@ -784,7 +793,7 @@ impl Backend { } let mut kinds: Vec = Vec::with_capacity(2); for (_, kind) in site.receiver.targets() { - if !kinds.contains(kind) { + if enabled(*kind) && !kinds.contains(kind) { kinds.push(*kind); } } @@ -1545,6 +1554,39 @@ mod tests { assert!(candidates.is_empty()); } + /// A typed receiver's candidate is filed only under the kinds the + /// project reads: a Symfony `->get('page')` could be a Drupal config + /// factory's or config object's read, but never is outside Drupal. + #[test] + fn typed_receiver_candidates_file_only_enabled_kinds() { + let backend = Backend::new_test(); + backend.set_is_symfony(true); + let map = SymbolMap { + typed_receiver_sites: vec![crate::symbol_map::TypedReceiverSite { + start: 0, + end: 4, + key: "page".to_string(), + is_write: false, + is_optional: false, + guards: 0, + receiver: crate::symbol_map::ReceiverClass::ContainerLookup, + }], + ..SymbolMap::default() + }; + let kinds: Vec = backend + .reference_entries_for_symbol_map("file:///project/src/Foo.php", &map) + .into_iter() + .filter_map(|(key, _)| match key { + ReferenceIndexKey::StringKey { kind, .. } => Some(kind), + _ => None, + }) + .collect(); + assert_eq!( + kinds, + [StringKeyKind::ContainerParameter, StringKeyKind::ServiceId] + ); + } + #[test] fn direct_entry_build_skips_non_indexable_uri() { let backend = Backend::new_test(); diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index 24e0a492c..b92f89ced 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -163,6 +163,7 @@ impl Backend { /// one. pub(crate) fn update_resource_symbol_index(&self, uri: &str, content: &str) { self.note_twig_source(uri, content); + self.note_drupal_config_document(uri); if self.is_resource_uri(uri) { self.publish_resource_symbol_index(uri, content); } else if self.symbol_map_for(uri).is_some() { @@ -307,6 +308,9 @@ impl Backend { spans = crate::drupal::links::links_route_spans(content); spans.extend(crate::drupal::links::links_parameter_spans(content)); } + crate::drupal::DeclarationFile::Permissions => { + spans = crate::drupal::permissions::permissions_spans(content); + } } } else if is_symfony && crate::symfony::resources::is_container_config_uri(uri) { declares_twig_loader = crate::symfony::tags::tags_twig_loader(content); diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index 125bea4fc..a5a3cfc60 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -28,6 +28,7 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC emit_drupal_hook_attribute_spans(class, ctx); emit_drupal_computed_hook_sites(&class.members, class.name.span.start.offset, ctx); emit_drupal_route_builder_spans(&class.members, class.name.span.start.offset, ctx); + emit_drupal_editable_config_spans(&class.members, ctx); if let Some(ref extends) = class.extends { for ident in extends.types.iter() { diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index 6cd09dee6..dc9350b2f 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -43,8 +43,18 @@ const ENTITY_TYPE_KINDS: [EntityTypeKind; 3] = [ }, ]; +/// Whether `attribute`, a resolved class name, is one of the attributes +/// that declare an entity type. +pub(crate) fn is_entity_type_attribute(attribute: &str) -> bool { + let attribute = attribute.trim_start_matches('\\'); + ENTITY_TYPE_KINDS + .iter() + .any(|kind| kind.attribute.eq_ignore_ascii_case(attribute)) +} + /// Record the entity type `class` declares: a span over its ID, and the -/// class and storage the ID stands for. +/// class and storage the ID stands for, and a span over the permission its +/// `admin_permission` names. pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( class: &Class<'_>, ctx: &mut ExtractionCtx<'_>, @@ -53,6 +63,7 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( return; } let mut found = Vec::new(); + let mut admin_permissions = Vec::new(); for kind in &ENTITY_TYPE_KINDS { for attribute in attributes_named(&class.attribute_lists, kind.attribute, ctx) { let mut id = None; @@ -65,6 +76,16 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( storage = Some(named); } } + "admin_permission" => { + push_laravel_string_span( + StringKeyKind::DrupalPermission, + false, + false, + value, + ctx.content, + &mut admin_permissions, + ); + } _ => {} } } @@ -78,10 +99,14 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( && doc.contains("EntityType(") { found.extend(annotation_entity_type(doc, offset)); + if !found.is_empty() { + admin_permissions.extend(annotation_admin_permission(doc, offset)); + } } if found.is_empty() { return; } + ctx.spans.extend(admin_permissions); let Some(fqn) = ctx .resolved_name_at(class.name.span.start.offset) .map(|fqn| crate::atom::atom(fqn.trim_start_matches('\\'))) @@ -148,12 +173,15 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( // Drupal's discovery reads attributes only, never `@Route` annotations. let class_routes = route_declarations(&class.attribute_lists, None, class, ctx); let class_details = route_details(&class.attribute_lists, ctx); + let mut spans: Vec = class_details + .iter() + .flat_map(|details| details.permissions.iter().cloned()) + .collect(); let prefix = match class_routes.first().map(|route| &route.name) { Some(RouteName::Literal(name, _, _)) => name.as_str(), _ => "", }; let is_named = |route: &&RouteDeclaration| matches!(route.name, RouteName::Literal(..)); - let mut spans = Vec::new(); let mut sites = Vec::new(); let mut aliases = Vec::new(); let mut route_count = 0usize; @@ -169,6 +197,11 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( invokable |= is_invoke; let routes = route_declarations(&method.attribute_lists, None, class, ctx); let details = route_details(&method.attribute_lists, ctx); + spans.extend( + details + .iter() + .flat_map(|details| details.permissions.iter().cloned()), + ); any_method_route |= !routes.is_empty(); let named = routes.iter().filter(is_named).count(); route_count += named; @@ -246,6 +279,8 @@ struct RouteDetails { title_callback: Option, entity_view: Option, entity_form: Option, + /// The spans of the permissions its `_permission` requirement names. + permissions: Vec, } /// The details of each `#[Route]` among `attribute_lists`, in the order @@ -294,6 +329,9 @@ fn route_details( .and_then(|form| crate::drupal::routing::entity_type_of(&form)); details.defaults = default_values(value, content); } + "requirements" => { + push_requirements_permissions(value, content, &mut details.permissions); + } _ => {} } } @@ -1182,6 +1220,403 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_url_spans( } } +/// Record the permissions `new Route()` requires: the `_permission` of its +/// requirements array, the third argument. +pub(in crate::symbol_map::extraction) fn emit_drupal_route_permission_spans( + class: &Expression<'_>, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Expression::Identifier(identifier) = class else { + return; + }; + let resolved = ctx + .resolved_name_at(identifier.span().start.offset) + .unwrap_or_else(|| bytes_to_str(identifier.value())); + if resolved + .trim_start_matches('\\') + .eq_ignore_ascii_case(SYMFONY_ROUTE) + && let Some(requirements) = argument_at(argument_list, 2, b"requirements") + { + push_requirements_permissions(requirements, ctx.content, &mut ctx.spans); + } +} + +const SYMFONY_ROUTE: &str = "Symfony\\Component\\Routing\\Route"; + +/// Record the permissions a method call reads: the `_permission` a route's +/// `setRequirement()` or `setRequirements()` sets. The calls that check a +/// permission on an account or a role are recorded with the receiver they +/// need (see `symfony_receiver_method`). +pub(in crate::symbol_map::extraction) fn emit_drupal_requirement_permission_spans( + member_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + let is = |name: &str| member_name.eq_ignore_ascii_case(name); + if !ctx.drupal || !(is("setRequirement") || is("setRequirements")) { + return; + } + if is("setRequirement") { + let is_permission = argument_at(argument_list, 0, b"key") + .and_then(|key| resolved_string_literal(key, ctx.content)) + .is_some_and(|(key, _, _)| key == "_permission"); + if is_permission && let Some(value) = argument_at(argument_list, 1, b"regex") { + push_requirement_permissions(value, ctx.content, &mut ctx.spans); + } + } else if let Some(requirements) = argument_at(argument_list, 0, b"requirements") { + push_requirements_permissions(requirements, ctx.content, &mut ctx.spans); + } +} + +/// Record the permissions a static call reads: the second argument of +/// `AccessResult::allowedIfHasPermission()`, and each one in the array +/// `allowedIfHasPermissions()` takes second. +pub(in crate::symbol_map::extraction) fn emit_drupal_static_permission_spans( + class: &str, + method: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Some(list) = crate::drupal::permissions::static_permission_call(class, method) else { + return; + }; + let Some(argument) = argument_at(argument_list, 1, b"permission") + .or_else(|| argument_at(argument_list, 1, b"permissions")) + else { + return; + }; + if list { + push_permission_list(argument, ctx.content, &mut ctx.spans); + } else { + push_laravel_string_span( + StringKeyKind::DrupalPermission, + false, + false, + argument, + ctx.content, + &mut ctx.spans, + ); + } +} + +/// Record the permissions `user_role_grant_permissions()` and +/// `user_role_revoke_permissions()`, written as `written`, take in the +/// array they take second. +pub(in crate::symbol_map::extraction) fn emit_drupal_function_permission_spans( + written: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal || !crate::drupal::permissions::is_role_permissions_function(written) { + return; + } + if let Some(permissions) = argument_at(argument_list, 1, b"permissions") { + push_permission_list(permissions, ctx.content, &mut ctx.spans); + } +} + +/// Push a span for each permission an array literal lists. +fn push_permission_list(list: &Expression<'_>, content: &str, spans: &mut Vec) { + let Some(elements) = crate::parser::array_literal_elements(list) else { + return; + }; + for element in elements.iter() { + if let ArrayElement::Value(value) = element { + push_laravel_string_span( + StringKeyKind::DrupalPermission, + false, + false, + value.value, + content, + spans, + ); + } + } +} + +/// Push the spans of the permissions a requirements array's `_permission` +/// names. +fn push_requirements_permissions( + requirements: &Expression<'_>, + content: &str, + spans: &mut Vec, +) { + if let Some(value) = array_entry(requirements, "_permission", content) { + push_requirement_permissions(value, content, spans); + } +} + +/// Push the spans of the permissions a `_permission` requirement written +/// as `value` names, each part of an `a+b` or `a,b` on its own. +fn push_requirement_permissions( + value: &Expression<'_>, + content: &str, + spans: &mut Vec, +) { + // A string with escapes reads differently from its text, whose + // offsets the parts would not match. + if let Some((text, start, end)) = resolved_string_literal(value, content) + && text.len() == end - start + { + spans.extend(crate::drupal::permissions::requirement_spans(text, start)); + } +} + +/// Record the configuration name `new ConfigTarget()` reads first, and the +/// keys of that object its property path, a key or a list of them, names +/// second. +pub(in crate::symbol_map::extraction) fn emit_drupal_config_target_spans( + class: &Expression<'_>, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Expression::Identifier(identifier) = class else { + return; + }; + let resolved = ctx + .resolved_name_at(identifier.span().start.offset) + .unwrap_or_else(|| bytes_to_str(identifier.value())); + if !crate::drupal::is_config_target_class(resolved) { + return; + } + let Some(name) = argument_at(argument_list, 0, b"configName") else { + return; + }; + push_laravel_string_span( + StringKeyKind::DrupalConfigName, + false, + false, + name, + ctx.content, + &mut ctx.spans, + ); + let Some((name, _, _)) = resolved_string_literal(name, ctx.content) else { + return; + }; + let Some(path) = argument_at(argument_list, 1, b"propertyPath") else { + return; + }; + let keys: Vec<&Expression<'_>> = match crate::parser::array_literal_elements(path) { + Some(elements) => elements + .iter() + .filter_map(|element| match element { + ArrayElement::Value(value) => Some(value.value), + _ => None, + }) + .collect(), + None => vec![path], + }; + for key in keys { + if let Some((key, start, end)) = + resolved_string_literal(key, ctx.content).filter(|(key, _, _)| !key.is_empty()) + { + push_config_key_span(name, key, start, end, &mut ctx.spans); + } + } +} + +/// Push the span of the key `key` of the configuration object `name`, +/// written from `start` to `end`. +fn push_config_key_span( + name: &str, + key: &str, + start: usize, + end: usize, + spans: &mut Vec, +) { + spans.push(SymbolSpan { + start: start as u32, + end: end as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalConfigKey, + key: crate::drupal::config_keys::config_key(name, key), + is_write: false, + is_optional: false, + }, + }); +} + +/// Record the key an editable config object's `set()` or `clear()` reads, +/// for the type engine to confirm against its receiver, whose type carries +/// the object's name. Its `get()` is recorded as a container lookup's, +/// which the config object is one of the targets of. +pub(in crate::symbol_map::extraction) fn record_drupal_config_key_sites( + member_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + if !member_name.eq_ignore_ascii_case("set") && !member_name.eq_ignore_ascii_case("clear") { + return; + } + let Some(Argument::Positional(key)) = argument_list.arguments.first() else { + return; + }; + if let Some((key, start, end)) = + resolved_string_literal(key.value, ctx.content).filter(|(key, _, _)| !key.is_empty()) + { + ctx.typed_receiver_sites.push(TypedReceiverSite { + start: start as u32, + end: end as u32, + key: key.to_string(), + is_write: false, + is_optional: false, + guards: 0, + receiver: ReceiverClass::EditableConfigKeys, + }); + } +} + +/// Record the configuration name a form element's `#config_target` names +/// before its `:`, among the entries of an array literal. +pub(in crate::symbol_map::extraction) fn emit_drupal_config_target_entry_spans( + elements: &TokenSeparatedSequence<'_, ArrayElement<'_>>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + for element in elements.iter() { + let ArrayElement::KeyValue(entry) = element else { + continue; + }; + let is_target = resolved_string_literal(entry.key, ctx.content) + .is_some_and(|(key, _, _)| key == "#config_target"); + if !is_target { + continue; + } + if let Some((target, start, end)) = resolved_string_literal(entry.value, ctx.content) + && target.len() == end - start + { + let (name, key) = target.split_once(':').unwrap_or((target, "")); + if !name.is_empty() && !key.is_empty() { + let key_start = start + name.len() + 1; + push_config_key_span(name, key, key_start, end, &mut ctx.spans); + } + if !name.is_empty() { + ctx.spans.push(SymbolSpan { + start: start as u32, + end: (start + name.len()) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalConfigName, + key: name.to_string(), + is_write: false, + is_optional: false, + }, + }); + } + } + } +} + +/// Record each configuration name the array a config factory's +/// `loadMultiple()` takes lists, for the type engine to confirm against +/// its receiver. +pub(in crate::symbol_map::extraction) fn record_drupal_config_list_sites( + member_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal || !member_name.eq_ignore_ascii_case("loadMultiple") { + return; + } + let Some(Argument::Positional(names)) = argument_list.arguments.first() else { + return; + }; + let Some(elements) = crate::parser::array_literal_elements(names.value) else { + return; + }; + for element in elements.iter() { + let ArrayElement::Value(value) = element else { + continue; + }; + if let Some((name, start, end)) = resolved_string_literal(value.value, ctx.content) + .filter(|(name, _, _)| !name.is_empty()) + { + ctx.typed_receiver_sites.push(TypedReceiverSite { + start: start as u32, + end: end as u32, + key: name.to_string(), + is_write: false, + is_optional: false, + guards: 0, + receiver: ReceiverClass::ConfigFactory, + }); + } + } +} + +/// Record the configuration names a config form's +/// `getEditableConfigNames()` returns in an array literal. +pub(in crate::symbol_map::extraction) fn emit_drupal_editable_config_spans( + members: &Sequence<'_, ClassLikeMember<'_>>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + for member in members.iter() { + let ClassLikeMember::Method(method) = member else { + continue; + }; + if !method + .name + .value + .eq_ignore_ascii_case(b"getEditableConfigNames") + { + continue; + } + let mut returned = Vec::new(); + mago_syntax::walker::Walker::walk_method(&ReturnWalker, method, &mut returned); + for value in returned { + let Some(elements) = crate::parser::array_literal_elements(value) else { + continue; + }; + for element in elements.iter() { + if let ArrayElement::Value(name) = element { + push_laravel_string_span( + StringKeyKind::DrupalConfigName, + false, + false, + name.value, + ctx.content, + &mut ctx.spans, + ); + } + } + } + } +} + +/// The values a method's `return` statements return. +struct ReturnWalker; + +impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, Vec<&'ast Expression<'arena>>> + for ReturnWalker +{ + fn walk_in_return( + &self, + statement: &'ast Return<'arena>, + returned: &mut Vec<&'ast Expression<'arena>>, + ) { + if let Some(value) = statement.value { + returned.push(value); + } + } +} + /// Record the route name a call reads in its positional argument `index`, /// or in the `route_name:` argument that names it, and the parameters of /// that route it passes in the argument after it, or in the @@ -1348,6 +1783,29 @@ fn annotation_entity_type(doc: &str, offset: u32) -> Option<(String, u32, u32, O }) } +/// The span of the permission the `admin_permission` of an entity type +/// annotation in the docblock `doc`, which starts at `offset` in the file, +/// names. +fn annotation_admin_permission(doc: &str, offset: u32) -> Option { + let body = ENTITY_TYPE_KINDS + .iter() + .find_map(|kind| Some(doc.find(kind.annotation)? + kind.annotation.len()))?; + let entry = annotation_entries(doc, body) + .into_iter() + .find(|entry| entry.path.is_empty() && entry.key == "admin_permission")?; + let key = &doc[entry.value_start..entry.value_end]; + (!key.is_empty()).then(|| SymbolSpan { + start: offset + entry.value_start as u32, + end: offset + entry.value_end as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalPermission, + key: key.to_string(), + is_write: false, + is_optional: false, + }, + }) +} + /// A `key = "value"` pair of an annotation, with the keys of the `{…}` /// blocks around it. struct AnnotationEntry<'d> { @@ -1486,6 +1944,304 @@ mod tests { } } + /// Each method that checks or changes a permission reaches the classes + /// that have it, and no other table claims the method first. + #[test] + fn every_permission_method_reaches_its_drupal_classes() { + for (method, classes) in [ + ( + "hasPermission", + &[ + "Drupal\\Core\\Session\\AccountInterface", + "Drupal\\user\\RoleInterface", + "Drupal\\Core\\Session\\PermissionCheckerInterface", + "Drupal\\Core\\Session\\CalculatedPermissionsItemInterface", + ][..], + ), + ("grantPermission", &["Drupal\\user\\RoleInterface"][..]), + ("revokePermission", &["Drupal\\user\\RoleInterface"][..]), + ] { + let (receiver, is_write) = + crate::symbol_map::extraction::symfony_receiver_method(method, 0) + .unwrap_or_else(|| panic!("{method} names no permission")); + assert!(!is_write, "{method}"); + for class in classes { + assert!( + receiver + .targets() + .contains(&(*class, StringKeyKind::DrupalPermission)), + "{method}: {receiver:?} has no {class} target" + ); + } + assert!(hook_invocation(method, 0).is_none(), "{method}"); + assert!( + crate::drupal::route_parameter_call(method).is_none(), + "{method}" + ); + } + } + + /// Each method that reads a configuration name reaches the classes that + /// have it, and no other table claims the method first. + #[test] + fn every_config_method_reaches_its_drupal_classes() { + for (method, classes) in [ + ("get", &["Drupal\\Core\\Config\\ConfigFactoryInterface"][..]), + ( + "getEditable", + &["Drupal\\Core\\Config\\ConfigFactoryInterface"][..], + ), + ( + "loadMultiple", + &["Drupal\\Core\\Config\\ConfigFactoryInterface"][..], + ), + ( + "config", + &[ + "Drupal\\Core\\Controller\\ControllerBase", + "Drupal\\Core\\Form\\FormBase", + ][..], + ), + ] { + let (receiver, is_write) = + crate::symbol_map::extraction::symfony_receiver_method(method, 0) + .unwrap_or_else(|| panic!("{method} names no configuration")); + assert!(!is_write, "{method}"); + for class in classes { + assert!( + receiver + .targets() + .contains(&(*class, StringKeyKind::DrupalConfigName)), + "{method}: {receiver:?} has no {class} target" + ); + } + assert!(hook_invocation(method, 0).is_none(), "{method}"); + assert!( + crate::drupal::route_parameter_call(method).is_none(), + "{method}" + ); + } + } + + /// Each configuration name a static call, a `ConfigTarget`, a + /// `#config_target`, or a config form's editable names reads is a span + /// over its own text, and the names a `loadMultiple()` lists are left + /// for the type engine. + #[test] + fn config_name_spans_cover_each_name() { + let php = r#" 'acme.e:key', '#title' => 'acme.not']; + $factory->loadMultiple(['acme.f', 'acme.g']); + } +} +"#; + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + doctrine: false, + }, + None, + ); + let found: Vec<&str> = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalConfigName, + key, + .. + } => { + let text = &php[span.start as usize..span.end as usize]; + assert_eq!(key, text); + Some(text) + } + _ => None, + }) + .collect(); + assert_eq!(found, ["acme.a", "acme.b", "acme.c", "acme.d", "acme.e"]); + let listed: Vec<&str> = map + .typed_receiver_sites + .iter() + .filter(|site| site.receiver == ReceiverClass::ConfigFactory) + .map(|site| site.key.as_str()) + .collect(); + assert_eq!(listed, ["acme.f", "acme.g"]); + } + + /// A configuration key written beside its object's name is a span over + /// its own text, keyed by both, and one a config object reads is left + /// for the type engine, which knows the object. + #[test] + fn config_key_spans_cover_each_key() { + let php = r#" 'acme.a:notify.email']; + new ConfigTarget('acme.b', 'one'); + new ConfigTarget('acme.c', ['two', 'three']); + $config->get('four'); + $config->set('five', 1); + $config->clear('six'); + $config->other('seven'); + } +} +"#; + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + doctrine: false, + }, + None, + ); + let found: Vec<(&str, &str)> = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalConfigKey, + key, + .. + } => Some((&php[span.start as usize..span.end as usize], key.as_str())), + _ => None, + }) + .collect(); + assert_eq!( + found, + [ + ("notify.email", "acme.a#notify.email"), + ("one", "acme.b#one"), + ("two", "acme.c#two"), + ("three", "acme.c#three"), + ] + ); + let sites: Vec<(&str, ReceiverClass)> = map + .typed_receiver_sites + .iter() + .filter(|site| { + site.receiver + .targets() + .iter() + .any(|(_, kind)| *kind == StringKeyKind::DrupalConfigKey) + }) + .map(|site| (site.key.as_str(), site.receiver)) + .collect(); + assert_eq!( + sites, + [ + ("four", ReceiverClass::ContainerLookup), + ("five", ReceiverClass::EditableConfigKeys), + ("six", ReceiverClass::EditableConfigKeys), + ] + ); + } + + /// Each permission a requirement, a list, or an entity type names is a + /// span over its own text. + #[test] + fn permission_spans_cover_each_name() { + let php = r#" 'a+b']); + new Route('/a', requirements: ['_permission' => 'c,d+e']); + $route->setRequirement('_permission', 'f'); + $route->setRequirement('_format', 'json'); + AccessResult::allowedIfHasPermissions($account, ['g', 'h'], 'OR'); + AccessResult::allowedIfHasPermission($account, 'i'); + user_role_grant_permissions('editor', ['j']); + } +} +"#; + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + doctrine: false, + }, + None, + ); + let found: Vec<&str> = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalPermission, + key, + .. + } => { + let text = &php[span.start as usize..span.end as usize]; + assert_eq!(key, text); + Some(text) + } + _ => None, + }) + .collect(); + assert_eq!( + found, + [ + "administer acme", + "a", + "b", + "c", + "d+e", + "f", + "g", + "h", + "i", + "j" + ] + ); + } + /// The entity type IDs, classes, and storages `php` declares. fn declared(php: &str) -> Vec<(String, String, Option)> { let arena = mago_allocator::LocalArena::new(); diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index 71808bd33..743580dcc 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -45,6 +45,8 @@ pub(super) fn extract_instantiation_expr<'a>( emit_symfony_instantiation_spans(inst.class, args, ctx); emit_twig_callable_instantiation(inst.class, args, ctx); emit_drupal_url_spans(inst.class, args, ctx); + emit_drupal_route_permission_spans(inst.class, args, ctx); + emit_drupal_config_target_spans(inst.class, args, ctx); if !class_text.is_empty() { emit_call_site( format!("new {}", class_text), @@ -423,6 +425,7 @@ fn extract_function_call<'a>( ctx, ); emit_wordpress_hook_span(raw, &func_call.argument_list, ctx); + emit_drupal_function_permission_spans(raw, &func_call.argument_list, ctx); if let Some(trigger) = crate::symbol_map::laravel_resources::auth_helper_trigger( ctx.content, name_clean, @@ -490,6 +493,9 @@ fn extract_method_call<'a>( record_symfony_receiver_sites(object, &member_name, argument_list, ctx); record_drupal_route_parameter_sites(&member_name, argument_list, ctx); record_drupal_hook_invocations(&member_name, argument_list, ctx); + emit_drupal_requirement_permission_spans(&member_name, argument_list, ctx); + record_drupal_config_list_sites(&member_name, argument_list, ctx); + record_drupal_config_key_sites(&member_name, argument_list, ctx); record_yii_view_sites(object, &member_name, argument_list, ctx); emit_mock_method_spans(object, &member_name, argument_list, ctx); emit_entity_field_spans(object, &member_name, argument_list, ctx); @@ -619,6 +625,14 @@ fn extract_static_method_call<'a>( &mut ctx.spans, ); } + if ctx.drupal && crate::drupal::is_config_lookup(semantic_subject, &member_name) { + try_emit_laravel_string_span( + crate::symbol_map::StringKeyKind::DrupalConfigName, + &static_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } if ctx.drupal && crate::drupal::is_entity_query(semantic_subject, &member_name) { try_emit_laravel_string_span( crate::symbol_map::StringKeyKind::DrupalEntityType, @@ -633,6 +647,12 @@ fn extract_static_method_call<'a>( { emit_drupal_route_span(index, &static_call.argument_list, ctx); } + emit_drupal_static_permission_spans( + semantic_subject, + &member_name, + &static_call.argument_list, + ctx, + ); } extract_from_arguments(&static_call.argument_list.arguments, ctx, scope_start); } diff --git a/src/symbol_map/extraction/expressions/mod.rs b/src/symbol_map/extraction/expressions/mod.rs index 24c3aee71..77b22cdb9 100644 --- a/src/symbol_map/extraction/expressions/mod.rs +++ b/src/symbol_map/extraction/expressions/mod.rs @@ -242,10 +242,12 @@ pub(super) fn extract_from_expression<'a>( // ── Array ── Expression::Array(array) => { try_emit_array_callable_span(&array.elements, ctx.content, &mut ctx.spans); + emit_drupal_config_target_entry_spans(&array.elements, ctx); extract_from_array_elements(&array.elements, ctx, scope_start); } Expression::LegacyArray(array) => { try_emit_array_callable_span(&array.elements, ctx.content, &mut ctx.spans); + emit_drupal_config_target_entry_spans(&array.elements, ctx); extract_from_array_elements(&array.elements, ctx, scope_start); } Expression::List(list) => { diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 79a85b83f..cd14fab4d 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -364,7 +364,7 @@ use wordpress::*; use yii::*; use yii_routes::*; -pub(crate) use drupal::hook_invocation; +pub(crate) use drupal::{hook_invocation, is_entity_type_attribute}; pub(crate) use symfony::{ configurator_function_kind, is_autowire_attribute, is_bus_name_stamp, is_event_listener_attribute, is_message_handler_attribute, is_template_attribute, diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index 4b67dcde9..84ad30aa6 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -77,7 +77,7 @@ pub(crate) fn symfony_receiver_method( // turned away on a single byte. if !matches!( member_name.as_bytes().first().map(u8::to_ascii_lowercase), - Some(b'g' | b'h' | b's' | b'a' | b'd' | b'r' | b't' | b'p' | b'f' | b'c') + Some(b'g' | b'h' | b's' | b'a' | b'd' | b'r' | b't' | b'p' | b'f' | b'c' | b'l') ) { return None; } @@ -150,6 +150,12 @@ pub(crate) fn symfony_receiver_method( { Some((ReceiverClass::EntityTypeManager, false)) } + 0 if is("hasPermission") => Some((ReceiverClass::PermissionCheck, false)), + 0 if is("getEditable") || is("loadMultiple") => Some((ReceiverClass::ConfigFactory, false)), + 0 if is("config") => Some((ReceiverClass::ConfigShortcut, false)), + 0 if is("grantPermission") || is("revokePermission") => { + Some((ReceiverClass::RolePermissions, false)) + } 0 if is("addListener") => Some((ReceiverClass::EventListeners, true)), 0 if is("removeListener") || is("hasListeners") @@ -174,6 +180,11 @@ pub(in crate::symbol_map::extraction) fn record_symfony_receiver_sites( if !ctx.service_container && !ctx.twig && !ctx.yii { return; } + // Only Drupal's config factory has a method starting with `l` the + // table names, so elsewhere the one byte still turns those away. + if !ctx.drupal && matches!(member_name.as_bytes().first(), Some(b'l' | b'L')) { + return; + } if ctx.symfony && member_name.eq_ignore_ascii_case("trans") { record_translator_sites(argument_list, ctx); return; diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index c23656bdf..0a81e7d58 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -182,6 +182,24 @@ pub(crate) enum StringKeyKind { /// theme manager's calls that invoke it, and read by the implementations /// a `#[Hook]` attribute or a procedural function's name make. DrupalHook, + /// A Drupal permission name: declared by a top-level key of a + /// permissions file, and read by an account's or a role's + /// `hasPermission()`, the access result helpers, a route's + /// `_permission` requirement, and an entity type's `admin_permission`. + DrupalPermission, + /// A Drupal configuration name: declared by a default configuration + /// file's name and by a configuration schema entry (see + /// `drupal::config`), and read by `\Drupal::config()`, the config + /// factory, the `config()` shortcut of controllers and forms, a config + /// form's `getEditableConfigNames()`, and a form element's + /// `#config_target`. + DrupalConfigName, + /// A key of a Drupal configuration object, keyed by the object's name + /// as well as the key (see `drupal::config_keys::config_key`): + /// declared by the object's configuration schema, and read by a config + /// object's `get()`, `set()`, and `clear()`, the part after the `:` of + /// a `#config_target`, and a `ConfigTarget`'s property path. + DrupalConfigKey, } impl StringKeyKind { @@ -232,7 +250,10 @@ impl StringKeyKind { Self::DrupalEntityType | Self::DrupalRoute | Self::DrupalRouteParameter - | Self::DrupalHook => KeyFramework::Drupal, + | Self::DrupalHook + | Self::DrupalPermission + | Self::DrupalConfigName + | Self::DrupalConfigKey => KeyFramework::Drupal, } } @@ -873,6 +894,14 @@ const DRUPAL_ACCESS_MANAGER: &str = "Drupal\\Core\\Access\\AccessManagerInterfac const DRUPAL_ROUTE_PROVIDER: &str = "Drupal\\Core\\Routing\\RouteProviderInterface"; pub(crate) const DRUPAL_MODULE_HANDLER: &str = "Drupal\\Core\\Extension\\ModuleHandlerInterface"; pub(crate) const DRUPAL_THEME_MANAGER: &str = "Drupal\\Core\\Theme\\ThemeManagerInterface"; +pub(crate) const DRUPAL_CONFIG_FACTORY: &str = "Drupal\\Core\\Config\\ConfigFactoryInterface"; +const DRUPAL_CONFIG_BASE: &str = "Drupal\\Core\\Config\\ConfigBase"; +const DRUPAL_CONFIG: &str = "Drupal\\Core\\Config\\Config"; +const DRUPAL_ACCOUNT: &str = "Drupal\\Core\\Session\\AccountInterface"; +const DRUPAL_ROLE: &str = "Drupal\\user\\RoleInterface"; +const DRUPAL_PERMISSION_CHECKER: &str = "Drupal\\Core\\Session\\PermissionCheckerInterface"; +const DRUPAL_CALCULATED_PERMISSIONS_ITEM: &str = + "Drupal\\Core\\Session\\CalculatedPermissionsItemInterface"; /// The namespace of the PHP configurators that build Symfony's service /// container. @@ -903,7 +932,10 @@ pub(crate) enum ReceiverClass { MailTemplate, /// What `get()` and `has()` look a name up in: a service container, or /// one of Symfony's parameter bags, whose same two methods take a - /// parameter name instead. + /// parameter name instead, or Drupal's config factory, whose `get()` + /// takes a configuration name, or a Drupal config object, whose `get()` + /// takes one of its keys; the object it is comes from its type (see + /// `drupal::types`). ContainerLookup, /// What `getParameter()`, `hasParameter()`, and `setParameter()` take a /// container parameter name in: the container itself, and the @@ -1003,6 +1035,24 @@ pub(crate) enum ReceiverClass { ThemeHooks, /// Either of the two, for the methods they share. Hooks, + /// What `hasPermission()` takes a permission name first on: an + /// account, a role, the permission checker, and a calculated + /// permissions item. + PermissionCheck, + /// A Drupal role, whose `grantPermission()` and `revokePermission()` + /// take a permission name. + RolePermissions, + /// Drupal's config factory, whose `getEditable()` takes a + /// configuration name and `loadMultiple()` a list of them; its `get()` + /// is a [`Self::ContainerLookup`]. + ConfigFactory, + /// What has Drupal's `config()` shortcut, which takes a configuration + /// name: a controller or a form, a config form included. + ConfigShortcut, + /// An editable Drupal config object, whose `set()` and `clear()` take + /// one of its keys; an `ImmutableConfig` throws on both. Its `get()` + /// is a [`Self::ContainerLookup`], as every config object's is. + EditableConfigKeys, /// Symfony's serializer, normalizers, and denormalizers, whose /// `serialize()`, `deserialize()`, `normalize()`, and `denormalize()` /// take a context whose `groups` name serializer groups. @@ -1032,6 +1082,8 @@ impl ReceiverClass { // A container bag is a PSR container too, so it has to be asked // about first. Self::ContainerLookup => &[ + (DRUPAL_CONFIG_FACTORY, StringKeyKind::DrupalConfigName), + (DRUPAL_CONFIG_BASE, StringKeyKind::DrupalConfigKey), (SYMFONY_CONTAINER_BAG, ContainerParameter), (SYMFONY_PARAMETER_BAG, ContainerParameter), (PSR_CONTAINER, ServiceId), @@ -1129,6 +1181,22 @@ impl ReceiverClass { (DRUPAL_MODULE_HANDLER, StringKeyKind::DrupalHook), (DRUPAL_THEME_MANAGER, StringKeyKind::DrupalHook), ], + Self::PermissionCheck => &[ + (DRUPAL_ACCOUNT, StringKeyKind::DrupalPermission), + (DRUPAL_ROLE, StringKeyKind::DrupalPermission), + (DRUPAL_PERMISSION_CHECKER, StringKeyKind::DrupalPermission), + ( + DRUPAL_CALCULATED_PERMISSIONS_ITEM, + StringKeyKind::DrupalPermission, + ), + ], + Self::RolePermissions => &[(DRUPAL_ROLE, StringKeyKind::DrupalPermission)], + Self::ConfigFactory => &[(DRUPAL_CONFIG_FACTORY, StringKeyKind::DrupalConfigName)], + Self::EditableConfigKeys => &[(DRUPAL_CONFIG, StringKeyKind::DrupalConfigKey)], + Self::ConfigShortcut => &[ + (DRUPAL_CONTROLLER_BASE, StringKeyKind::DrupalConfigName), + (DRUPAL_FORM_BASE, StringKeyKind::DrupalConfigName), + ], Self::Serializer => &[ (SYMFONY_SERIALIZER, StringKeyKind::SerializerGroup), (SYMFONY_NORMALIZER, StringKeyKind::SerializerGroup), @@ -1226,6 +1294,13 @@ impl TypedReceiverSite { /// Whether this site, once confirmed, could name `key` as a `kind`. /// The reference index files a site under each kind it could become. pub(crate) fn may_name(&self, kind: StringKeyKind, key: &str) -> bool { + // A configuration key's site holds the key alone; the object's + // name comes with its receiver's type. + let key = if kind == StringKeyKind::DrupalConfigKey { + crate::drupal::config_keys::split_config_key(key).1 + } else { + key + }; self.key == key && self .receiver diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 115c4f0b0..4e27caa2a 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -385,6 +385,10 @@ impl Backend { kind: StringKeyKind, except: Option<(&str, u32)>, ) -> Vec { + // Configuration names are declared by files no symbol map reads. + if kind == StringKeyKind::DrupalConfigName { + return self.drupal_config_names(); + } let mut names = Vec::new(); if kind == StringKeyKind::TranslationDomain { self.visit_symfony_declarations( diff --git a/src/type_engine/call_resolution/return_types/call_arms.rs b/src/type_engine/call_resolution/return_types/call_arms.rs index 88b8afe49..881edc63d 100644 --- a/src/type_engine/call_resolution/return_types/call_arms.rs +++ b/src/type_engine/call_resolution/return_types/call_arms.rs @@ -104,6 +104,37 @@ impl Backend { } return classes; } + // A configuration object read by its name carries the name. + if let Some(backend) = in_drupal + && (method_name.eq_ignore_ascii_case("config") + || method_name.eq_ignore_ascii_case("get") + || method_name.eq_ignore_ascii_case("getEditable")) + && let Some(name) = first_literal_text_arg(text_args, "name") + && let Some(ty) = lhs_resolved + .iter() + .filter_map(|resolved| resolved.class_info.as_deref()) + .find_map(|owner| { + crate::drupal::types::config_object_type( + backend, + owner, + method_name, + name, + ctx.file.class_loader, + ctx.resolved_class_cache, + ) + }) + { + let classes = crate::type_engine::type_resolution::type_hint_to_classes_typed_returned( + &ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ); + if let Some(ref mut hint_out) = return_type_hint_out { + **hint_out = Some(ty); + } + return classes; + } if method_name.eq_ignore_ascii_case("getStorage") && let Some(backend) = in_drupal && let Some(id) = first_literal_text_arg(text_args, "entity_type_id") @@ -117,8 +148,8 @@ impl Backend { ctx.file.class_loader, ) { // A chain carries one hint: with two declarations of the - // ID it is their union, which a following load cannot - // read a single entity from. + // ID it is the union of their tagged storages, and a + // load on it gives either entity. let (classes, types): (Vec<_>, Vec<_>) = storages.into_iter().unzip(); if let Some(ref mut hint_out) = return_type_hint_out { **hint_out = Some(crate::php_type::PhpType::union(types)); @@ -449,6 +480,32 @@ impl Backend { { return classes; } + // `\Drupal::config('acme.settings')` carries the name it reads. + if method_name.eq_ignore_ascii_case("config") + && let Some(ref owner) = owner_class + && let Some(backend) = ctx.file.backend + && backend.is_drupal_project() + && let Some(name) = first_literal_text_arg(text_args, "name") + && let Some(ty) = crate::drupal::types::config_object_type( + backend, + owner, + method_name, + name, + ctx.file.class_loader, + ctx.resolved_class_cache, + ) + { + let classes = crate::type_engine::type_resolution::type_hint_to_classes_typed_returned( + &ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ); + if let Some(ref mut hint_out) = return_type_hint_out { + **hint_out = Some(ty); + } + return classes; + } // `\Drupal::entityQuery('node')` is a query tagged with the entity // type's classes (see `crate::drupal::types`). if method_name.eq_ignore_ascii_case("entityQuery") diff --git a/src/type_engine/resolver/mod.rs b/src/type_engine/resolver/mod.rs index 5836758b6..1813ff588 100644 --- a/src/type_engine/resolver/mod.rs +++ b/src/type_engine/resolver/mod.rs @@ -491,9 +491,17 @@ fn resolve_call_subject( // of alternatives. The same holds for an intersection inside a // union (`(A&I)|B`). if let Some(h) = hint { - let hint_names_a_class = matches!(h.kind(), TypeKind::Generic(g) if classes - .iter() - .any(|c| g.name.as_str() == c.fqn().as_str() || g.name.as_str() == c.name.as_str())); + let generic_names_a_class = |t: &PhpType| { + matches!(t.kind(), TypeKind::Generic(g) if classes + .iter() + .any(|c| g.name.as_str() == c.fqn().as_str() || g.name.as_str() == c.name.as_str())) + }; + // A union of such generics (`Config<'a'>|ImmutableConfig<'a'>`) + // carries them for each of its classes. + let hint_names_a_class = match h.kind() { + TypeKind::Union(members) => members.iter().any(generic_names_a_class), + _ => generic_names_a_class(&h), + }; let is_intersection = match h.kind() { TypeKind::Intersection(_) => true, TypeKind::Union(members) => members diff --git a/src/type_engine/variable/rhs_resolution/drupal_calls.rs b/src/type_engine/variable/rhs_resolution/drupal_calls.rs index 9d97b1066..4b40dd9db 100644 --- a/src/type_engine/variable/rhs_resolution/drupal_calls.rs +++ b/src/type_engine/variable/rhs_resolution/drupal_calls.rs @@ -67,6 +67,41 @@ pub(super) fn try_resolve_entity_storage_type( ) } +/// What a call that reads a configuration object by a literal name +/// returns: its declared type tagged with the name. +pub(super) fn try_resolve_config_object_type( + owner: &ClassInfo, + method_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &VarResolutionCtx<'_>, +) -> Option> { + let is = |name: &str| method_name.eq_ignore_ascii_case(name); + if !is("config") && !is("get") && !is("getEditable") { + return None; + } + let backend = ctx + .file + .backend + .filter(|backend| backend.is_drupal_project())?; + let name = literal_first(argument_list, "name", ctx.file.content)?; + let ty = crate::drupal::types::config_object_type( + backend, + owner, + method_name, + name, + ctx.file.class_loader, + ctx.resolved_class_cache, + )?; + Some( + crate::type_engine::type_ops::resolution::resolved_types_for_hint( + ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ), + ) +} + /// What a load on a tagged entity storage returns. pub(super) fn try_resolve_entity_load_type( method_name: &str, diff --git a/src/type_engine/variable/rhs_resolution/method_calls.rs b/src/type_engine/variable/rhs_resolution/method_calls.rs index edefee43b..04143181d 100644 --- a/src/type_engine/variable/rhs_resolution/method_calls.rs +++ b/src/type_engine/variable/rhs_resolution/method_calls.rs @@ -322,6 +322,14 @@ pub(super) fn resolve_member_call_on_receiver<'b>( ) { return result; } + if let Some(result) = super::drupal_calls::try_resolve_config_object_type( + owner, + &method_name, + argument_list, + ctx, + ) { + return result; + } if let Some(result) = try_resolve_handle_trait_type(owner, &method_name, argument_list, ctx) { diff --git a/src/type_engine/variable/rhs_resolution/static_calls.rs b/src/type_engine/variable/rhs_resolution/static_calls.rs index 43d142372..5001e9773 100644 --- a/src/type_engine/variable/rhs_resolution/static_calls.rs +++ b/src/type_engine/variable/rhs_resolution/static_calls.rs @@ -194,6 +194,14 @@ fn static_call_on_class( ) { return result; } + if let Some(result) = super::drupal_calls::try_resolve_config_object_type( + owner, + method_name, + argument_list, + ctx, + ) { + return result; + } } let arg_texts = static_arg_texts(static_call, ctx); diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index c017107c4..9283c3b45 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -81,8 +81,11 @@ pub(crate) fn resolve_laravel_string_key( | StringKeyKind::YiiRoute | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRoute - | StringKeyKind::DrupalRouteParameter => backend.symfony_declaration_locations(*kind, key), + | StringKeyKind::DrupalRouteParameter + | StringKeyKind::DrupalPermission => backend.symfony_declaration_locations(*kind, key), StringKeyKind::DrupalHook => backend.drupal_hook_documentation(key), + StringKeyKind::DrupalConfigName => backend.drupal_config_locations(key), + StringKeyKind::DrupalConfigKey => backend.drupal_config_key_locations(key), StringKeyKind::YiiView => backend.resolve_yii_view_definitions(key), StringKeyKind::TwigBlock => backend.twig_block_definitions(uri, None, key), StringKeyKind::TwigVariable => backend.twig_variable_definitions(uri, None, key), @@ -321,7 +324,8 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::YiiRoute | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRoute - | StringKeyKind::DrupalRouteParameter => { + | StringKeyKind::DrupalRouteParameter + | StringKeyKind::DrupalPermission => { return find_string_key_usages(kind, key, backend, snapshot, include_declaration); } // A documented pattern stands for every hook that fills it in. @@ -335,7 +339,9 @@ pub(crate) fn find_laravel_string_key_references( // declarations are too. StringKeyKind::TranslationDomain | StringKeyKind::YiiRouteArgument - | StringKeyKind::SymfonyRoute => { + | StringKeyKind::SymfonyRoute + | StringKeyKind::DrupalConfigName + | StringKeyKind::DrupalConfigKey => { find_string_key_usages(kind, key, backend, snapshot, false) } // Two spellings of one view share no key, so the renders are found diff --git a/tests/integration/drupal_config.rs b/tests/integration/drupal_config.rs new file mode 100644 index 000000000..8fdcb7736 --- /dev/null +++ b/tests/integration/drupal_config.rs @@ -0,0 +1,571 @@ +//! Drupal's configuration names: the configuration objects an extension +//! ships default configuration for or describes in its schema, read by +//! `\Drupal::config()`, the config factory, the `config()` shortcut, a +//! config form's editable names, and a form element's `#config_target`. + +use crate::common::{ + completion_response, hover_text_at, initialize_with_options, offset_position, response_labels, +}; +use crate::drupal_services::{definitions_at, position_in, references_at, uri_for, workspace_in}; +use tower_lsp_server::LanguageServer; +use tower_lsp_server::gen_lsp_types::*; + +/// Just enough of core's configuration API, where Composer would put core. +const STUBS: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal.php", + "factory->get('acme.settings'); + $this->factory->getEditable('acme.settings'); + $this->factory->loadMultiple(['acme.settings', 'system.site']); + $this->config('acme.settings'); + $form['notify'] = ['#type' => 'email', '#config_target' => 'acme.settings:notify']; + $form['other'] = ['#config_target' => new ConfigTarget('acme.settings', 'other')]; + return $form; + } +} +"#; + +const FORM_PATH: &str = "web/modules/custom/acme/src/Form/SettingsForm.php"; + +const CONTROLLER: &str = r#"config('system.site'); + \Drupal::config('system.menu.main'); + } +} +"#; + +const CONTROLLER_PATH: &str = "web/modules/custom/acme/src/Controller/SiteController.php"; + +fn site() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in CORE { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + dir +} + +fn files<'a>(extra: &[(&'a str, &'a str)]) -> Vec<(&'a str, &'a str)> { + STUBS.iter().copied().chain(extra.iter().copied()).collect() +} + +async fn site_backend(extra: &[(&str, &str)]) -> (phpantom_lsp::Backend, tempfile::TempDir) { + workspace_in(site(), &files(extra)).await +} + +/// Each location as its file and line. +fn places(found: &[Location]) -> Vec<(Uri, u32)> { + found + .iter() + .map(|location| (location.uri.clone(), location.range.start.line)) + .collect() +} + +/// Every place that names a configuration object goes to its default +/// configuration file and its schema entry, a custom module's and core's +/// alike. +#[tokio::test] +async fn every_place_goes_to_the_default_file_and_the_schema() { + let (backend, dir) = site_backend(&[ + (DEFAULT_PATH, DEFAULT), + (SCHEMA_PATH, SCHEMA), + (FORM_PATH, FORM), + (CONTROLLER_PATH, CONTROLLER), + ]) + .await; + let acme = vec![ + (uri_for(&dir, DEFAULT_PATH), 0), + (uri_for(&dir, SCHEMA_PATH), 0), + ]; + let form = uri_for(&dir, FORM_PATH); + let sites: Vec = FORM + .match_indices("'acme.settings") + .map(|(at, _)| at) + .collect(); + assert_eq!(sites.len(), 8); + for at in sites { + let position = offset_position(FORM, at + 3); + let found = definitions_at(&backend, &form, position).await; + assert_eq!(places(&found), acme, "{position:?}"); + } + + let core = vec![ + ( + uri_for( + &dir, + "web/core/modules/system/config/install/system.site.yml", + ), + 0, + ), + ( + uri_for( + &dir, + "web/core/modules/system/config/schema/system.schema.yml", + ), + 0, + ), + ]; + let controller = uri_for(&dir, CONTROLLER_PATH); + for (uri, content) in [(&form, FORM), (&controller, CONTROLLER)] { + let position = position_in(content, "'system.site'", 3); + let found = definitions_at(&backend, uri, position).await; + assert_eq!(places(&found), core, "{content}"); + } + + // A configuration entity's name finds the wildcard entry for its kind. + let found = definitions_at( + &backend, + &controller, + position_in(CONTROLLER, "'system.menu.main'", 3), + ) + .await; + assert_eq!( + places(&found), + [ + ( + uri_for( + &dir, + "web/core/modules/system/config/install/system.menu.main.yml" + ), + 0 + ), + ( + uri_for( + &dir, + "web/core/modules/system/config/schema/system.schema.yml" + ), + 3 + ), + ] + ); + + let hover = hover_text_at(&backend, &form, 15, 26) + .await + .unwrap_or_default(); + assert!(hover.contains("Acme settings"), "{hover}"); + assert!(hover.contains("acme.settings.yml"), "{hover}"); + assert!(hover.contains("acme.schema.yml"), "{hover}"); + + // The references are every read and both declarations. + let found = references_at( + &backend, + &form, + offset_position(FORM, FORM.find("'acme.settings").unwrap() + 3), + ) + .await; + assert_eq!(found.len(), 10, "{found:?}"); +} + +#[tokio::test] +async fn names_complete_in_every_place_and_data_types_do_not() { + let form = FORM.replace("acme.settings", ""); + let controller = CONTROLLER.replace("system.site", ""); + let (backend, dir) = site_backend(&[ + (DEFAULT_PATH, DEFAULT), + (SCHEMA_PATH, SCHEMA), + (FORM_PATH, form.as_str()), + (CONTROLLER_PATH, controller.as_str()), + ]) + .await; + let mut places: Vec<(&str, &str, usize)> = form + .match_indices("''") + .map(|(at, _)| (FORM_PATH, form.as_str(), at + 1)) + .collect(); + // The `#config_target`'s name, before its key. + places.push(( + FORM_PATH, + form.as_str(), + form.find("':notify'").unwrap() + 1, + )); + places.push(( + CONTROLLER_PATH, + controller.as_str(), + controller.find("''").unwrap() + 1, + )); + assert_eq!(places.len(), 9); + for (path, content, at) in places { + let position = offset_position(content, at); + let labels = response_labels( + completion_response( + &backend, + &uri_for(&dir, path), + position.line, + position.character, + None, + ) + .await, + ); + for name in [ + "acme.settings", + "acme.limits", + "system.site", + "system.menu.main", + "system.theme.global", + ] { + assert!( + labels.contains(&name.to_string()), + "{path}:{position:?}: {labels:?}" + ); + } + for not_a_name in [ + "system.menu.*", + "theme_settings", + "config_object", + "block.settings.system_menu_block:*", + "config_test.system", + "config_test.types", + ] { + assert!( + !labels.contains(¬_a_name.to_string()), + "{path}:{position:?}: {labels:?}" + ); + } + } +} + +/// Every place reaches a core name too. +#[tokio::test] +async fn every_place_goes_to_core_configuration() { + let form = FORM.replace("acme.settings", "system.site"); + let (backend, dir) = site_backend(&[(FORM_PATH, form.as_str())]).await; + let core = vec![ + ( + uri_for( + &dir, + "web/core/modules/system/config/install/system.site.yml", + ), + 0, + ), + ( + uri_for( + &dir, + "web/core/modules/system/config/schema/system.schema.yml", + ), + 0, + ), + ]; + let uri = uri_for(&dir, FORM_PATH); + let sites: Vec = form + .match_indices("'system.site") + .map(|(at, _)| at) + .collect(); + assert_eq!(sites.len(), 9); + for at in sites { + let position = offset_position(&form, at + 3); + let found = definitions_at(&backend, &uri, position).await; + assert_eq!(places(&found), core, "{position:?}"); + } +} + +/// A test module's configuration counts while its file is open. +#[tokio::test] +async fn an_open_test_module_offers_its_names() { + let controller = CONTROLLER.replace("system.site", ""); + let fixture = + "web/core/modules/system/tests/modules/config_test/config/install/config_test.system.yml"; + let fixture_schema = + "web/core/modules/system/tests/modules/config_test/config/schema/config_test.schema.yml"; + let (backend, dir) = site_backend(&[ + (CONTROLLER_PATH, controller.as_str()), + (fixture, "foo: bar\n"), + ( + fixture_schema, + "config_test.types:\n type: config_object\n", + ), + ]) + .await; + let php = uri_for(&dir, CONTROLLER_PATH); + let position = offset_position(&controller, controller.find("''").unwrap() + 1); + let offered = || async { + response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ) + }; + let labels = offered().await; + assert!( + labels.contains(&"config_test.system".to_string()), + "{labels:?}" + ); + assert!( + labels.contains(&"config_test.types".to_string()), + "{labels:?}" + ); + + for path in [fixture, fixture_schema] { + backend + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { + uri: uri_for(&dir, path), + }, + }) + .await; + } + let labels = offered().await; + assert!( + !labels.contains(&"config_test.system".to_string()), + "{labels:?}" + ); + assert!( + !labels.contains(&"config_test.types".to_string()), + "{labels:?}" + ); +} + +/// A class that uses core's `ConfigFormBaseTrait` without being a form, +/// as a list builder may, reads a name in its `config()` too. +#[tokio::test] +async fn a_class_using_the_config_form_trait_reads_names() { + let trait_stub = "config('acme.settings'); + } +} +"#; + let path = "web/modules/custom/acme/src/ListBuilder.php"; + let (backend, dir) = site_backend(&[ + ( + "web/core/lib/Drupal/Core/Form/ConfigFormBaseTrait.php", + trait_stub, + ), + (DEFAULT_PATH, DEFAULT), + (SCHEMA_PATH, SCHEMA), + (path, builder), + ]) + .await; + let found = definitions_at( + &backend, + &uri_for(&dir, path), + position_in(builder, "'acme.settings'", 3), + ) + .await; + assert_eq!( + places(&found), + [ + (uri_for(&dir, DEFAULT_PATH), 0), + (uri_for(&dir, SCHEMA_PATH), 0) + ] + ); +} + +/// The same short names, of classes that are not Drupal's, read nothing. +#[tokio::test] +async fn look_alike_classes_read_no_name() { + let consumer = r#"get('acme.settings'); + $registry->config('acme.settings'); + $x = ['#config_target' => 1]; + } +} +"#; + let path = "web/modules/custom/acme/src/Elsewhere.php"; + let (backend, dir) = site_backend(&[ + (DEFAULT_PATH, DEFAULT), + (SCHEMA_PATH, SCHEMA), + (path, consumer), + ]) + .await; + let uri = uri_for(&dir, path); + for (at, _) in consumer.match_indices("'acme.settings'") { + let found = definitions_at(&backend, &uri, offset_position(consumer, at + 3)).await; + assert!(found.is_empty(), "{at}: {found:?}"); + } +} + +/// A project that is not a Drupal site offers no configuration name. +#[tokio::test] +async fn a_project_that_is_not_drupal_reads_no_name() { + let dir = tempfile::tempdir().unwrap(); + let files = [ + ( + "composer.json", + r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#, + ), + ("src/config/install/acme.settings.yml", "notify: ''\n"), + ( + "src/Check.php", + " tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in CORE { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + dir +} + +async fn site_backend(php: &str) -> (phpantom_lsp::Backend, tempfile::TempDir) { + let files: Vec<(&str, &str)> = STUBS + .iter() + .copied() + .chain([(SCHEMA_PATH, SCHEMA), (FORM_PATH, php)]) + .collect(); + workspace_in(site(), &files).await +} + +/// The labels completion offers at `marker` (`|`) in `template`, which is +/// opened without the marker. +async fn offered_at(template: &str) -> Vec { + let at = template.find('|').expect("marker"); + let php = template.replacen('|', "", 1); + let (backend, dir) = site_backend(&php).await; + let position = offset_position(&php, at); + response_labels( + completion_response( + &backend, + &uri_for(&dir, FORM_PATH), + position.line, + position.character, + None, + ) + .await, + ) +} + +fn form(body: &str) -> String { + format!( + r#"settings = $factory->get('acme.settings'); + }} + + public function run(): void {{ + {body} + }} +}} +"# + ) +} + +const TOP_LEVEL: [&str; 7] = [ + "_core", + "dynamic", + "langcode", + "loopy", + "message", + "notifications", + "notify_email", +]; + +#[tokio::test] +async fn keys_complete_after_every_way_of_reading_the_object() { + for body in [ + "\\Drupal::config('acme.settings')->get('|');", + "$config = \\Drupal::config('acme.settings');\n $config->get('|');", + "$this->factory->get('acme.settings')->get('|');", + "$this->settings->get('|');", + "$this->config('acme.settings')->get('|');", + "$this->factory->getEditable('acme.settings')->set('|', 1);", + "$config = $this->config('acme.settings');\n $config->clear('|');", + "$x = ['#config_target' => 'acme.settings:|'];", + "new ConfigTarget('acme.settings', '|');", + "new ConfigTarget('acme.settings', ['notify_email', '|']);", + ] { + let mut labels = offered_at(&form(body)).await; + labels.sort(); + assert_eq!(labels, TOP_LEVEL, "{body}"); + } +} + +#[tokio::test] +async fn nested_keys_complete_a_level_at_a_time() { + for (body, expected) in [ + ( + "\\Drupal::config('acme.settings')->get('notifications.|');", + &["email"][..], + ), + // A named type's keys. + ( + "\\Drupal::config('acme.settings')->get('message.|');", + &["body", "subject"][..], + ), + // A type that comes back to itself, and a placeholder, have none. + ( + "\\Drupal::config('acme.settings')->get('loopy.|');", + &[][..], + ), + ( + "\\Drupal::config('acme.settings')->get('dynamic.|');", + &[][..], + ), + // A configuration entity's name finds the wildcard entry. + // A configuration entity's name finds the wildcard entry, whose + // `config_entity` type this schema does not declare. + ( + "\\Drupal::config('node.type.article')->get('|');", + &["name"][..], + ), + ( + "$x = ['#config_target' => 'acme.settings:notifications.|'];", + &["email"][..], + ), + ] { + let mut labels = offered_at(&form(body)).await; + labels.sort(); + assert_eq!(labels, expected, "{body}"); + } +} + +/// A class that uses `ConfigFormBaseTrait` without being a form reads the +/// object its `config()` names, as a config form does. +#[tokio::test] +async fn a_class_using_the_config_form_trait_carries_the_name() { + let template = r#"config('acme.settings')->get('|'); + } +} +"#; + let mut labels = offered_at(template).await; + labels.sort(); + assert_eq!(labels, TOP_LEVEL); +} + +/// `set()` and `clear()` throw on an `ImmutableConfig`, so nothing is +/// offered there. +#[tokio::test] +async fn an_immutable_object_offers_no_key_to_write() { + for body in [ + "\\Drupal::config('acme.settings')->set('|', 1);", + "$this->settings->clear('|');", + ] { + let labels = offered_at(&form(body)).await; + assert!( + !labels.iter().any(|label| label == "notify_email"), + "{body}: {labels:?}" + ); + } +} + +#[tokio::test] +async fn a_key_goes_to_its_schema_line() { + let body = "\\Drupal::config('acme.settings')->get('notifications.email');\n $this->settings->get('notify_email');\n $this->factory->getEditable('acme.settings')->set('notify_email', 1);\n $x = ['#config_target' => 'acme.settings:notify_email'];\n new ConfigTarget('acme.settings', 'notify_email');\n \\Drupal::config('acme.settings')->get('message.subject');"; + let php = form(body); + let (backend, dir) = site_backend(&php).await; + let uri = uri_for(&dir, FORM_PATH); + let schema = uri_for(&dir, SCHEMA_PATH); + let data_types = uri_for(&dir, "web/core/config/schema/core.data_types.schema.yml"); + let line_of = + |needle: &str| SCHEMA[..SCHEMA.find(needle).unwrap()].matches('\n').count() as u32; + let cases = [ + ( + "'notifications.email'", + 16, + (&schema, line_of(" email:")), + ), + ("'notify_email'", 2, (&schema, line_of(" notify_email:"))), + ( + "set('notify_email'", + 6, + (&schema, line_of(" notify_email:")), + ), + ( + "settings:notify_email'", + 10, + (&schema, line_of(" notify_email:")), + ), + ( + "'acme.settings', 'notify_email'", + 19, + (&schema, line_of(" notify_email:")), + ), + ("'message.subject'", 10, (&data_types, 10)), + ]; + for (needle, inside, (expected_uri, expected_line)) in cases { + let at = php.find(needle).unwrap() + inside; + let found = definitions_at(&backend, &uri, offset_position(&php, at)).await; + assert_eq!( + found + .iter() + .map(|location| (&location.uri, location.range.start.line)) + .collect::>(), + [(expected_uri, expected_line)], + "{needle}" + ); + } +} + +/// The references of a key reach the reads a config object's type +/// confirms in other files, from either end. +#[tokio::test] +async fn key_references_reach_typed_reads_in_other_files() { + let php = form("$x = ['#config_target' => 'acme.settings:notify_email'];"); + let reader_path = "web/modules/custom/acme/src/Reader.php"; + let reader = "get('notify_email');\n \\Drupal::config('other.settings')->get('notify_email');\n }\n}\n"; + let files: Vec<(&str, &str)> = STUBS + .iter() + .copied() + .chain([ + (SCHEMA_PATH, SCHEMA), + (FORM_PATH, php.as_str()), + (reader_path, reader), + ]) + .collect(); + let (backend, dir) = workspace_in(site(), &files).await; + let form_uri = uri_for(&dir, FORM_PATH); + let reader_uri = uri_for(&dir, reader_path); + let at = php.find("settings:notify_email").unwrap() + 12; + let from_form = + crate::drupal_services::references_at(&backend, &form_uri, offset_position(&php, at)).await; + let at = reader.find("get('notify_email')").unwrap() + 6; + let from_reader = + crate::drupal_services::references_at(&backend, &reader_uri, offset_position(reader, at)) + .await; + for found in [&from_form, &from_reader] { + // The other object's key of the same name is not one of them. + assert_eq!( + found + .iter() + .filter(|(uri, _)| *uri == reader_uri) + .collect::>(), + [&(reader_uri.clone(), 5)], + "{found:?}" + ); + assert!(found.iter().any(|(uri, _)| *uri == form_uri), "{found:?}"); + } +} + +/// The object's type carries its name, which hover shows. +#[tokio::test] +async fn hover_shows_the_name_an_object_carries() { + let php = form("$config = \\Drupal::config('acme.settings');\n $config;"); + let (backend, dir) = site_backend(&php).await; + let position = offset_position(&php, php.find("$config;").unwrap() + 2); + let hover = hover_text_at( + &backend, + &uri_for(&dir, FORM_PATH), + position.line, + position.character, + ) + .await + .unwrap_or_default(); + assert!( + hover.contains("ImmutableConfig<'acme.settings'>"), + "{hover}" + ); +} + +/// Editing the schema on disk drops the keys read from it. +#[tokio::test] +async fn an_edited_schema_offers_its_new_keys() { + let template = form("\\Drupal::config('acme.settings')->get('|');"); + let at = template.find('|').unwrap(); + let php = template.replacen('|', "", 1); + let dir = site(); + let schema_path = dir.path().join(SCHEMA_PATH); + std::fs::create_dir_all(schema_path.parent().unwrap()).unwrap(); + std::fs::write(&schema_path, SCHEMA).unwrap(); + let files: Vec<(&str, &str)> = STUBS + .iter() + .copied() + .chain([(FORM_PATH, php.as_str())]) + .collect(); + let (backend, dir) = workspace_in(dir, &files).await; + let uri = uri_for(&dir, FORM_PATH); + let position = offset_position(&php, at); + let offered = || async { + response_labels( + completion_response(&backend, &uri, position.line, position.character, None).await, + ) + }; + assert!(offered().await.contains(&"notify_email".to_string())); + + std::fs::write( + &schema_path, + SCHEMA.replace( + " notify_email:", + " added_key:\n type: string\n notify_email:", + ), + ) + .unwrap(); + backend + .did_change_watched_files(DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: uri_for(&dir, SCHEMA_PATH), + kind: FileChangeType::Changed, + }], + }) + .await; + let labels = offered().await; + assert!(labels.contains(&"added_key".to_string()), "{labels:?}"); +} + +/// The name rides on the type and changes nothing else about it: the +/// object keeps its members, and passes and returns as the class its +/// method declares. +#[tokio::test] +async fn a_tagged_object_is_still_its_class() { + let body = "$config = \\Drupal::config('acme.settings');\n $config->|"; + let labels = offered_at(&form(body)).await; + assert!( + labels.iter().any(|label| label.starts_with("save")), + "{labels:?}" + ); + + let php = form( + "$this->takes(\\Drupal::config('acme.settings'));\n $this->takes($this->settings);\n }\n\n private function takes(ImmutableConfig $config): ImmutableConfig {\n return $this->factory->get('acme.settings');", + ); + let (backend, dir) = site_backend(&php).await; + let uri = uri_for(&dir, FORM_PATH); + backend.update_ast(uri.as_str(), &php); + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), &php, &mut diagnostics); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); +} diff --git a/tests/integration/drupal_entities.rs b/tests/integration/drupal_entities.rs index 130827d73..5130a09ec 100644 --- a/tests/integration/drupal_entities.rs +++ b/tests/integration/drupal_entities.rs @@ -482,3 +482,60 @@ async fn entity_query_ids_go_to_their_declaration_and_complete() { assert!(labels.contains(&"acme_note".to_string()), "{labels:?}"); assert!(labels.contains(&"acme_style".to_string()), "{labels:?}"); } + +/// An entity type ID two classes declare, each with its own storage, loads +/// either entity when the load is chained straight on `getStorage()`. +#[tokio::test] +async fn a_chained_load_on_an_id_declared_twice_is_either_entity() { + let note = NOTE.replace( + "class Note {}", + "class Note {\n public function noteOnly(): void {}\n}", + ); + let other = r#" OtherStorage::class], +)] +class OtherNote { + public function otherOnly(): void {} +} + +class OtherStorage extends \Drupal\Core\Entity\Sql\SqlContentEntityStorage {} +"#; + let chained = r#"getStorage('acme_note')->load(1)?->noteOnly(); + $manager->getStorage('acme_note')->load(1)?->otherOnly(); + } +} +"#; + let path = "web/modules/custom/acme/src/Chained.php"; + let other_path = "web/modules/custom/acme/src/Entity/OtherNote.php"; + let mut files: Vec<(&str, &str)> = STORAGE_STUBS.to_vec(); + files.extend([ + (MANAGER_PATH, MANAGER), + (NOTE_PATH, note.as_str()), + (other_path, other), + (path, chained), + ]); + let (backend, dir) = workspace(&files).await; + let php = uri_for(&dir, path); + for (needle, target) in [ + ("load(1)?->noteOnly", NOTE_PATH), + ("load(1)?->otherOnly", other_path), + ] { + let inside = needle.rfind('>').unwrap() + 2; + let found = definitions_at(&backend, &php, position_in(chained, needle, inside)).await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!(found[0].uri, uri_for(&dir, target), "{needle}"); + } +} diff --git a/tests/integration/drupal_permissions.rs b/tests/integration/drupal_permissions.rs new file mode 100644 index 000000000..2fbdd27b6 --- /dev/null +++ b/tests/integration/drupal_permissions.rs @@ -0,0 +1,681 @@ +//! Drupal's permission names: the keys a permissions file declares, read by +//! the access checks of accounts and roles, the access result helpers, a +//! route's `_permission` requirement, and an entity type's +//! `admin_permission`. + +use crate::common::{completion_response, hover_text_at, initialize_with_options, response_labels}; +use crate::drupal_services::{ + definitions_at, position_in, references_at, uri_for, workspace, workspace_in, +}; +use tower_lsp_server::LanguageServer; +use tower_lsp_server::gen_lsp_types::*; + +/// Just enough of core's access API, where Composer would put core. +const STUBS: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal/Core/Session/AccountInterface.php", + "hasPermission('administer acme'); + $role->hasPermission('administer acme'); + $this->checker->hasPermission('administer acme', $account); + $item->hasPermission('administer acme'); + $role->grantPermission('administer acme'); + $role->revokePermission('administer acme'); + AccessResult::allowedIfHasPermission($account, 'administer acme'); + AccessResult::allowedIfHasPermissions($account, ['view acme notes', 'administer acme']); + user_role_grant_permissions('editor', ['administer acme']); + \user_role_revoke_permissions('editor', ['administer acme']); + $route = new Route('/acme', [], ['_permission' => 'view acme notes+administer acme']); + $route->setRequirement('_permission', 'administer acme'); + $route->setRequirements(['_permission' => 'view acme notes,administer acme']); + } +} +"#; + +const CONSUMER_PATH: &str = "web/modules/custom/acme/src/Access.php"; + +const CONTROLLER: &str = r#" 'administer acme'])] + public function list(): array { return []; } +} +"#; + +const CONTROLLER_PATH: &str = "web/modules/custom/acme/src/Controller/NotesController.php"; + +const ENTITY: &str = r#"(extra: &[(&'a str, &'a str)]) -> Vec<(&'a str, &'a str)> { + STUBS.iter().copied().chain(extra.iter().copied()).collect() +} + +/// The 0-based lines of `content` holding `needle`. +fn lines_of(content: &str, needle: &str) -> Vec { + content + .lines() + .enumerate() + .filter(|(_, line)| line.contains(needle)) + .map(|(at, _)| at as u32) + .collect() +} + +#[tokio::test] +async fn a_permission_goes_to_its_permissions_file_and_shows_its_title() { + let (backend, dir) = workspace(&files(&[ + (PERMISSIONS_PATH, PERMISSIONS), + (CONSUMER_PATH, CONSUMER), + ])) + .await; + let php = uri_for(&dir, CONSUMER_PATH); + let yaml = uri_for(&dir, PERMISSIONS_PATH); + + let found = definitions_at( + &backend, + &php, + position_in(CONSUMER, "$account->hasPermission('administer acme')", 26), + ) + .await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!((&found[0].uri, found[0].range.start.line), (&yaml, 0)); + + let position = position_in(CONSUMER, "allowedIfHasPermission($account, 'administer", 35); + let hover = hover_text_at(&backend, &php, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("Administer acme"), "{hover}"); + assert!(hover.contains("Change every acme setting."), "{hover}"); + assert!(hover.contains("Restricted"), "{hover}"); + assert!(hover.contains("acme.permissions.yml"), "{hover}"); + + let position = position_in(PERMISSIONS, "view acme notes", 2); + let hover = hover_text_at(&backend, &yaml, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("View acme notes"), "{hover}"); + assert!(!hover.contains("Restricted"), "{hover}"); +} + +/// Every place that names a permission counts among its references. +#[tokio::test] +async fn every_place_that_names_a_permission_reads_it() { + let (backend, dir) = workspace(&files(&[ + (PERMISSIONS_PATH, PERMISSIONS), + (CONSUMER_PATH, CONSUMER), + (CONTROLLER_PATH, CONTROLLER), + (ENTITY_PATH, ENTITY), + (ROUTING_PATH, ROUTING), + ])) + .await; + let yaml = uri_for(&dir, PERMISSIONS_PATH); + let found = references_at(&backend, &yaml, Position::new(0, 3)).await; + + let mut expected = vec![(yaml.clone(), 0)]; + for (path, content) in [ + (CONSUMER_PATH, CONSUMER), + (CONTROLLER_PATH, CONTROLLER), + (ENTITY_PATH, ENTITY), + (ROUTING_PATH, ROUTING), + ] { + let uri = uri_for(&dir, path); + expected.extend( + lines_of(content, "administer acme") + .into_iter() + .map(|line| (uri.clone(), line)), + ); + } + expected.sort(); + assert_eq!(found, expected); + + let found = references_at(&backend, &yaml, Position::new(4, 3)).await; + let php = uri_for(&dir, CONSUMER_PATH); + let mut expected = vec![(yaml, 4)]; + expected.extend( + lines_of(CONSUMER, "view acme notes") + .into_iter() + .map(|line| (php.clone(), line)), + ); + expected.sort(); + assert_eq!(found, expected); +} + +/// Core's site, with its permissions files where Composer puts them, and a +/// test module's beside them. +fn core_site() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ( + "web/core/modules/system/system.permissions.yml", + "access content:\n title: 'View published content'\n", + ), + (SYSTEM_TEST_PATH, "system test only:\n title: 'Test'\n"), + ] { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + dir +} + +const SYSTEM_TEST_PATH: &str = + "web/core/modules/system/tests/modules/system_test/system_test.permissions.yml"; + +/// Every place that names a permission goes to its declaration and shows +/// it on hover, a custom module's and core's alike. +#[tokio::test] +async fn every_place_goes_to_the_permission_and_shows_it() { + let core_reader = "hasPermission('access content');\n }\n}\n"; + let core_reader_path = "web/modules/custom/acme/src/Front.php"; + let (backend, dir) = workspace_in( + core_site(), + &files(&[ + (PERMISSIONS_PATH, PERMISSIONS), + (CONSUMER_PATH, CONSUMER), + (CONTROLLER_PATH, CONTROLLER), + (ENTITY_PATH, ENTITY), + (ROUTING_PATH, ROUTING), + (core_reader_path, core_reader), + ]), + ) + .await; + // Finding references indexes the workspace, core's permissions files + // included. + references_at( + &backend, + &uri_for(&dir, PERMISSIONS_PATH), + Position::new(0, 3), + ) + .await; + + let yaml = uri_for(&dir, PERMISSIONS_PATH); + let system = uri_for(&dir, "web/core/modules/system/system.permissions.yml"); + let mut places = Vec::new(); + for (path, content) in [ + (CONSUMER_PATH, CONSUMER), + (CONTROLLER_PATH, CONTROLLER), + (ENTITY_PATH, ENTITY), + (ROUTING_PATH, ROUTING), + ] { + for (at, _) in content.match_indices("administer acme") { + places.push((path, content, at, &yaml, "Administer acme")); + } + } + for (path, content) in [(ROUTING_PATH, ROUTING), (core_reader_path, core_reader)] { + let at = content.find("access content").unwrap(); + places.push((path, content, at, &system, "View published content")); + } + assert_eq!(places.len(), 19); + for (path, content, at, declared, title) in places { + let uri = uri_for(&dir, path); + let position = crate::common::offset_position(content, at + 2); + let found = definitions_at(&backend, &uri, position).await; + assert_eq!( + found + .iter() + .map(|location| (&location.uri, location.range.start.line)) + .collect::>(), + [(declared, 0)], + "{path}:{position:?}" + ); + let hover = hover_text_at(&backend, &uri, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains(title), "{path}:{position:?}: {hover}"); + } +} + +/// A test module's permissions count while its file is open. +#[tokio::test] +async fn an_open_test_module_offers_its_permissions() { + let consumer = CONSUMER.replace( + "$account->hasPermission('administer acme')", + "$account->hasPermission('')", + ); + let fixture = "system test only:\n title: 'Test'\n"; + let (backend, dir) = workspace_in( + core_site(), + &files(&[ + (CONSUMER_PATH, consumer.as_str()), + (SYSTEM_TEST_PATH, fixture), + ]), + ) + .await; + let position = position_in(&consumer, "hasPermission('')", 15); + let labels = response_labels( + completion_response( + &backend, + &uri_for(&dir, CONSUMER_PATH), + position.line, + position.character, + None, + ) + .await, + ); + assert!( + labels.contains(&"system test only".to_string()), + "{labels:?}" + ); +} + +/// The same short names, of classes that are not Drupal's, read nothing. +#[tokio::test] +async fn look_alike_classes_read_no_permission() { + let consumer = r#" 'administer acme']); + $gate->hasPermission('administer acme'); + acme_grant_permissions('editor', ['administer acme']); + } +} +"#; + let path = "web/modules/custom/acme/src/Elsewhere.php"; + let (backend, dir) = + workspace(&files(&[(PERMISSIONS_PATH, PERMISSIONS), (path, consumer)])).await; + let yaml = uri_for(&dir, PERMISSIONS_PATH); + let found = references_at(&backend, &yaml, Position::new(0, 3)).await; + assert_eq!(found, vec![(yaml, 0)]); +} + +#[tokio::test] +async fn core_permissions_complete_and_test_modules_do_not() { + let consumer = r#"hasPermission(''); + $role->grantPermission(''); + AccessResult::allowedIfHasPermission($account, ''); + AccessResult::allowedIfHasPermissions($account, ['view acme notes', '']); + user_role_revoke_permissions('editor', ['']); + new Route('/a', [], ['_permission' => 'view acme notes+']); + $route->setRequirement('_permission', ''); + } +} +"#; + let controller = + CONTROLLER.replace("'_permission' => 'administer acme'", "'_permission' => ''"); + let entity = ENTITY + .replace( + "admin_permission: 'administer acme'", + "admin_permission: ''", + ) + .replace( + "admin_permission = \"administer acme\"", + "admin_permission = \"\"", + ); + let routing = ROUTING.replace("'access content+administer acme'", "'access content+'"); + let (backend, dir) = workspace_in( + core_site(), + &files(&[ + (PERMISSIONS_PATH, PERMISSIONS), + (CONSUMER_PATH, consumer), + (CONTROLLER_PATH, controller.as_str()), + (ENTITY_PATH, entity.as_str()), + (ROUTING_PATH, routing.as_str()), + ]), + ) + .await; + // Finding references indexes the workspace, core's permissions files + // included. + references_at( + &backend, + &uri_for(&dir, PERMISSIONS_PATH), + Position::new(0, 3), + ) + .await; + + let mut places: Vec<(&str, String, Position)> = [ + "hasPermission('')", + "grantPermission('')", + "$account, '')", + "'view acme notes', '']", + "['']", + "acme notes+'", + "'_permission', '')", + ] + .iter() + .map(|needle| { + let inside = needle.rfind('\'').unwrap(); + ( + CONSUMER_PATH, + needle.to_string(), + position_in(consumer, needle, inside), + ) + }) + .collect(); + places.push(( + CONTROLLER_PATH, + "requirements".to_string(), + position_in(&controller, "'_permission' => ''", 18), + )); + places.push(( + ENTITY_PATH, + "attribute".to_string(), + position_in(&entity, "admin_permission: ''", 19), + )); + places.push(( + ENTITY_PATH, + "annotation".to_string(), + position_in(&entity, "admin_permission = \"\"", 20), + )); + places.push(( + ROUTING_PATH, + "routing".to_string(), + position_in(&routing, "access content+'", 15), + )); + for (path, label, position) in places { + let labels = response_labels( + completion_response( + &backend, + &uri_for(&dir, path), + position.line, + position.character, + None, + ) + .await, + ); + for declared in ["administer acme", "view acme notes", "access content"] { + assert!( + labels.contains(&declared.to_string()), + "{label}: {labels:?}" + ); + } + assert!( + !labels.contains(&"system test only".to_string()), + "{label}: {labels:?}" + ); + assert!( + !labels.contains(&"permission_callbacks".to_string()), + "{label}: {labels:?}" + ); + } +} + +/// A project that is not a Drupal site has no permissions to offer, and +/// reads no `hasPermission()` as one. +#[tokio::test] +async fn a_project_that_is_not_drupal_reads_no_permission() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("composer.json"), + r#"{ "autoload": { "psr-4": { "App\\": "src/" } } }"#, + ) + .unwrap(); + let files = [ + ( + "src/Account.php", + "hasPermission('');\n }\n}\n", + ), + ]; + for (path, content) in files { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let backend = phpantom_lsp::Backend::new_test(); + initialize_with_options(&backend, dir.path(), None).await; + let uri = uri_for(&dir, "src/Check.php"); + crate::common::open_document(&backend, &uri, "php", files[2].1).await; + let labels = response_labels(completion_response(&backend, &uri, 4, 33, None).await); + assert!( + !labels.contains(&"administer acme".to_string()), + "{labels:?}" + ); +} + +async fn notify(backend: &phpantom_lsp::Backend, uri: &Uri, kind: FileChangeType) { + backend + .did_change_watched_files(DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: uri.clone(), + kind, + }], + }) + .await; +} + +/// Adding, editing, or deleting a custom module's permissions file on disk +/// refreshes the names offered. +#[tokio::test] +async fn a_permissions_file_changed_on_disk_refreshes_the_names() { + let consumer = CONSUMER.replace( + "$account->hasPermission('administer acme')", + "$account->hasPermission('')", + ); + let (backend, dir) = workspace(&files(&[(CONSUMER_PATH, consumer.as_str())])).await; + let php = uri_for(&dir, CONSUMER_PATH); + let position = position_in(&consumer, "hasPermission('')", 15); + let offered = || async { + response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ) + }; + assert!(!offered().await.contains(&"administer acme".to_string())); + + let path = dir.path().join(PERMISSIONS_PATH); + let yaml = uri_for(&dir, PERMISSIONS_PATH); + std::fs::write(&path, PERMISSIONS).unwrap(); + notify(&backend, &yaml, FileChangeType::Created).await; + let labels = offered().await; + assert!( + labels.contains(&"administer acme".to_string()), + "{labels:?}" + ); + + std::fs::write(&path, "administer acme notes:\n title: 'Administer'\n").unwrap(); + notify(&backend, &yaml, FileChangeType::Changed).await; + let labels = offered().await; + assert!( + labels.contains(&"administer acme notes".to_string()), + "{labels:?}" + ); + assert!( + !labels.contains(&"administer acme".to_string()), + "{labels:?}" + ); + + std::fs::remove_file(&path).unwrap(); + notify(&backend, &yaml, FileChangeType::Deleted).await; + let labels = offered().await; + assert!( + !labels.contains(&"administer acme notes".to_string()), + "{labels:?}" + ); +} + +/// A comment just above the string, holding quotes and backticks of its +/// own, changes nothing, and the same text inside the comment is no +/// string to complete: the first `hasPermission('` of the file is the +/// comment's. +#[tokio::test] +async fn a_comment_with_quotes_before_the_string_changes_nothing() { + let consumer = r#"hasPermission(''); + } +} +"#; + let entity = r#"hasPermission(''"), + (ENTITY_PATH, entity, " admin_permission: ''"), + ] { + let at = content.find(needle).unwrap() + needle.len() - 1; + let position = crate::common::offset_position(content, at); + let labels = response_labels( + completion_response( + &backend, + &uri_for(&dir, path), + position.line, + position.character, + None, + ) + .await, + ); + assert!( + labels.contains(&"administer acme".to_string()), + "{path}: {labels:?}" + ); + + let in_comment = content + .find(needle.trim_start().trim_start_matches("return $account->")) + .unwrap(); + let in_comment = in_comment + + needle + .trim_start() + .trim_start_matches("return $account->") + .len() + - 1; + assert!( + content[..in_comment] + .rsplit('\n') + .next() + .unwrap() + .contains("//") + ); + let position = crate::common::offset_position(content, in_comment); + let labels = response_labels( + completion_response( + &backend, + &uri_for(&dir, path), + position.line, + position.character, + None, + ) + .await, + ); + assert!(labels.is_empty(), "{path}, in the comment: {labels:?}"); + } +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 9f785eb39..0e3221e5c 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -205,10 +205,13 @@ mod document_highlight; mod document_links; mod document_symbols; mod document_symbols_blade; +mod drupal_config; +mod drupal_config_keys; mod drupal_directories; mod drupal_entities; mod drupal_hooks; mod drupal_mixed_workspace; +mod drupal_permissions; mod drupal_routes; mod drupal_services; mod duplicate_class_declarations;