diff --git a/benches/drupal_completion.rs b/benches/drupal_completion.rs index 45657c9c1..697051d1f 100644 --- a/benches/drupal_completion.rs +++ b/benches/drupal_completion.rs @@ -8,8 +8,9 @@ use tower_lsp_server::gen_lsp_types::*; /// Custom modules in the generated site, each declaring `SERVICES` /// services, `ROUTES` routes, `ENTITY_TYPES` entity types, `PERMISSIONS` -/// permissions, and `CONFIGS` configuration objects, with their default -/// configuration and schema, and documenting `HOOKS` hooks, and +/// permissions, `CONFIGS` configuration objects, with their default +/// configuration and schema, `ELEMENTS` render elements, `THEME_HOOKS` +/// theme hooks, and `LIBRARIES` libraries, documenting `HOOKS` hooks, and /// implementing the hooks the module before it documents. const MODULES: usize = 30; const SERVICES: usize = 10; @@ -17,7 +18,10 @@ const ROUTES: usize = 10; const ENTITY_TYPES: usize = 3; const PERMISSIONS: usize = 10; const CONFIGS: usize = 10; +const ELEMENTS: usize = 3; const HOOKS: usize = 10; +const THEME_HOOKS: usize = 5; +const LIBRARIES: usize = 10; const COMPOSER: &str = r#"{ "require": { "drupal/core-recommended": "^11" }, @@ -44,6 +48,10 @@ const STUBS: &[(&str, &str)] = &[ "web/core/lib/Drupal/Core/Entity/Attribute/ContentEntityType.php", "modules->invokeAll('mod1_'); $this->account->hasPermission('administer mod1 '); \Drupal::config('mod1.'); + $build = ['#type' => 'mod1_']; + $themed = ['#theme' => 'mod1_']; + $build = ['#attached' => ['library' => ['mod1/']]]; } } "#; @@ -103,7 +114,8 @@ fn rt() -> tokio::runtime::Runtime { } /// The files of a site with many custom modules: their services, routes, -/// entity types, documented hooks, and hook implementations. +/// entity types, render elements, documented hooks, hook implementations, +/// and theme hooks. fn generate_site() -> Vec<(String, String)> { let mut files = Vec::new(); @@ -154,6 +166,15 @@ fn generate_site() -> Vec<(String, String)> { } files.push((format!("{dir}/config/schema/mod{m}.schema.yml"), schema)); + let mut libraries = String::new(); + for l in 0..LIBRARIES { + let _ = writeln!( + libraries, + "library{l}:\n version: 1.x\n js:\n js/library{l}.js: {{}}\n dependencies:\n - core/drupal" + ); + } + files.push((format!("{dir}/mod{m}.libraries.yml"), libraries)); + for e in 0..ENTITY_TYPES { files.push(( format!("{dir}/src/Entity/Entity{e}.php"), @@ -163,6 +184,15 @@ fn generate_site() -> Vec<(String, String)> { )); } + for e in 0..ELEMENTS { + files.push(( + format!("{dir}/src/Element/Element{e}.php"), + format!( + " Vec<(String, String)> { } files.push((format!("{dir}/mod{m}.api.php"), api)); + let mut theme = format!( + " ['variables' => ['label' => NULL, 'items' => []]]," + ); + } + theme.push_str(" ];\n }\n}\n"); + files.push((format!("{dir}/src/Hook/ThemeHooks.php"), theme)); + if m > 0 { let previous = m - 1; let mut module = String::from(" 'mod1_'", + "mod1_element2", + ), ( "hook_name_invoke_all", &consumer, @@ -337,6 +386,20 @@ fn bench_drupal_name_completion(c: &mut Criterion) { "#[Hook('mod1_'", "mod1_event7", ), + ( + "theme_hook_name", + &consumer, + CONSUMER_PHP, + "'#theme' => 'mod1_'", + "mod1_widget4", + ), + ( + "library_name", + &consumer, + CONSUMER_PHP, + "['library' => ['mod1/'", + "mod1/library7", + ), ( "hook_implementation", &module, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 10cd2cabc..688f1c0b7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -69,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Drupal sites in any layout.** Modules and themes grouped in folders of their own, kept directly under `modules/` or `themes/`, or shipped inside an install profile are now indexed even when `.gitignore` leaves them out, so they work like those in `contrib/` and `custom/`. A site that keeps `core/` beside its `composer.json`, or a Drupal tree opened without any `composer.json`, is now recognized as a Drupal site. Contributed by @giuseppe-esposito-87. - **Drupal route names.** In a Drupal project, a route declared by a routing file, a `#[Route]` attribute, or a route builder completes, jumps to its declaration, and lists its references wherever code, a links file, or a template names it, from `Url::fromRoute()`, `$this->redirect()`, and `$form_state->setRedirect()` to `route_name:` and `path()`. A route name nothing declares is reported. The parameter keys those calls, links files, and templates pass jump to the placeholder of the route's path and complete from it, and a controller's parameter is typed as the entity Drupal upcasts it to, with a warning when a controller's type hint unrelated to the entity keeps Drupal from upcasting it. Contributed by @giuseppe-esposito-87. - **Drupal permissions and configuration.** In a Drupal project, a permission name completes from every permissions file, jumps to its declaration, and shows its title and description on hover, wherever an access check, a route's `_permission` requirement, or an entity type's `admin_permission` names it. A configuration name in `\Drupal::config()`, the config factory, a config form, or a `#config_target` completes and jumps to the default configuration and the schema entry that declare it, and the keys a configuration object's `get()`, `set()`, and `clear()` read complete from its schema one level at a time and jump to their schema line. Contributed by @giuseppe-esposito-87. +- **Drupal render arrays, theme hooks, and libraries.** In a Drupal project, theme hook, render element, and library names complete, jump to their declaration, and show it on hover wherever render arrays, templates, and libraries or theme info files write them, and a theme hook also leads to its templates and each theme's override. A theme hook's templates receive its variables, typed by what the render arrays pass. A render element or library nothing declares is reported. Contributed by @giuseppe-esposito-87. - **Symfony route names.** In a Symfony project, a route name declared by a `#[Route]` attribute, the routing YAML or XML, or a PHP routing configurator completes, jumps to its declaration, and lists its references wherever a controller or a Twig template builds a URL to it, from `generateUrl()` and `redirectToRoute()` to `path()` and `url()`. Names are followed through the imports that load them, bundles' own routing files included, along with the ones Symfony derives from a route, such as `contact.en` for a localized one. A route name nothing declares is reported, unless the router is configured to load its routes some other way. Contributed by @sidux. - **Symfony translations.** In a Symfony project, a translation key declared in a YAML or XLIFF catalogue completes, jumps to its entry in every locale, and lists its references wherever PHP or a Twig template translates it, from `trans()`, `TranslatableMessage`, and `t()` to the `trans` filter. A key is looked up in the domain the call names, and a domain jumps to its catalogues. A key spelled like one (`demo.title`) that nothing declares is reported, in a domain the project's own catalogues hold and no installed bundle ships. Contributed by @sidux. - **Symfony Twig templates.** In a Symfony project, a template name completes, jumps to its file, and lists its references wherever a controller, an email, or another template names it, from `render()` and `#[Template]` to `extends` and `include`. A template the application's `templates/` directory does not hold is reported, with a quick fix that creates it, unless the project has a Twig loader of its own that could serve it. Contributed by @sidux. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 906ba0601..db1db7cb5 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, hooks, permissions, and configuration with Drupal's own loaders and reflection. The framework `assertions.php` and `phpantom_lsp analyze` runs need `composer install` in each playground first; the `analyze` runs check the playgrounds for diagnostic regressions. `examples/symfony/`, `examples/yii/`, `examples/wordpress/` and `examples/drupal/` must report `[OK] No errors`. In `examples/laravel/`, `app/Demo.php` carries four deliberate mistakes: `Artisan::call('does:not-exist')` demonstrates `invalid_laravel_command`, `->withoutMiddleware('bakery.closed')` demonstrates `invalid_laravel_middleware`, and one `view('welcome', …)` call both leaves out a variable the template declares and passes a misspelled key, demonstrating `missing_view_variable` and `unused_view_variable`. So the analyze run must report exactly `[ERROR] Found 4 errors` on those three lines, not `[OK] No errors`; any other count, or an error on a different line, is a regression. +Note that clippy runs twice, once for library code and once including test code. The `php -l` checks keep the PHP and framework playgrounds valid. The `php -d zend.assertions=1` run executes `assertions.php`'s `runDemoAssertions()` to verify that `scaffolding/scaffolding.php`'s stubs actually return what their docblocks claim. Each framework playground has an `assertions.php` of its own that checks the demo against the real framework: the Laravel one boots Eloquent on an in-memory database, the Symfony one loads the routes, services, translations, templates, forms, and validation mappings with Symfony's own loaders, the Yii one builds the configuration and DI container the way Yii's web runner does, the WordPress one installs WordPress into a throwaway SQLite database and loads the demo plugin and theme, and the Drupal one reads the demo's services, routes, entity type, hooks, permissions, configuration, theme hooks, render elements, and libraries with Drupal's own loaders and reflection. The framework `assertions.php` and `phpantom_lsp analyze` runs need `composer install` in each playground first; the `analyze` runs check the playgrounds for diagnostic regressions. `examples/symfony/`, `examples/yii/`, `examples/wordpress/` and `examples/drupal/` must report `[OK] No errors`. In `examples/laravel/`, `app/Demo.php` carries four deliberate mistakes: `Artisan::call('does:not-exist')` demonstrates `invalid_laravel_command`, `->withoutMiddleware('bakery.closed')` demonstrates `invalid_laravel_middleware`, and one `view('welcome', …)` call both leaves out a variable the template declares and passes a misspelled key, demonstrating `missing_view_variable` and `unused_view_variable`. So the analyze run must report exactly `[ERROR] Found 4 errors` on those three lines, not `[OK] No errors`; any other count, or an error on a different line, is a regression. ## Code Style diff --git a/docs/todo.md b/docs/todo.md index 9ea78a7d3..ec2bf4ca4 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 | -| DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High | | T67 | [A DQL expression whose type depends on the driver is `mixed`](todo/type-inference.md#t67-a-dql-expression-whose-type-depends-on-the-driver-is-mixed) | Low-Medium | Medium | | T68 | [A DQL field is typed by its property, not its column's type](todo/type-inference.md#t68-a-dql-field-is-typed-by-its-property-not-its-columns-type) | Low | Medium | | T65 | [The expression builder's fragments are not literal strings](todo/type-inference.md#t65-the-expression-builders-fragments-are-not-literal-strings) | Low | Low-Medium | @@ -132,7 +131,6 @@ ships, remove every row that names it. | DR13 | [Module names and `*.info.yml`](todo/drupal.md#dr13-module-names-and-infoyml) | Low-Medium | Low-Medium | | DR10 | [Plugin IDs](todo/drupal.md#dr10-plugin-ids) | Medium | Medium | | DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium | -| DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High | | DR4 | [Entity field access](todo/drupal.md#dr4-entity-field-access) | Medium-High | High | | DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium | | DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium | @@ -142,6 +140,7 @@ ships, remove every row that names it. | DR21 | [Return types core's docblocks leave wide](todo/drupal.md#dr21-return-types-cores-docblocks-leave-wide) | Medium | Low-Medium | | DR22 | [Complete permissions built in code](todo/drupal.md#dr22-complete-permissions-built-in-code) | Low-Medium | Medium | | DR23 | [Warn on unknown permission names](todo/drupal.md#dr23-warn-on-unknown-permission-names) | Medium | Low-Medium | +| DR24 | [Warn on unknown theme hook names](todo/drupal.md#dr24-warn-on-unknown-theme-hook-names) | Low-Medium | Medium-High | ## Yii diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 158c2acc6..060f0e19f 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -8,9 +8,11 @@ the services the `*.services.yml` files declare, the entity types entity classes declare, the routes the routing files, route attributes, 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. +files declare, the configuration objects and keys the default +configuration and the configuration schema declare, the theme hooks +`hook_theme()` implementations declare, the render elements element +classes declare, and the libraries the `*.libraries.yml` files declare +are read. Nothing reads fields yet. The requests in [#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the @@ -28,9 +30,10 @@ is what they expect from an editor. 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, -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. +permission names, configuration names, render element names, hook names, +theme hook names, library 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) within the same impact tier. @@ -66,32 +69,6 @@ names and report a misspelt one. --- -## DR7. Render arrays, theme hooks, and libraries - -**Impact: Medium · Complexity: Medium-High** - -```php -return [ - '#theme' => 'acme_order_summary', - '#order' => $order, - '#attached' => ['library' => ['acme/order-summary']], -]; -``` - -- **`#theme`** names a theme hook a `hook_theme()` implementation - declares, which renders `templates/acme-order-summary.html.twig`. - Navigate from the string to both, complete it, and complete the - `#`-prefixed variables the hook declares. -- **`#type`** names a render or form element class - (`#[RenderElement('details')]`, `#[FormElement('textfield')]`). -- **Libraries** in `#attached` name entries in `.libraries.yml`. - -The Twig support already finds templates outside Symfony, and a -template's variables from the renders that name it; a theme hook's -variables would be one more source of them (see `src/twig/variables.rs`). - ---- - ## DR10. Plugin IDs **Impact: Medium · Complexity: Medium** @@ -107,9 +84,11 @@ Blocks, field types, formatters, widgets, queue workers, and the other plugin types declare an ID in an attribute (or, in older code, a docblock annotation). Complete and navigate plugin IDs where a plugin manager takes one (`createInstance()`, `getDefinition()`, -`hasDefinition()`), and in the YAML and render arrays that name them -(`'#type'` is [DR7](#dr7-render-arrays-theme-hooks-and-libraries)), and -type `createInstance()` as the plugin class. +`hasDefinition()`), and in the YAML that names them, and type +`createInstance()` as the plugin class. Render elements, the plugins a +render array's `'#type'` names, already complete and navigate, and the +reader that finds their attributes and annotations takes the attribute +class as a parameter, so the other plugin types can reuse it. --- @@ -353,3 +332,25 @@ 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. + +--- + +## DR24. Warn on unknown theme hook names + +**Impact: Low-Medium · Complexity: Medium-High** + +```php +return ['#theme' => 'acme_order_sumary', '#order' => $order]; +``` + +A `#theme` that names no theme hook never reaches a template: Drupal +logs a warning and renders only the array's children and `#markup`. Render element and library names are reported when +nothing declares them, but theme hook names are not, because the hooks +read from `hook_theme()` implementations are not all of them. Views, in +core, and some contributed modules build their hooks in a loop or from +other calls, which are not read, and a hook name does not say which +extension declares it, so the only safe rule, silence wherever an +implementation cannot be read, would silence every site. Report a name +only once those hooks are read, or with a rule that knows which names +such an implementation can add. A suggestion such as +`acme_greeting__compact` is valid when its base hook is. diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 9b1297c21..645721d78 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -3,8 +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, a theme hook, permissions, and configuration the way a real Drupal -module declares them. +a render element, 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. @@ -100,11 +100,48 @@ Run `composer install` here first. Composer puts core and contrib into definition of `'notify_email'`, which lands on its line in `acme.schema.yml`. Hover `$settings`, whose type carries the name it was read by. +- Theme hooks navigate between the render arrays that name them, their + `hook_theme()` entry, and their templates, core's included. In + `GreetingController::greet()`, go to the definition of `'acme_greeting'`, + which lists its key in `src/Hook/AcmeHooks.php`, the module's + `templates/acme-greeting.html.twig`, and `acme_theme`'s override, or hover + it to see its template and variables. Complete a `#theme` value, core's + `item_list` included, or a key after `'#'` to see the hook's variables, and + go to the definition of `'#name'`. In `compactGreet()`, + `'acme_greeting__compact'` is a suggestion: it leads to `acme_greeting` and + to `acme_theme`'s `acme-greeting--compact.html.twig`. +- A theme hook's templates receive its variables, typed by the render + arrays that pass them, and the variables Drupal adds to every template. + In `web/modules/custom/acme/templates/acme-greeting.html.twig`, hover + `name` to see the `string` `GreetingController::greet()` passes, go to its + definition in `AcmeHooks::theme()`, or complete after `attributes.` to see + core's `Attribute` methods. +- Library names navigate between the libraries file that declares them and + the render arrays, templates, and YAML that attach them. In + `web/modules/custom/acme/src/Controller/GreetingController.php`, hover + `'acme/greeting'` in the `#attached` of `greet()` to see its libraries file + and version, or go to its definition, which lands on its key in + `acme.libraries.yml`; the `attach_library()` in + `templates/acme-greeting.html.twig`, the `dependencies` of + `web/themes/custom/acme_theme/acme_theme.libraries.yml`, and the + `libraries` of `acme_theme.info.yml` go there too. Complete a library name + in any of them; core's libraries, such as `core/drupal`, are offered too. + A name no libraries file declares is reported, unless its module adds + libraries in code with `hook_library_info_build()` or + `hook_library_info_alter()`. - 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 `LanguageManager`, and the language module's `LanguageServiceProvider` swaps in `ConfigurableLanguageManager`, so it is either class. +- Render element names in `#type` navigate to the element class that + declares them. In `card()` in + `web/modules/custom/acme/src/Controller/GreetingController.php`, go to the + definition of `'acme_greeting_card'`, which lands on + `src/Element/GreetingCard.php`, or hover `'details'` to see core's + `Details` class. Complete a name inside `'#type' => ''` or + `$build['more']['#type'] = ''`; core's elements are offered too. A name + no element class declares is reported. ## What the Symfony support already reads diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 095d9cca3..14ec58a81 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -15,6 +15,7 @@ use Drupal\acme\Controller\GreetingController; use Drupal\acme\Controller\NoteController; +use Drupal\acme\Element\GreetingCard; use Drupal\acme\Entity\Note; use Drupal\acme\Form\SettingsForm; use Drupal\acme\Greeter; @@ -42,6 +43,10 @@ use Drupal\Core\Language\LanguageManager; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Link; +use Drupal\Core\Render\Attribute\FormElement; +use Drupal\Core\Render\Attribute\RenderElement; +use Drupal\Core\Render\Element\Details; +use Drupal\Core\Render\Element\RenderElementBase; use Drupal\Core\Url; use Drupal\language\ConfigurableLanguageManager; use Drupal\language\LanguageServiceProvider; @@ -268,6 +273,13 @@ protected function getDefaultLanguageValues() check('acme_greeting declares the variables the controller passes', array_keys($themeHooks['acme_greeting']['variables'] ?? []) === ['name', 'greeting']); check('acme_greeting has the template its name implies', is_file("$module/templates/acme-greeting.html.twig")); check('acme_theme preprocesses acme_greeting', function_exists('acme_theme_preprocess_acme_greeting')); +check('acme_theme overrides the acme_greeting template', is_file("$theme/templates/acme-greeting.html.twig")); +check('GreetingController::greet() renders acme_greeting with its variables', str_contains((string) file_get_contents("$module/src/Controller/GreetingController.php"), "'#theme' => 'acme_greeting',\n '#name' => \$name,\n '#greeting' =>")); +check('GreetingController::compactGreet() renders the acme_greeting__compact suggestion', str_contains((string) file_get_contents("$module/src/Controller/GreetingController.php"), "'#theme' => 'acme_greeting__compact'") && str_starts_with('acme_greeting__compact', 'acme_greeting__') && !isset($themeHooks['acme_greeting__compact'])); +check('acme_theme draws the suggestion with acme-greeting--compact.html.twig', is_file("$theme/templates/acme-greeting--compact.html.twig")); +check('GreetingController::greet() passes a string as #name', (string) (new ReflectionMethod(GreetingController::class, 'greet'))->getParameters()[0]->getType() === 'string'); +check("core turns a theme hook template's attributes variables into an Attribute, which has addClass()", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Theme/ThemeManager.php"), "foreach (['attributes', 'title_attributes', 'content_attributes'] as \$key) {") && method_exists(\Drupal\Core\Template\Attribute::class, 'addClass')); +check("core's ThemeCommonElements declares item_list, drawn by system's item-list.html.twig", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Theme/ThemeCommonElements.php"), " 'item_list' => [\n 'variables' => [\n 'items' => [],") && is_file("$web/core/modules/system/templates/item-list.html.twig")); // The module documents, invokes, and implements its own hook, and // implements core's form alter hooks. @@ -282,6 +294,73 @@ protected function getDefaultLanguageValues() check("core documents hook_form_alter() with a docblock acme_form_alter()'s hover shows", (bool) preg_match('~/\*\*\n \* Perform alterations before a form is rendered\.\n(?: \*[^\n]*\n)* \*/\nfunction hook_form_alter\(~', (string) file_get_contents("$web/core/lib/Drupal/Core/Form/form.api.php"))); check("core's FormBuilder::prepareForm() fires the form alter hooks", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Form/FormBuilder.php"), '$this->moduleHandler->alter($hooks, $form, $form_state, $form_id);')); +// ─── Render elements ──────────────────────────────────────────────────────── + +$card = new ReflectionClass(GreetingCard::class); +check('GreetingCard declares the acme_greeting_card element by attribute', (attributeArguments($card, RenderElement::class)[0] ?? null) === 'acme_greeting_card'); +check('GreetingCard is a render element', $card->isSubclassOf(RenderElementBase::class)); +check("core's Details declares the details element", (attributeArguments(new ReflectionClass(Details::class), RenderElement::class)[0] ?? null) === 'details'); + +// Every element the controller names is one the module or core declares. +$declaredElements = ['acme_greeting_card']; +foreach (glob("$web/core/lib/Drupal/Core/Render/Element/*.php") ?: [] as $file) { + $class = 'Drupal\\Core\\Render\\Element\\' . basename($file, '.php'); + if (!class_exists($class)) { + continue; + } + $element = new ReflectionClass($class); + foreach ([RenderElement::class, FormElement::class] as $attribute) { + $declaredElements[] = attributeArguments($element, $attribute)[0] ?? null; + } +} +preg_match_all("/'#type'(?:\\] =| =>) '([^']+)'/", $controllerSource, $namedElements); +check('GreetingController names the custom and a core element in a literal and an assignment', $namedElements[1] === ['acme_greeting_card', 'details']); +check('every element GreetingController names is declared', array_diff($namedElements[1], $declaredElements) === []); + +// ─── Libraries ────────────────────────────────────────────────────────────── + +// A library is `/`, the key a top-level one of the +// extension's libraries file (core's AssetResolver splits it on the first +// slash). +$libraryFiles = [ + 'core' => "$web/core/core.libraries.yml", + 'acme' => "$module/acme.libraries.yml", + 'acme_theme' => "$theme/acme_theme.libraries.yml", +]; +$libraries = []; +foreach ($libraryFiles as $extension => $file) { + foreach (Yaml::decode((string) file_get_contents($file)) as $key => $library) { + $libraries["$extension/$key"] = $library; + } +} +check('acme.libraries.yml declares acme/greeting', isset($libraries['acme/greeting'])); +check('acme_theme.libraries.yml declares acme_theme/global', isset($libraries['acme_theme/global'])); +check('core.libraries.yml declares core/drupal', isset($libraries['core/drupal'])); +check("acme/greeting's version is the 1.x its hover shows", ($libraries['acme/greeting']['version'] ?? null) === '1.x'); +check('the CSS files the libraries name exist', is_file("$module/css/acme-greeting.css") && is_file("$theme/css/global.css")); +check("Drupal's AssetResolver splits a library name on its first slash", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Asset/AssetResolver.php"), "[\$extension, \$name] = explode('/', \$library, 2);")); + +// Every library the demo attaches or depends on is declared. +$template = (string) file_get_contents("$module/templates/acme-greeting.html.twig"); +preg_match_all("/attach_library\\('([^']+)'\\)/", $template, $attachedInTwig); +preg_match_all("/'#attached' => \\['library' => \\['([^']+)'\\]\\]/", $controllerSource, $attachedInPhp); +$attached = [ + 'GreetingController::greet()' => $attachedInPhp[1], + 'the acme_greeting template' => $attachedInTwig[1], + 'the dependencies of acme/greeting' => $libraries['acme/greeting']['dependencies'] ?? [], + 'the dependencies of acme_theme/global' => $libraries['acme_theme/global']['dependencies'] ?? [], + "acme_theme's info file" => $themeInfo['libraries'] ?? [], +]; +$preprocessed = ['attributes' => ['class' => []]]; +acme_theme_preprocess_acme_greeting($preprocessed); +$attached['acme_theme_preprocess_acme_greeting()'] = $preprocessed['#attached']['library'] ?? []; +foreach ($attached as $where => $names) { + check("$where attaches a library", $names !== []); + check("every library $where attaches is declared", array_diff($names, array_keys($libraries)) === []); +} +check("core's Twig extension declares attach_library()", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Template/TwigExtension.php"), "new TwigFunction('attach_library'")); +check("core's ThemeManager collects the #attached a preprocess function sets", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Theme/ThemeManager.php"), "foreach (['#attached', '#cache'] as \$key)")); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; diff --git a/examples/drupal/web/modules/custom/acme/acme.libraries.yml b/examples/drupal/web/modules/custom/acme/acme.libraries.yml new file mode 100644 index 000000000..5cbb58aff --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/acme.libraries.yml @@ -0,0 +1,11 @@ +# Try: find the references of greeting to see every place that attaches +# acme/greeting: the controller, the template, and acme_theme's library. +greeting: + version: 1.x + css: + component: + css/acme-greeting.css: {} + # Try: go to the definition of core/drupal, which lands on its key in + # core's core.libraries.yml, or complete a library name in this list. + dependencies: + - core/drupal diff --git a/examples/drupal/web/modules/custom/acme/css/acme-greeting.css b/examples/drupal/web/modules/custom/acme/css/acme-greeting.css new file mode 100644 index 000000000..82948f1a7 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/css/acme-greeting.css @@ -0,0 +1,3 @@ +.acme-greeting { + font-weight: bold; +} diff --git a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php index 2d84da338..c05ff276c 100644 --- a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php +++ b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php @@ -28,10 +28,34 @@ public function greet(string $name): array { // Try: go to the definition of `$this->greeter->greet()`, or complete // after `$this->` to see the helpers ControllerBase inherits from core. + // Go to the definition of 'acme_greeting', which lists its key in + // AcmeHooks::theme(), its template, and acme_theme's override, or of + // '#name', which lands on the variable the hook declares. Complete a + // key after '#' to see the hook's variables. return [ '#theme' => 'acme_greeting', '#name' => $name, '#greeting' => $this->greeter->greet($name), + // Try: hover 'acme/greeting' to see its libraries file and + // version, or complete a library name in the list. + '#attached' => ['library' => ['acme/greeting']], + ]; + } + + /** + * The greeting in the compact form acme_theme draws it in. + * + * @return array + */ + public function compactGreet(string $name): array + { + // Try: hover 'acme_greeting__compact', a suggestion of acme_greeting, + // or go to its definition, which lists acme_greeting's key and + // template and acme_theme's acme-greeting--compact.html.twig. + return [ + '#theme' => 'acme_greeting__compact', + '#name' => $name, + '#greeting' => $this->greeter->greet($name), ]; } @@ -58,4 +82,25 @@ public function worldLink(): Link return Link::createFromRoute($this->t('Greet the world'), 'acme.greeting', $url->getRouteParameters()); } + + /** + * The greeting for `$name` in a card, with more about it folded away. + * + * @return array + */ + public function card(string $name): array + { + // Try: go to the definition of 'acme_greeting_card', which lands on + // the GreetingCard class, or hover 'details' to see core's element + // class. Complete an element name inside '#type' => '', core's + // elements included. + $build = [ + '#type' => 'acme_greeting_card', + 'greeting' => $this->greet($name), + ]; + $build['more']['#type'] = 'details'; + $build['more']['#title'] = $this->t('More'); + + return $build; + } } diff --git a/examples/drupal/web/modules/custom/acme/src/Element/GreetingCard.php b/examples/drupal/web/modules/custom/acme/src/Element/GreetingCard.php new file mode 100644 index 000000000..c4ff22fdd --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/Element/GreetingCard.php @@ -0,0 +1,27 @@ + + */ + public function getInfo(): array + { + return [ + '#theme_wrappers' => ['container'], + '#attributes' => ['class' => ['acme-greeting-card']], + ]; + } +} diff --git a/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig b/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig index 745899960..e547212f1 100644 --- a/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig +++ b/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig @@ -12,5 +12,13 @@ #} {# Try: go to the definition of 'acme.greeting' in path(), or of 'name' in the parameters after it, which lands on {name} in acme.routing.yml. #} +{# Try: go to the definition of 'acme/greeting' in attach_library(), which + lands on its key in acme.libraries.yml, or complete a library name inside + the quotes. #} +{{ attach_library('acme/greeting') }} +{# Try: hover `name`, a `string` GreetingController::greet() passes as + '#name', or go to its definition, which lands on its key in + AcmeHooks::theme(). Complete after `attributes.` to see the methods of + core's template Attribute, which Drupal adds to every template. #} {{ greeting }}

{{ 'Greet the world'|t }} diff --git a/examples/drupal/web/themes/custom/acme_theme/acme_theme.info.yml b/examples/drupal/web/themes/custom/acme_theme/acme_theme.info.yml index 805f3bd3f..72c73f20a 100644 --- a/examples/drupal/web/themes/custom/acme_theme/acme_theme.info.yml +++ b/examples/drupal/web/themes/custom/acme_theme/acme_theme.info.yml @@ -4,3 +4,7 @@ description: 'Theme for the PHPantom Drupal playground.' package: PHPantom core_version_requirement: ^11 base theme: false +# Try: hover acme_theme/global to see the file that declares it and its +# version, or complete a library name in this list. +libraries: + - acme_theme/global diff --git a/examples/drupal/web/themes/custom/acme_theme/acme_theme.libraries.yml b/examples/drupal/web/themes/custom/acme_theme/acme_theme.libraries.yml new file mode 100644 index 000000000..bc01a0755 --- /dev/null +++ b/examples/drupal/web/themes/custom/acme_theme/acme_theme.libraries.yml @@ -0,0 +1,7 @@ +global: + version: 1.x + css: + theme: + css/global.css: {} + dependencies: + - acme/greeting diff --git a/examples/drupal/web/themes/custom/acme_theme/acme_theme.theme b/examples/drupal/web/themes/custom/acme_theme/acme_theme.theme index d8f3729f9..5eefba2f8 100644 --- a/examples/drupal/web/themes/custom/acme_theme/acme_theme.theme +++ b/examples/drupal/web/themes/custom/acme_theme/acme_theme.theme @@ -15,4 +15,7 @@ declare(strict_types=1); function acme_theme_preprocess_acme_greeting(array &$variables): void { $variables['attributes']['class'][] = 'acme-theme-greeting'; + // Try: go to the definition of 'acme_theme/global', which lands on its + // key in acme_theme.libraries.yml. + $variables['#attached']['library'][] = 'acme_theme/global'; } diff --git a/examples/drupal/web/themes/custom/acme_theme/css/global.css b/examples/drupal/web/themes/custom/acme_theme/css/global.css new file mode 100644 index 000000000..db39c11fb --- /dev/null +++ b/examples/drupal/web/themes/custom/acme_theme/css/global.css @@ -0,0 +1,3 @@ +.acme-theme-greeting { + color: #333; +} diff --git a/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting--compact.html.twig b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting--compact.html.twig new file mode 100644 index 000000000..4b5d717f7 --- /dev/null +++ b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting--compact.html.twig @@ -0,0 +1,11 @@ +{# +/** + * @file + * Acme Theme's template for the acme_greeting__compact suggestion. + * + * Available variables: + * - greeting: The full greeting text. + * - attributes: HTML attributes for the wrapper. + */ +#} +{{ greeting }} diff --git a/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting.html.twig b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting.html.twig new file mode 100644 index 000000000..37629706d --- /dev/null +++ b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting.html.twig @@ -0,0 +1,12 @@ +{# +/** + * @file + * Acme Theme's override of the acme_greeting template. + * + * Available variables: + * - name: The name being greeted. + * - greeting: The full greeting text. + * - attributes: HTML attributes for the wrapper. + */ +#} +{{ greeting }}

diff --git a/src/classmap_scanner/discovery.rs b/src/classmap_scanner/discovery.rs index d838cc55d..3c61999f0 100644 --- a/src/classmap_scanner/discovery.rs +++ b/src/classmap_scanner/discovery.rs @@ -694,6 +694,8 @@ pub fn scan_drupal_directories( 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()); + } else if crate::drupal::is_template_file(path, web_root) { + declaration_files.templates.push(path.to_path_buf()); } } } @@ -720,6 +722,9 @@ pub struct DrupalDeclarationFiles { pub default_config: Vec, /// The configuration schema files. pub config_schemas: Vec, + /// The Twig templates theme hooks are drawn with (see + /// `drupal::is_template_file`), recorded without being read. + pub templates: Vec, } /// The extensions besides `.php` that Drupal treats as PHP source. diff --git a/src/classmap_scanner/discovery_tests.rs b/src/classmap_scanner/discovery_tests.rs index 8e4d8c154..ecb5d7d5c 100644 --- a/src/classmap_scanner/discovery_tests.rs +++ b/src/classmap_scanner/discovery_tests.rs @@ -823,3 +823,31 @@ fn scan_drupal_directories_collects_declaration_files_outside_tests() { ] ); } + +#[test] +fn scan_drupal_directories_records_templates_outside_tests() { + let dir = tempfile::tempdir().unwrap(); + let web_root = dir.path(); + for path in [ + "core/modules/system/templates/item-list.html.twig", + "themes/contrib/olivo/templates/content/node.html.twig", + "core/modules/system/tests/themes/fixture/templates/item-list.html.twig", + "modules/custom/acme/templates/acme.txt.twig", + ] { + let full = web_root.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, "{{ x }}\n").unwrap(); + } + + let (_, files) = scan_drupal_directories(web_root, &IndexFilters::empty(), None); + let mut templates = files.templates; + templates.sort(); + assert_eq!( + templates, + vec![ + web_root.join("core/modules/system/templates/item-list.html.twig"), + web_root.join("themes/contrib/olivo/templates/content/node.html.twig"), + ] + ); + assert!(files.declarations.is_empty()); +} diff --git a/src/completion/drupal_libraries.rs b/src/completion/drupal_libraries.rs new file mode 100644 index 000000000..04445d8b7 --- /dev/null +++ b/src/completion/drupal_libraries.rs @@ -0,0 +1,150 @@ +//! Completion of the Drupal libraries a render array attaches. +//! +//! The strings of the `library` list under a render array's `#attached` +//! are library names, which the libraries files declare (see +//! [`crate::drupal::libraries`]). The buffer is mid-edit and usually does +//! not parse, so the list is recognised from the text before the cursor, +//! in the shapes the symbol map reads: `'#attached' => ['library' => ['|`, +//! `$build['#attached']['library'][] = '|`, and assignments of a whole +//! `library` list or `#attached` array. + +use tower_lsp_server::gen_lsp_types::{CompletionResponse, Position}; + +use crate::Backend; +use crate::completion::source::code_context::CodeContext; +use crate::drupal::elements::{arrow_key, assignment_target}; +use crate::symbol_map::StringKeyKind; +use crate::text_position::position_to_offset; + +const ATTACHED: &str = "#attached"; +const LIBRARY: &str = "library"; + +impl Backend { + /// Complete a library name in the `library` list of a render array's + /// `#attached`. + pub(crate) fn try_drupal_library_completion( + &self, + uri: &str, + content: &str, + position: Position, + code: &CodeContext<'_>, + ) -> Option { + let start = attached_library_start(content, code)?; + let cursor = position_to_offset(content, position) as usize; + self.symfony_key_response( + uri, + StringKeyKind::DrupalLibrary, + content, + start, + cursor, + position, + ) + } +} + +/// Where the library name the cursor is typing starts, when the string it +/// is in is an item of the `library` list of an `#attached`. +fn attached_library_start(content: &str, code: &CodeContext<'_>) -> Option { + let (quote, _) = code.open_string?; + let reads_library = match code.last_code_byte()? { + // `$build['#attached']['library'][] = '|` + b'=' => assignment_target(code.code_before) + .and_then(|target| target.trim_end().strip_suffix("[]")) + .is_some_and(|target| ends_with_keys(target, &[ATTACHED, LIBRARY])), + b'[' | b',' => { + let (list, enclosing) = match &code.open_brackets[..] { + [.., enclosing, list] => (list, Some(enclosing)), + [list] => (list, None), + [] => return None, + }; + let before_list = &content[..list.code_before]; + if list.byte != b'[' { + false + } else if arrow_key(before_list) == Some(LIBRARY) { + // `'#attached' => ['library' => ['|` or + // `$build['#attached'] = ['library' => ['|` + enclosing.is_some_and(|attached| { + let before = &content[..attached.code_before]; + attached.byte == b'[' + && (arrow_key(before) == Some(ATTACHED) + || assignment_target(before) + .is_some_and(|target| ends_with_keys(target, &[ATTACHED]))) + }) + } else { + // `$build['#attached']['library'] = ['|` + assignment_target(before_list) + .is_some_and(|target| ends_with_keys(target, &[ATTACHED, LIBRARY])) + } + } + _ => false, + }; + reads_library.then_some(quote + 1) +} + +/// Whether `target` ends with the offsets `['keys…']`, in order. +fn ends_with_keys(mut target: &str, keys: &[&str]) -> bool { + for key in keys.iter().rev() { + let Some(rest) = target + .trim_end() + .strip_suffix(']') + .and_then(|rest| strip_quoted(rest.trim_end(), key)) + .and_then(|rest| rest.trim_end().strip_suffix('[')) + else { + return false; + }; + target = rest; + } + true +} + +/// `text` without the string literal `key` it ends with. +fn strip_quoted<'a>(text: &'a str, key: &str) -> Option<&'a str> { + ['\'', '"'].into_iter().find_map(|quote| { + text.strip_suffix(quote)? + .strip_suffix(key)? + .strip_suffix(quote) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::completion::source::code_context::code_context_at; + + /// Where the name starts, for a cursor at the end of `php`. + fn start(php: &str) -> Option { + let code = code_context_at(php, php.len())?; + attached_library_start(php, &code) + } + + #[test] + fn the_library_list_of_attached_reads_library_names() { + for php in [ + " ['library' => ['co", + " [\n 'library' => [\n 'acme/one',\n \"co", + " ['co", + ] { + assert_eq!(start(php), Some(php.len() - 2), "{php}"); + } + } + + #[test] + fn other_strings_read_nothing() { + for php in [ + " ['co", + " ['drupalSettings' => ['co", + " ['library' => 'co", + " ['co", + " ['library' => [], 'co", + ] { + assert_eq!(start(php), None, "{php}"); + } + } +} diff --git a/src/completion/drupal_theme_hooks.rs b/src/completion/drupal_theme_hooks.rs new file mode 100644 index 000000000..642dc3b43 --- /dev/null +++ b/src/completion/drupal_theme_hooks.rs @@ -0,0 +1,227 @@ +//! Completion for the theme hooks a Drupal render array names. +//! +//! `'#theme' => '|'`, `$build['#theme'] = '|'`, and the names of a `#theme` +//! or `#theme_wrappers` list are completed from the theme hooks the site +//! declares (see [`crate::drupal::theme_hooks`]). A key of an array whose +//! own `#theme` names a hook is completed from the hook's variables, as +//! `#name`. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::completion::source::code_context::CodeContext; +use crate::symbol_map::StringKeyKind; +use crate::text_position::{offset_to_position, position_to_offset}; + +/// The render array keys whose value names theme hooks. +const HOOK_KEYS: [&str; 2] = ["#theme", "#theme_wrappers"]; + +impl Backend { + /// Complete a theme hook name, or a variable of the hook a render + /// array's `#theme` names. `None` when the cursor is on neither. + pub(crate) fn try_drupal_theme_hook_completion( + &self, + content: &str, + position: Position, + code: &CodeContext<'_>, + ) -> Option { + let (quote, _) = code.open_string?; + let cursor = position_to_offset(content, position) as usize; + let prefix = content.get(quote + 1..cursor)?; + let in_list = matches!(code.last_code_byte(), Some(b'[' | b',')) + && code + .open_brackets + .last() + .is_some_and(|bracket| bracket.byte == b'['); + let names_hook = names_theme_hook(code.code_before) + || (in_list + && code + .open_brackets + .last() + .is_some_and(|bracket| names_theme_hook(&content[..bracket.code_before]))); + if names_hook { + return self.symfony_names_response( + StringKeyKind::DrupalThemeHook, + self.drupal_theme_hook_names(), + content, + quote + 1, + cursor, + position, + ); + } + if !in_list { + return None; + } + let open = code.open_brackets.last()?.offset; + let hook = array_theme_hook(content, open)?; + let edit_range = Range { + start: offset_to_position(content, quote + 1), + end: position, + }; + let items: Vec = self + .drupal_theme_hook_variable_names(&hook) + .into_iter() + .filter(|name| starts_with_ignoring_case(name, prefix)) + .enumerate() + .map(|(at, name)| CompletionItem { + label: name.clone(), + kind: Some(CompletionItemKind::Field), + detail: Some(format!("Theme hook `{hook}`")), + sort_text: Some(format!("{at:05}")), + filter_text: Some(name.clone()), + text_edit: Some(CompletionItemTextEdit::TextEdit(TextEdit { + range: edit_range, + new_text: name, + })), + ..Default::default() + }) + .collect(); + (!items.is_empty()).then_some(CompletionResponse::CompletionItemList(items)) + } +} + +fn starts_with_ignoring_case(name: &str, prefix: &str) -> bool { + name.as_bytes() + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix.as_bytes())) +} + +/// Whether the code `before` a value ends with `'#theme' =>`, or +/// `['#theme'] =`, or the same with `#theme_wrappers`. +fn names_theme_hook(before: &str) -> bool { + crate::drupal::elements::value_key(before).is_some_and(|key| HOOK_KEYS.contains(&key)) +} + +/// The hook the array literal opened at `open` names in a `'#theme' => +/// 'name'` entry of its own, before or after the cursor. +fn array_theme_hook(content: &str, open: usize) -> Option { + enum Token<'a> { + Literal(&'a str), + Arrow, + Other, + } + let bytes = content.as_bytes(); + let line_end = + |from: usize| memchr::memchr(b'\n', &bytes[from..]).map_or(bytes.len(), |end| from + end); + let mut depth = 0usize; + let mut tokens: Vec> = Vec::new(); + let mut at = open; + while at < bytes.len() { + match bytes[at] { + quote @ (b'\'' | b'"') => { + let mut end = at + 1; + while end < bytes.len() && bytes[end] != quote { + end += if bytes[end] == b'\\' { 2 } else { 1 }; + } + if depth == 1 { + tokens.push(Token::Literal(content.get(at + 1..end.min(bytes.len()))?)); + } + at = end + 1; + continue; + } + // A comment, which may hold a quote of its own. + b'/' if bytes.get(at + 1) == Some(&b'*') => { + at = memchr::memmem::find(&bytes[at + 2..], b"*/") + .map_or(bytes.len(), |end| at + 2 + end + 2); + continue; + } + b'/' if bytes.get(at + 1) == Some(&b'/') => { + at = line_end(at); + continue; + } + b'#' if bytes.get(at + 1) != Some(&b'[') => { + at = line_end(at); + continue; + } + b'[' | b'(' | b'{' => { + depth += 1; + if depth > 1 { + tokens.push(Token::Other); + } + } + b']' | b')' | b'}' => { + depth -= 1; + if depth == 0 { + break; + } + } + b'=' if depth == 1 && bytes.get(at + 1) == Some(&b'>') => { + tokens.push(Token::Arrow); + at += 2; + continue; + } + b';' if depth == 1 => break, + byte if depth == 1 && !byte.is_ascii_whitespace() => tokens.push(Token::Other), + _ => {} + } + at += 1; + } + tokens + .windows(4) + .find_map(|window| match window { + [ + Token::Literal("#theme"), + Token::Arrow, + Token::Literal(name), + next, + ] if !matches!(next, Token::Arrow) => Some(name.to_string()), + _ => None, + }) + .or_else(|| match tokens.as_slice() { + [ + .., + Token::Literal("#theme"), + Token::Arrow, + Token::Literal(name), + ] => Some(name.to_string()), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_value_after_a_theme_key_names_a_hook() { + assert!(names_theme_hook("['#theme' => ")); + assert!(names_theme_hook("[\"#theme_wrappers\"=>")); + assert!(names_theme_hook("$build['box']['#theme'] = ")); + assert!(!names_theme_hook("$build['#theme'] == ")); + assert!(!names_theme_hook("['#type' => ")); + } + + #[test] + fn the_arrays_own_theme_entry_names_its_hook() { + let php = "$x = ['#', '#theme' => 'acme_greeting', 'list' => ['#theme' => 'item_list']];"; + let open = php.find('[').unwrap(); + assert_eq!( + array_theme_hook(php, open).as_deref(), + Some("acme_greeting") + ); + let php = "$x = ['#name' => 1, '#theme' => 'acme_greeting'];"; + assert_eq!( + array_theme_hook(php, php.find('[').unwrap()).as_deref(), + Some("acme_greeting") + ); + let php = "$x = ['#theme' => ['a', 'b'], '#'];"; + assert_eq!(array_theme_hook(php, php.find('[').unwrap()), None); + } + + #[test] + fn a_quote_in_a_comment_does_not_hide_the_theme_entry() { + for php in [ + "$x = [\n // Don't cache.\n '#theme' => 'acme_greeting',\n '#na',\n];", + "$x = [\n # Don't cache.\n '#theme' => 'acme_greeting',\n '#na',\n];", + "$x = [\n /* Don't \"cache\". */\n '#theme' => 'acme_greeting',\n '#na',\n];", + "$x = [\n '#theme' => 'acme_greeting', // Don't cache.\n '#na',\n];", + "$x = [\n '#theme' => 'acme_greeting',\n #[Attribute] 'x' => fn() => 1,\n '#na',\n];", + ] { + assert_eq!( + array_theme_hook(php, php.find('[').unwrap()).as_deref(), + Some("acme_greeting"), + "{php}" + ); + } + } +} diff --git a/src/completion/handler/string_arguments.rs b/src/completion/handler/string_arguments.rs index 6dd0c32a6..51383ea65 100644 --- a/src/completion/handler/string_arguments.rs +++ b/src/completion/handler/string_arguments.rs @@ -138,6 +138,41 @@ impl Backend { return Some(response); } + // ── Drupal render elements ────────────────────────────── + // `'#type' => '|'` and `$form['name']['#type'] = '|'`. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = + self.try_drupal_render_element_completion(uri, content, position, code) + { + return Some(response); + } + + // ── Drupal theme hooks ────────────────────────────────── + // `'#theme' => '|'`, a `#theme` or `#theme_wrappers` list, and the + // `#` keys of a render array whose `#theme` names a hook. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = self.try_drupal_theme_hook_completion(content, position, code) + { + return Some(response); + } + + // ── Drupal libraries ──────────────────────────────────── + // `'#attached' => ['library' => ['|']]` and + // `$build['#attached']['library'][] = '|'`. Asked before the + // Symfony names, which would read an item after a comma as an + // argument of a call around the array. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = self.try_drupal_library_completion(uri, content, position, code) + { + 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 b52452662..ba7dc934f 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -44,7 +44,9 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { StringKeyKind::GateAbility => CompletionItemKind::Method, StringKeyKind::Env => CompletionItemKind::Constant, StringKeyKind::ServiceId => CompletionItemKind::Reference, - StringKeyKind::DrupalEntityType => CompletionItemKind::Class, + StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRenderElement => { + CompletionItemKind::Class + } StringKeyKind::ContainerParameter => CompletionItemKind::Property, StringKeyKind::SymfonyConfigKey => CompletionItemKind::Field, StringKeyKind::SymfonyEvent | StringKeyKind::WordPressHook | StringKeyKind::DrupalHook => { @@ -52,15 +54,17 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { } StringKeyKind::MessengerBus => CompletionItemKind::Reference, StringKeyKind::SerializerGroup => CompletionItemKind::EnumMember, - StringKeyKind::YiiRouteArgument | StringKeyKind::DrupalRouteParameter => { - CompletionItemKind::Field - } + StringKeyKind::YiiRouteArgument + | StringKeyKind::DrupalRouteParameter + | StringKeyKind::DrupalThemeHookVariable => CompletionItemKind::Field, StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => CompletionItemKind::Function, StringKeyKind::TwigVariable => CompletionItemKind::Variable, StringKeyKind::Route | StringKeyKind::SymfonyRoute | StringKeyKind::YiiRoute | StringKeyKind::DrupalRoute + | StringKeyKind::DrupalThemeHook + | StringKeyKind::DrupalLibrary | StringKeyKind::Command | StringKeyKind::Section | StringKeyKind::Stack @@ -196,18 +200,22 @@ impl Backend { | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRoute | StringKeyKind::DrupalPermission - | StringKeyKind::DrupalConfigName => self.symfony_declared_names(*kind).into(), + | StringKeyKind::DrupalConfigName + | StringKeyKind::DrupalRenderElement + | StringKeyKind::DrupalLibrary => self.symfony_declared_names(*kind).into(), StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => { self.twig_callable_names(*kind).into() } // Only the documentation, not the invocations the type engine // would have to confirm in every file that fires a hook. StringKeyKind::DrupalHook => self.drupal_documented_hooks().into(), + StringKeyKind::DrupalThemeHook => self.drupal_theme_hook_names().into(), // A translation key is completed in the domain it is looked up // in, a configuration key under the key it is nested in, which // only the Symfony completion knows, and a route argument from // the routes of the handler it is written in, and a Drupal route - // parameter from the route its call names. A hook name is + // parameter from the route its call names, and a theme hook's + // variable from the `#theme` of its render array. A hook name is // completed with its patterns as snippets, which only the // WordPress completion builds. StringKeyKind::SymfonyTranslation @@ -216,6 +224,7 @@ impl Backend { | StringKeyKind::YiiRouteArgument | StringKeyKind::DrupalRouteParameter | StringKeyKind::DrupalConfigKey + | StringKeyKind::DrupalThemeHookVariable | StringKeyKind::Section | StringKeyKind::Stack | StringKeyKind::TwigBlock diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 1e8287ec9..2658772ae 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -81,8 +81,10 @@ pub(crate) mod command_params; mod doctrine_fields; mod drupal_config; pub(crate) mod drupal_hooks; +mod drupal_libraries; mod drupal_permissions; pub(crate) mod drupal_route_params; +mod drupal_theme_hooks; 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 ce508aad1..c8794b96c 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -518,7 +518,7 @@ impl Backend { /// The completion response offering those of `names` that start with /// what is typed between `prefix_start` and `cursor`. - fn symfony_names_response( + pub(crate) fn symfony_names_response( &self, kind: StringKeyKind, names: Vec, @@ -564,6 +564,18 @@ impl Backend { if !self.is_drupal_project() { return None; } + if let Some(start) = + crate::drupal::libraries::twig_library_name_start(content, cursor) + { + return self.symfony_key_response( + uri, + StringKeyKind::DrupalLibrary, + content, + start, + cursor, + position, + ); + } let start = crate::symfony::routes::drupal_twig_route_name_start(content, cursor)?; return self.symfony_key_response( uri, @@ -604,6 +616,12 @@ impl Backend { } if let Some(start) = crate::twig::templates::twig_template_name_start(content, cursor) { (StringKeyKind::TwigTemplate, start) + } else if let Some(start) = self + .is_drupal_project() + .then(|| crate::drupal::libraries::twig_library_name_start(content, cursor)) + .flatten() + { + (StringKeyKind::DrupalLibrary, start) } else if let Some(start) = self .is_in_drupal_web_root(uri) .then(|| crate::symfony::routes::drupal_twig_route_name_start(content, cursor)) @@ -672,6 +690,14 @@ impl Backend { crate::drupal::permissions::routing_permission_start(content, cursor)?, ), crate::drupal::DeclarationFile::Permissions => return None, + crate::drupal::DeclarationFile::Libraries => ( + StringKeyKind::DrupalLibrary, + crate::drupal::libraries::libraries_file_name_start(content, cursor)?, + ), + crate::drupal::DeclarationFile::Info => ( + StringKeyKind::DrupalLibrary, + crate::drupal::libraries::info_file_name_start(content, cursor)?, + ), } } else if !is_symfony { return None; diff --git a/src/diagnostics/collect.rs b/src/diagnostics/collect.rs index bc895eb6d..eea2b0194 100644 --- a/src/diagnostics/collect.rs +++ b/src/diagnostics/collect.rs @@ -212,9 +212,10 @@ pub(crate) type SlowDiagnosticObserver<'a> = impl Backend { /// Returns `true` if the URI should be skipped for diagnostics: stub /// files (embedded or under a stub path), and resource documents (YAML, - /// XML, and XLIFF files), which are not PHP. A Twig template and a - /// Symfony validation mapping are resource documents too, but what they - /// hold is judged (see [`Self::collect_resource_diagnostics`]). Vendor + /// XML, and XLIFF files), which are not PHP. A Twig template, a Symfony + /// validation mapping, and a Drupal file that reads library names are + /// resource documents too, but what they hold is judged (see + /// [`Self::collect_resource_diagnostics`]). Vendor /// files are /// not skipped here: users /// working in monorepos or with `--prefer-source` packages @@ -229,11 +230,22 @@ impl Backend { } /// Whether the resource document at `uri` is one whose contents are - /// judged: a Twig template, or a Symfony validation mapping. + /// judged: a Twig template, a Symfony validation mapping, or a Drupal + /// libraries or info file. pub(crate) fn has_resource_diagnostics(&self, uri: &str) -> bool { self.is_twig_template_uri(uri) || (crate::symfony::validation::is_validation_mapping_uri(uri) && self.is_symfony_project()) + || self.reads_drupal_libraries(uri) + } + + /// Whether `uri` is a Drupal libraries file or info file, whose library + /// names are judged. + fn reads_drupal_libraries(&self, uri: &str) -> bool { + matches!( + self.drupal_declaration_file(uri), + Some(crate::drupal::DeclarationFile::Libraries | crate::drupal::DeclarationFile::Info) + ) } /// Collect the diagnostics of a resource document that has them (see @@ -246,13 +258,18 @@ impl Backend { ) { if self.is_twig_template_uri(uri_str) { self.collect_template_diagnostics(uri_str, content, out); + } else if self.reads_drupal_libraries(uri_str) { + let ctx = helpers::FileDiagnosticContext::gather(self, uri_str); + if ctx.symbol_map.matches_source(content) { + self.collect_invalid_laravel_string_key_diagnostics(&ctx, uri_str, content, out); + } } else { self.collect_constraint_option_diagnostics(uri_str, content, out); } } /// Collect the diagnostics of a Twig template: the template, route, - /// and translation names it holds that nothing declares. A template + /// translation, and library names it holds that nothing declares. A template /// is not PHP, so this is all that is judged there. pub fn collect_template_diagnostics( &self, diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index 64ea605e9..7eb433f83 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -48,6 +48,8 @@ enum CheckedStringKind { YiiRoute, YiiRouteArgument, DrupalRoute, + DrupalRenderElement, + DrupalLibrary, } impl CheckedStringKind { @@ -78,6 +80,8 @@ impl CheckedStringKind { StringKeyKind::YiiRoute => Self::YiiRoute, StringKeyKind::YiiRouteArgument => Self::YiiRouteArgument, StringKeyKind::DrupalRoute => Self::DrupalRoute, + StringKeyKind::DrupalRenderElement => Self::DrupalRenderElement, + StringKeyKind::DrupalLibrary => Self::DrupalLibrary, // A section or stack name is judged against the templates that // render the one it is written in, which the Blade pass has and // this one does not. And anything at all can be bound at @@ -100,7 +104,10 @@ impl CheckedStringKind { // 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. + // too, by the configuration a site exports and by code. Views + // and other modules declare theme hooks in loops, which are not + // read, and a render array may pass anything besides a hook's + // variables. StringKeyKind::WordPressHook | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRouteParameter @@ -108,6 +115,8 @@ impl CheckedStringKind { | StringKeyKind::DrupalPermission | StringKeyKind::DrupalConfigName | StringKeyKind::DrupalConfigKey + | StringKeyKind::DrupalThemeHook + | StringKeyKind::DrupalThemeHookVariable | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack @@ -188,6 +197,13 @@ struct DeclaredKeys<'a> { /// The `Class::method` and `Class` names Drupal gives attribute routes, /// sorted. drupal_route_aliases: Vec, + /// The Drupal render element names the project declares, sorted. + drupal_render_elements: Vec, + /// The Drupal library names the project's libraries files declare, + /// sorted. + drupal_libraries: Vec, + /// The extensions that build libraries in code, sorted. + drupal_library_builders: Vec, } impl<'a> DeclaredKeys<'a> { @@ -223,6 +239,9 @@ impl<'a> DeclaredKeys<'a> { let has_messenger_bus = names(|kind| kind == CheckedStringKind::MessengerBus); let has_yii_route = names(|kind| kind == CheckedStringKind::YiiRoute); let has_drupal_route = names(|kind| kind == CheckedStringKind::DrupalRoute); + let has_drupal_render_element = + names(|kind| kind == CheckedStringKind::DrupalRenderElement); + let has_drupal_library = names(|kind| kind == CheckedStringKind::DrupalLibrary); // A package with no routes of its own, whose names are registered // by the host application, cannot be judged: the valid set is @@ -395,6 +414,28 @@ impl<'a> DeclaredKeys<'a> { (Vec::new(), Vec::new(), Vec::new()) }; + // Core's and contributed modules' elements are only known once + // the workspace index has read the folders `.gitignore` leaves out. + let drupal_render_elements = if has_drupal_render_element { + backend.ensure_workspace_index_ready_for_request(); + backend.symfony_declared_names(crate::symbol_map::StringKeyKind::DrupalRenderElement) + } else { + Vec::new() + }; + + // Core's and contributed modules' libraries files are only read + // once the workspace index has walked the folders `.gitignore` + // leaves out. + let (drupal_libraries, drupal_library_builders) = if has_drupal_library { + backend.ensure_workspace_index_ready_for_request(); + ( + backend.symfony_declared_names(crate::symbol_map::StringKeyKind::DrupalLibrary), + backend.drupal_library_builders(), + ) + } else { + (Vec::new(), Vec::new()) + }; + Self { backend, routes, @@ -421,6 +462,9 @@ impl<'a> DeclaredKeys<'a> { drupal_routes, drupal_route_patterns, drupal_route_aliases, + drupal_render_elements, + drupal_libraries, + drupal_library_builders, } } @@ -504,6 +548,13 @@ impl<'a> DeclaredKeys<'a> { CheckedStringKind::YiiRoute => self.judge_yii_route(key), CheckedStringKind::YiiRouteArgument => self.judge_yii_route_argument(key), CheckedStringKind::DrupalRoute => self.judge_drupal_route(key), + CheckedStringKind::DrupalRenderElement => self.judge_drupal_render_element(key), + CheckedStringKind::DrupalLibrary => crate::drupal::libraries::judge_library( + &self.drupal_libraries, + &self.drupal_library_builders, + key, + ) + .map(|valid| (valid, "library", "invalid_drupal_library")), } } @@ -576,6 +627,20 @@ impl<'a> DeclaredKeys<'a> { Some((false, VERDICT.0, VERDICT.1)) } + /// Every render element is a class with an element attribute or + /// annotation, so the names they declare are all of them. A project + /// that declares none was not read at all. + fn judge_drupal_render_element(&self, key: &str) -> Verdict { + if self.drupal_render_elements.is_empty() { + return None; + } + let valid = self + .drupal_render_elements + .binary_search_by(|name| name.as_str().cmp(key)) + .is_ok(); + Some((valid, "render element", "invalid_drupal_render_element")) + } + /// No package Yii installs declares routes of its own, so the /// project's routes are all of them. A project that declares none was /// not read at all. @@ -906,9 +971,16 @@ impl Backend { { return None; } - // Test code passes invented route names to mocks, and an - // `*.api.php` file's examples name invented routes too. - if *kind == crate::symbol_map::StringKeyKind::DrupalRoute && is_drupal_test_code { + // Test code passes invented route names to mocks and uses + // its test modules' elements and libraries, and an + // `*.api.php` file's examples name invented ones too. + if matches!( + kind, + crate::symbol_map::StringKeyKind::DrupalRoute + | crate::symbol_map::StringKeyKind::DrupalRenderElement + | crate::symbol_map::StringKeyKind::DrupalLibrary + ) && is_drupal_test_code + { return None; } Some(( diff --git a/src/drupal/elements.rs b/src/drupal/elements.rs new file mode 100644 index 000000000..82c80da3e --- /dev/null +++ b/src/drupal/elements.rs @@ -0,0 +1,167 @@ +//! Drupal's render elements: the names a `#[RenderElement]` or +//! `#[FormElement]` class (or the annotation older code uses) declares, +//! read by the `#type` of a render array. + +use tower_lsp_server::gen_lsp_types::{CompletionResponse, Location, Position}; + +use crate::Backend; +use crate::atom::Atom; +use crate::completion::source::code_context::CodeContext; +use crate::symbol_map::{StringKeyKind, SymbolKind}; + +/// The `#`-prefixed key of a render array whose value is the string that +/// starts after `code_before`, the code before its opening quote: +/// `'#type' => '…'` in an array literal, or `$a['#type'] = '…'`. +pub(crate) fn render_array_value_key(code_before: &str) -> Option<&str> { + value_key(code_before).filter(|key| key.starts_with('#')) +} + +/// The literal key whose value starts after `code_before`: the key of a +/// `'key' =>` entry, or the last offset of a `$a['key'] =` assignment. +pub(crate) fn value_key(code_before: &str) -> Option<&str> { + arrow_key(code_before).or_else(|| { + let target = assignment_target(code_before)?; + trailing_literal(target.trim_end().strip_suffix(']')?) + }) +} + +/// The literal key of the `'key' =>` entry `before` ends with. +pub(crate) fn arrow_key(before: &str) -> Option<&str> { + trailing_literal(before.trim_end().strip_suffix("=>")?) +} + +/// The code `before`, which ends with an assignment's `=`, assigns to: the +/// code before a plain `=`, not a comparison or a compound assignment. +pub(crate) fn assignment_target(before: &str) -> Option<&str> { + let target = before.trim_end().strip_suffix('=')?; + (!target.ends_with([ + '=', '!', '<', '>', '.', '+', '-', '*', '/', '%', '&', '|', '^', '?', ':', + ])) + .then_some(target) +} + +/// The contents of the string literal `text` ends with. +pub(crate) fn trailing_literal(text: &str) -> Option<&str> { + let text = text.trim_end(); + let quote = text + .chars() + .next_back() + .filter(|c| matches!(c, '\'' | '"'))?; + let inner = &text[..text.len() - 1]; + Some(&inner[inner.rfind(quote)? + 1..]) +} + +impl Backend { + /// The classes that declare the plugin `id` as a key of `kind`, in a + /// stable order. + pub(crate) fn drupal_plugin_classes(&self, kind: StringKeyKind, id: &str) -> Vec { + let mut found: Vec = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + for site in &map.drupal_plugin_classes { + let at = map.spans.partition_point(|span| span.start < site.start); + let declares_id = map.spans[at..] + .iter() + .take_while(|span| span.start == site.start) + .any(|span| { + matches!( + &span.kind, + SymbolKind::StringKey { kind: declared, key, .. } + if *declared == kind && key == id + ) + }); + if declares_id && !found.contains(&site.class) { + found.push(site.class); + } + } + }); + found + } + + /// Where the render element `name` is declared: the name of each class + /// that declares it, else the ID its declaration writes. + pub(crate) fn drupal_render_element_definitions(&self, name: &str) -> Vec { + let kind = StringKeyKind::DrupalRenderElement; + let classes: Vec = self + .drupal_plugin_classes(kind, name) + .iter() + .filter_map(|class| self.class_declaration_location(class)) + .collect(); + if classes.is_empty() { + self.symfony_declaration_locations(kind, name) + } else { + classes + } + } + + /// The hover detail of the render element `name`: the classes that + /// declare it and the file of the first. + pub(crate) fn drupal_render_element_detail(&self, name: &str) -> String { + let kind = StringKeyKind::DrupalRenderElement; + let classes = self.drupal_plugin_classes(kind, name); + if classes.is_empty() { + return "Not declared by any element class in the project".to_string(); + } + let mut detail = classes + .iter() + .map(|class| format!("Class `{class}`")) + .collect::>() + .join("\n\n"); + if let Some(path) = self + .symfony_declaration_locations(kind, name) + .first() + .and_then(|location| self.workspace_relative_path(location.uri.as_str())) + { + detail.push_str(&format!("\n\nDeclared in `{path}`")); + } + detail + } + + /// Complete a render element name in the `#type` of a render array. + pub(crate) fn try_drupal_render_element_completion( + &self, + uri: &str, + content: &str, + position: Position, + code: &CodeContext<'_>, + ) -> Option { + let (quote, _) = code.open_string?; + if render_array_value_key(code.code_before)? != "#type" { + return None; + } + let cursor = crate::text_position::position_to_offset(content, position) as usize; + self.symfony_key_response( + uri, + StringKeyKind::DrupalRenderElement, + content, + quote + 1, + cursor, + position, + ) + } +} + +#[cfg(test)] +mod tests { + use super::render_array_value_key; + + #[test] + fn the_key_before_a_value_is_read_from_entries_and_assignments() { + assert_eq!(render_array_value_key("return ['#type' =>"), Some("#type")); + assert_eq!(render_array_value_key("[\"#type\"=>"), Some("#type")); + assert_eq!( + render_array_value_key("$form['name']['#type'] ="), + Some("#type") + ); + assert_eq!( + render_array_value_key("$form['name'][ \"#type\" ]="), + Some("#type") + ); + assert_eq!(render_array_value_key("['type' =>"), None); + assert_eq!(render_array_value_key("$form['#type'] =="), None); + assert_eq!(render_array_value_key("$form['#type'] .="), None); + assert_eq!(render_array_value_key("$form['#type'] !="), None); + assert_eq!(render_array_value_key("$form['#type'] ??="), None); + assert_eq!(render_array_value_key("$type ="), None); + assert_eq!(render_array_value_key("foo("), None); + } +} diff --git a/src/drupal/hooks.rs b/src/drupal/hooks.rs index db79ac9e0..68c73c009 100644 --- a/src/drupal/hooks.rs +++ b/src/drupal/hooks.rs @@ -108,6 +108,19 @@ impl ExtensionIndex { .map(|(_, is_theme)| *is_theme) } + /// The directory of the extension `name`, when the walk found it. + pub(crate) fn dir_of(&self, name: &str) -> Option<&Path> { + self.by_dir + .iter() + .find(|(_, (known, _))| known == name) + .map(|(dir, _)| dir.as_path()) + } + + /// Whether the file at `path` belongs to a theme. + pub(crate) fn owner_is_theme(&self, path: &Path) -> bool { + self.owner(path).is_some_and(|(_, _, is_theme)| is_theme) + } + /// The extension a file belongs to: the one of its nearest ancestor /// directory holding an `.info.yml`, with that directory. fn owner<'a>(&'a self, path: &'a Path) -> Option<(&'a Path, &'a str, bool)> { @@ -591,6 +604,22 @@ impl Backend { found } + /// Every implementation of the hook `key` Drupal collects: its file, + /// where the hook is named there, and its extension. + pub(crate) fn drupal_hook_implementations(&self, key: &str) -> Vec<(String, u32, String)> { + self.hook_occurrences(key) + .into_iter() + .filter_map(|occurrence| match occurrence.role { + Occurrence::Implementation { + extension, + collected: true, + .. + } => Some((occurrence.uri, occurrence.start, extension.to_string())), + _ => None, + }) + .collect() + } + /// Whether the type engine confirms `site` of the file at `uri`, read /// in isolation from whatever pass is running, as the declarations of /// another file are. diff --git a/src/drupal/libraries.rs b/src/drupal/libraries.rs new file mode 100644 index 000000000..35a90f92c --- /dev/null +++ b/src/drupal/libraries.rs @@ -0,0 +1,672 @@ +//! Drupal's libraries: the sets of CSS and JavaScript files an extension +//! declares, one per top-level key of `.libraries.yml`, named +//! `/`. +//! +//! A library is read by the `dependencies` of another library, the +//! `libraries` a theme's info file attaches to every page, a template's +//! `attach_library()`, and the `library` list of a render array's +//! `#attached` (see `symbol_map::extraction::drupal`). Libraries a module +//! builds in code, through `hook_library_info_build()`, are not read. + +use super::routing::{entries, lines, scalar}; +use crate::Backend; +use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; + +/// Whether `name` has the shape of a library name: an extension and a +/// key, joined by a slash. +pub(crate) fn is_library_name(name: &str) -> bool { + name.split_once('/') + .is_some_and(|(extension, key)| !extension.is_empty() && !key.is_empty()) + && !name.bytes().any(|byte| { + byte.is_ascii_whitespace() + || matches!(byte, b'\'' | b'"' | b'{' | b'}' | b'[' | b']' | b',') + }) +} + +/// The extension whose libraries file `path` (a path or URI) is: the name +/// before `.libraries.yml`. +pub(crate) fn libraries_file_extension(path: &str) -> Option<&str> { + let name = path.rsplit('/').next().unwrap_or(path); + name.strip_suffix(".libraries.yml") + .filter(|extension| !extension.is_empty()) +} + +/// A library span over `name`, written at `start`. +fn library_span(name: &str, start: usize, is_write: bool) -> SymbolSpan { + SymbolSpan { + start: start as u32, + end: (start + name.len()) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalLibrary, + key: name.to_string(), + is_write, + is_optional: false, + }, + } +} + +/// Push the library `raw`, a YAML scalar starting at `start`, reads. +fn push_library(raw: &str, start: usize, spans: &mut Vec) { + let trimmed = raw.trim_start(); + let leading = raw.len() - trimmed.len(); + let (name, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); + if is_library_name(name) { + spans.push(library_span(name, start + leading + quote, false)); + } +} + +/// Push each library of an `[a, 'b']` flow sequence starting at `start`. +fn push_flow_libraries(raw: &str, start: usize, spans: &mut Vec) { + let Some(open) = raw.find('[') else { + return; + }; + let close = raw.rfind(']').unwrap_or(raw.len()).max(open + 1); + let mut item_start = open + 1; + for item in raw[open + 1..close].split(',') { + push_library(item, start + item_start, spans); + item_start += item.len() + 1; + } +} + +/// The library spans of the libraries file of `extension`: each top-level +/// key declares `/`, and each item of a library's +/// `dependencies` reads a library. Sorted by offset. +pub(crate) fn libraries_file_spans(extension: &str, content: &str) -> Vec { + let mut spans = Vec::new(); + // The indentation of the keys of the library the scan is in. + let mut child_indent: Option = None; + // The indentation of the `dependencies:` key whose list the scan is in. + let mut dependencies: Option = None; + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + if semantic.trim().is_empty() { + continue; + } + let indent = crate::framework::leading_spaces(semantic); + let text = &semantic[indent..]; + // YAML lets a block list's items sit at its key's indentation. + if let Some(list) = dependencies { + if indent >= list + && let Some(item) = text.strip_prefix('-') + { + push_library(item, line_start + indent + 1, &mut spans); + continue; + } + dependencies = None; + } + let Some(colon) = text.find(':') else { + continue; + }; + let (key, (quote, _)) = crate::framework::strip_yaml_quotes(text[..colon].trim_end()); + if indent == 0 { + child_indent = None; + let name = format!("{extension}/{key}"); + if !key.is_empty() && is_library_name(&name) { + spans.push(SymbolSpan { + end: (line_start + quote + key.len()) as u32, + ..library_span(&name, line_start + quote, true) + }); + } + continue; + } + let child = *child_indent.get_or_insert(indent); + if indent == child && key == "dependencies" { + let value = &text[colon + 1..]; + if value.trim_start().starts_with('[') { + push_flow_libraries(value, line_start + indent + colon + 1, &mut spans); + } else { + dependencies = Some(indent); + } + } + } + spans +} + +/// The libraries a theme's info file attaches to every page: the items of +/// its top-level `libraries` list. Nothing for a module's or a profile's +/// info file, which has no such list. +pub(crate) fn info_file_spans(content: &str) -> Vec { + let mut spans = Vec::new(); + if !is_theme_info(content) { + return spans; + } + let mut in_list = false; + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + if semantic.trim().is_empty() { + continue; + } + let indent = crate::framework::leading_spaces(semantic); + let text = &semantic[indent..]; + if in_list { + if let Some(item) = text.strip_prefix('-') { + push_library(item, line_start + indent + 1, &mut spans); + continue; + } + in_list = false; + } + if indent > 0 { + continue; + } + let Some(value) = text.strip_prefix("libraries:") else { + continue; + }; + if value.trim_start().starts_with('[') { + push_flow_libraries(value, line_start + "libraries:".len(), &mut spans); + } else { + in_list = true; + } + } + spans +} + +/// Whether an info file declares a theme. +fn is_theme_info(content: &str) -> bool { + crate::framework::line_offsets(content) + .iter() + .any(|(_, line)| { + line.strip_prefix("type:").is_some_and(|value| { + let value = crate::symfony::resources::yaml_content_before_comment(value).trim(); + crate::framework::strip_yaml_quotes(value).0 == "theme" + }) + }) +} + +/// Where the library name the cursor is typing in a libraries file starts: +/// an item of a library's `dependencies`. +pub(crate) fn libraries_file_name_start(content: &str, cursor: usize) -> Option { + list_item_start(content, cursor, "dependencies", |indent| indent > 0) +} + +/// Where the library name the cursor is typing in a theme's info file +/// starts: an item of its `libraries`. +pub(crate) fn info_file_name_start(content: &str, cursor: usize) -> Option { + if !is_theme_info(content) { + return None; + } + list_item_start(content, cursor, "libraries", |indent| indent == 0) +} + +/// Where the item the cursor is typing starts, in a block or flow list +/// under a `key:` whose indentation `at_level` accepts. +fn list_item_start( + content: &str, + cursor: usize, + key: &str, + at_level: impl Fn(usize) -> bool, +) -> Option { + let before = content.get(..cursor)?; + let line_start = before.rfind('\n').map_or(0, |index| index + 1); + let line = &before[line_start..]; + let name_start = line + .bytes() + .rposition(|byte| byte.is_ascii_whitespace() || matches!(byte, b'\'' | b'"' | b'[' | b',')) + .map_or(0, |index| index + 1); + let opening = line[..name_start].trim_end_matches(['\'', '"']).trim_end(); + let indent = crate::framework::leading_spaces(opening); + let opening = &opening[indent..]; + let in_list = if opening == "-" { + list_key_above(&content[..line_start], indent) + .is_some_and(|(key_indent, found)| found == key && at_level(key_indent)) + } else { + // A flow list still open on the key's own line. + opening + .strip_prefix(key) + .and_then(|rest| rest.trim_start().strip_prefix(':')) + .is_some_and(|rest| rest.trim_start().starts_with('[') && !rest.contains(']')) + && at_level(indent) + }; + in_list.then_some(line_start + name_start) +} + +/// The key whose block list an item indented by `indent`, after the text +/// `above`, belongs to, with the key's indentation: the first line above +/// it that is not an item of the same list, indented no deeper than the +/// item. +fn list_key_above(above: &str, indent: usize) -> Option<(usize, &str)> { + let line = above + .lines() + .rev() + .map(crate::symfony::resources::yaml_content_before_comment) + .filter(|line| !line.trim().is_empty()) + .find(|line| { + let line_indent = crate::framework::leading_spaces(line); + line_indent < indent || (line_indent == indent && !line[line_indent..].starts_with('-')) + })?; + let line_indent = crate::framework::leading_spaces(line); + let key = line[line_indent..].trim_end().strip_suffix(':')?; + Some((line_indent, key)) +} + +/// The `version` the libraries file `content` gives the library `key`, as +/// written. +pub(crate) fn library_version(content: &str, key: &str) -> Option { + let lines = lines(content); + let (_, library) = entries(&lines) + .into_iter() + .find(|(entry, _)| entry.indent == 0 && entry.key == key)?; + entries(library) + .into_iter() + .find(|(child, _)| child.key == "version") + .map(|(child, _)| scalar(child.value, child.value_start).0) + .filter(|version| !version.is_empty()) +} + +/// Whether the library `key` is one of the sorted `declared` names, or +/// `None` when that cannot be told: a site whose libraries files were not +/// read declares none at all, and an extension among the sorted `builders` +/// may add any library in code. A name whose extension declares nothing, +/// or does not exist, is no library. +pub(crate) fn judge_library(declared: &[String], builders: &[String], key: &str) -> Option { + if declared.is_empty() { + return None; + } + let found = |names: &[String], wanted: &str| { + names + .binary_search_by(|name| name.as_str().cmp(wanted)) + .is_ok() + }; + if found(declared, key) { + return Some(true); + } + let extension = key.split_once('/').map_or(key, |(extension, _)| extension); + (!found(builders, extension)).then_some(false) +} + +// ─── Twig ─────────────────────────────────────────────────────────────────── + +/// The `attach_library` function a template calls with a library name. +const ATTACH_LIBRARY: &str = "attach_library"; + +/// The library names a template's `attach_library('…')` calls read, sorted +/// by offset. Only a call inside `{{ }}` or `{% %}` reads one, and not in +/// a `{% verbatim %}` block. +pub(crate) fn twig_library_spans(content: &str) -> Vec { + let bytes = content.as_bytes(); + let mut spans = Vec::new(); + if memchr::memmem::find(bytes, ATTACH_LIBRARY.as_bytes()).is_none() { + return spans; + } + let mut raw_until: Option<&'static str> = None; + crate::symfony::translations::for_each_twig_block(content, |body, end| { + let tag = (bytes[body - 1] == b'%') + .then(|| crate::twig::callables::tag_at(content, body, end)) + .flatten(); + if let Some(close) = raw_until { + if tag.is_some_and(|(tag, _)| tag == close) { + raw_until = None; + } + return; + } + if let Some(close) = tag.and_then(|(tag, _)| crate::twig::callables::opens_raw_text(tag)) { + raw_until = Some(close); + return; + } + for found in memchr::memmem::find_iter(&bytes[body..end], ATTACH_LIBRARY) { + let at = body + found; + if !is_function_start(content, at) { + continue; + } + let mut open = at + ATTACH_LIBRARY.len(); + while open < end && bytes[open].is_ascii_whitespace() { + open += 1; + } + if open >= end || bytes[open] != b'(' { + continue; + } + open += 1; + while open < end && bytes[open].is_ascii_whitespace() { + open += 1; + } + let Some(quote @ (b'\'' | b'"')) = bytes.get(open).copied().filter(|_| open < end) + else { + continue; + }; + let start = open + 1; + let Some(len) = memchr::memchr(quote, &bytes[start..end]) else { + continue; + }; + let name = &content[start..start + len]; + if is_library_name(name) { + spans.push(library_span(name, start, false)); + } + } + }); + spans +} + +/// Whether the `attach_library` at `at` is the function itself, not part +/// of a longer name or a method of something. +fn is_function_start(content: &str, at: usize) -> bool { + !content[..at] + .bytes() + .next_back() + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) + && !content[at + ATTACH_LIBRARY.len()..] + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +/// Where the library name a template's `attach_library('` the cursor is +/// in starts, when the name typed so far runs up to `cursor`. +pub(crate) fn twig_library_name_start(content: &str, cursor: usize) -> Option { + let before = content.get(..cursor)?; + let name_start = before + .bytes() + .rposition(|byte| { + !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'/')) + }) + .map_or(0, |index| index + 1); + let before_name = before[..name_start].strip_suffix(['\'', '"'])?; + let before_paren = before_name.trim_end().strip_suffix('(')?.trim_end(); + let function = before_paren.strip_suffix(ATTACH_LIBRARY)?; + is_function_start(content, function.len()).then_some(name_start) +} + +/// The hooks whose implementations may add libraries in code. +const LIBRARY_BUILDING_HOOKS: [&str; 2] = ["library_info_build", "library_info_alter"]; + +/// The extensions that implement `hook_library_info_build()` or +/// `hook_library_info_alter()`, by the file they do it in, and all of them +/// sorted. +#[derive(Default)] +pub(crate) struct LibraryBuilders { + by_source: std::collections::HashMap>, + extensions: Vec, +} + +impl LibraryBuilders { + fn new(by_source: std::collections::HashMap>) -> Self { + let mut extensions: Vec = by_source.values().flatten().cloned().collect(); + extensions.sort_unstable(); + extensions.dedup(); + Self { + by_source, + extensions, + } + } +} + +/// The extensions the implementations of either library hook Drupal +/// collects in `map` are for, sorted. +fn builder_extensions(map: &crate::symbol_map::SymbolMap) -> Vec { + let mut extensions: Vec = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalHook, + key, + is_write: false, + .. + } if LIBRARY_BUILDING_HOOKS.contains(&key.as_str()) => { + let at = map + .drupal_hooks + .binary_search_by_key(&span.start, |site| site.start) + .ok()?; + match &map.drupal_hooks[at].role { + crate::symbol_map::HookRole::Implementation { + extension, + collected: true, + .. + } => Some(extension.to_string()), + _ => None, + } + } + _ => None, + }) + .collect(); + extensions.sort_unstable(); + extensions.dedup(); + extensions +} + +impl Backend { + /// The extensions that implement `hook_library_info_build()` or + /// `hook_library_info_alter()`, sorted: the libraries under them may be + /// added in code. Read once, then kept up to date one file at a time + /// (see [`Self::note_drupal_library_builder_sources`]). + pub(crate) fn drupal_library_builders(&self) -> Vec { + if let Some(builders) = self.drupal.library_builders.read().as_ref() { + return builders.extensions.clone(); + } + let mut by_source: std::collections::HashMap> = + std::collections::HashMap::new(); + for hook in LIBRARY_BUILDING_HOOKS { + for (uri, _, extension) in self.drupal_hook_implementations(hook) { + by_source.entry(uri).or_default().push(extension); + } + } + let builders = LibraryBuilders::new(by_source); + let extensions = builders.extensions.clone(); + *self.drupal.library_builders.write() = Some(std::sync::Arc::new(builders)); + extensions + } + + /// Follow the file at `uri`, now holding `map`, in what + /// [`Self::drupal_library_builders`] read: the extensions it implements + /// either hook for are read from `map` alone. A map dropped (`None`) + /// while its file is read again changes nothing; a file gone from disk + /// builds nothing. + pub(crate) fn note_drupal_library_builder_sources( + &self, + uri: &str, + map: Option<&crate::symbol_map::SymbolMap>, + ) { + let Some(builders) = self.drupal.library_builders.read().clone() else { + return; + }; + let found = match map { + Some(map) => builder_extensions(map), + None if crate::util::uri_to_path(uri).is_some_and(|path| path.exists()) => return, + None => Vec::new(), + }; + if builders + .by_source + .get(uri) + .map_or(found.is_empty(), |known| *known == found) + { + return; + } + let mut by_source = builders.by_source.clone(); + if found.is_empty() { + by_source.remove(uri); + } else { + by_source.insert(uri.to_string(), found); + } + let mut slot = self.drupal.library_builders.write(); + // Another change landed meanwhile: read them all again. + *slot = slot + .as_ref() + .filter(|current| std::sync::Arc::ptr_eq(current, &builders)) + .map(|_| std::sync::Arc::new(LibraryBuilders::new(by_source))); + } + + /// What the hover of the library `name` says: the libraries file that + /// declares it, and the version it gives the library. + pub(crate) fn drupal_library_detail(&self, name: &str) -> String { + let declared = self.symfony_declaration_locations(StringKeyKind::DrupalLibrary, name); + let Some(location) = declared.first() else { + return "Not declared in the project's libraries files".to_string(); + }; + let uri = location.uri.as_str(); + let mut detail = match self.workspace_relative_path(uri) { + Some(path) => format!("Declared in `{}`", path), + None => "Declared in a libraries file".to_string(), + }; + let key = name.split_once('/').map_or(name, |(_, key)| key); + if let Some(version) = self + .get_file_content(uri) + .and_then(|content| library_version(&content, key)) + { + detail.push_str(&format!("\n\nVersion `{}`", version)); + } + detail + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each span as the text it covers, its key, and whether it declares. + fn found<'a>(spans: &[SymbolSpan], content: &'a str) -> Vec<(&'a str, String, bool)> { + spans + .iter() + .map(|span| { + let SymbolKind::StringKey { key, is_write, .. } = &span.kind else { + unreachable!() + }; + ( + &content[span.start as usize..span.end as usize], + key.clone(), + *is_write, + ) + }) + .collect() + } + + #[test] + fn top_level_keys_declare_and_dependencies_read() { + let yaml = "# Libraries.\ngreeting:\n version: 1.x\n css:\n component:\n css/greeting.css: {}\n dependencies:\n - core/drupal\n - 'core/once' # comment\n'quoted.one':\n js:\n js/one.js: { attributes: { dependencies: not/read } }\n dependencies:\n - acme/greeting\n version: VERSION\nflow:\n dependencies: [core/drupal, 'acme/greeting']\n"; + assert_eq!( + found(&libraries_file_spans("acme", yaml), yaml), + [ + ("greeting", "acme/greeting".to_string(), true), + ("core/drupal", "core/drupal".to_string(), false), + ("core/once", "core/once".to_string(), false), + ("quoted.one", "acme/quoted.one".to_string(), true), + ("acme/greeting", "acme/greeting".to_string(), false), + ("flow", "acme/flow".to_string(), true), + ("core/drupal", "core/drupal".to_string(), false), + ("acme/greeting", "acme/greeting".to_string(), false), + ] + ); + } + + #[test] + fn the_extension_is_the_file_name() { + assert_eq!( + libraries_file_extension("file:///s/web/core/core.libraries.yml"), + Some("core") + ); + assert_eq!( + libraries_file_extension("/s/web/themes/custom/x/x.libraries.yml"), + Some("x") + ); + assert_eq!(libraries_file_extension("/s/.libraries.yml"), None); + assert_eq!(libraries_file_extension("/s/x.routing.yml"), None); + } + + #[test] + fn a_themes_info_file_attaches_libraries() { + let yaml = "name: Acme Theme\ntype: theme\nlibraries:\n - acme_theme/global\n - \"core/drupal\"\ndependencies:\n - acme:acme\nlibraries-extend:\n core/drupal:\n - acme_theme/extra\n"; + assert_eq!( + found(&info_file_spans(yaml), yaml), + [ + ("acme_theme/global", "acme_theme/global".to_string(), false), + ("core/drupal", "core/drupal".to_string(), false), + ] + ); + let flow = "type: 'theme'\nlibraries: [acme_theme/global]\n"; + assert_eq!(found(&info_file_spans(flow), flow).len(), 1); + let module = "type: module\nlibraries:\n - acme/greeting\n"; + assert!(info_file_spans(module).is_empty()); + } + + #[test] + fn only_twig_code_attaches_libraries() { + let twig = "

attach_library('in/html')

\n{% verbatim %}{{ attach_library('in/verbatim') }}{% endverbatim %}\n{% do attach_library('acme/tag') %}{{ attach_library('acme/print') }}\n"; + assert_eq!( + found(&twig_library_spans(twig), twig), + [ + ("acme/tag", "acme/tag".to_string(), false), + ("acme/print", "acme/print".to_string(), false), + ] + ); + } + + #[test] + fn completion_starts_in_library_lists_only() { + let yaml = "a:\n dependencies:\n - core/dr"; + assert_eq!( + libraries_file_name_start(yaml, yaml.len()), + Some(yaml.len() - 7) + ); + let yaml = "a:\n dependencies:\n - '"; + assert_eq!( + libraries_file_name_start(yaml, yaml.len()), + Some(yaml.len()) + ); + let yaml = "a:\n dependencies: [core/drupal, co"; + assert_eq!( + libraries_file_name_start(yaml, yaml.len()), + Some(yaml.len() - 2) + ); + let yaml = "a:\n js:\n - x"; + assert_eq!(libraries_file_name_start(yaml, yaml.len()), None); + let yaml = "dependencies:\n - x"; + assert_eq!(libraries_file_name_start(yaml, yaml.len()), None); + + let info = "type: theme\nlibraries:\n - acme"; + assert_eq!(info_file_name_start(info, info.len()), Some(info.len() - 4)); + let info = "type: theme\ndependencies:\n - acme"; + assert_eq!(info_file_name_start(info, info.len()), None); + let info = "type: module\nlibraries:\n - acme"; + assert_eq!(info_file_name_start(info, info.len()), None); + } + + #[test] + fn the_version_is_read_from_the_librarys_block() { + let yaml = "one:\n js: {}\ntwo:\n css:\n version: no\n version: \"1.0.1\"\n"; + assert_eq!(library_version(yaml, "two").as_deref(), Some("1.0.1")); + assert_eq!(library_version(yaml, "one"), None); + } + + #[test] + fn a_library_is_judged_against_the_declared_names() { + let declared = ["acme/greeting".to_string(), "core/drupal".to_string()]; + let builders = ["example".to_string()]; + assert_eq!( + judge_library(&declared, &builders, "acme/greeting"), + Some(true) + ); + assert_eq!( + judge_library(&declared, &builders, "acme/greting"), + Some(false) + ); + assert_eq!( + judge_library(&declared, &builders, "nowhere/thing"), + Some(false) + ); + assert_eq!(judge_library(&declared, &builders, "example/built"), None); + // The builder's name is the whole extension, not a prefix. + assert_eq!( + judge_library(&declared, &builders, "examples/x"), + Some(false) + ); + assert_eq!(judge_library(&[], &builders, "acme/greeting"), None); + } + + #[test] + fn templates_attach_libraries() { + let twig = "{{ attach_library('acme/greeting') }}\n{# attach_library('in/comment') #}\n{{ attach_library( \"core/drupal\" ) }}{{ x.attach_library('no/method') }}{{ my_attach_library('no/other') }}\n"; + assert_eq!( + found(&twig_library_spans(twig), twig), + [ + ("acme/greeting", "acme/greeting".to_string(), false), + ("core/drupal", "core/drupal".to_string(), false), + ] + ); + let twig = "{{ attach_library('acme/gr"; + assert_eq!( + twig_library_name_start(twig, twig.len()), + Some(twig.len() - 7) + ); + let twig = "{{ path('acme"; + assert_eq!(twig_library_name_start(twig, twig.len()), None); + let twig = "{{ x.attach_library('acme"; + assert_eq!(twig_library_name_start(twig, twig.len()), None); + } +} diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 8462b429f..bbf414a62 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -19,11 +19,15 @@ use parking_lot::RwLock; pub(crate) mod computed_hooks; pub(crate) mod config; pub(crate) mod config_keys; +pub(crate) mod elements; pub(crate) mod hooks; +pub(crate) mod libraries; pub(crate) mod links; pub(crate) mod permissions; pub(crate) mod routing; pub(crate) mod service_providers; +pub(crate) mod template_variables; +pub(crate) mod theme_hooks; pub(crate) mod types; pub(crate) mod upcasting; @@ -49,6 +53,20 @@ pub(crate) struct DrupalState { extensions: RwLock, /// What the hook documentation says, until a declaring file changes. hook_index: RwLock>>, + /// The paths of the Twig templates of every web root, outside test + /// modules, which the workspace walk does not reach in core and + /// contributed extensions, by the hook name their file name spells + /// (see [`theme_hooks::template_key`]). + templates: RwLock>>, + /// The theme hooks the site declares, until a file they come from + /// changes. + theme_hook_index: RwLock>>, + /// The extensions that build libraries in code, until a file that + /// implements the hook changes. + library_builders: RwLock>>, + /// The theme hook suggestions (`__`) render arrays name, + /// sorted; see [`template_variables`]. + theme_suggestions: RwLock>>>, /// The services each service provider's `alter()` gives another class, /// keyed by the provider's URI; see [`service_providers`]. service_replacements: RwLock>>, @@ -75,6 +93,10 @@ pub(crate) enum DeclarationFile { Links, /// `.permissions.yml`. Permissions, + /// `.libraries.yml`. + Libraries, + /// An extension's `.info.yml`. + Info, } /// The kind of declaration file the file name at the end of `path` is, if @@ -98,6 +120,10 @@ fn declaration_file_kind(path: &str) -> Option { .any(|suffix| name.ends_with(suffix)) { Some(DeclarationFile::Links) + } else if libraries::libraries_file_extension(name).is_some() { + Some(DeclarationFile::Libraries) + } else if hooks::is_info_file_name(name) { + Some(DeclarationFile::Info) } else { None } @@ -248,11 +274,22 @@ pub(crate) fn matches_route_pattern(pattern: &str, name: &str) -> bool { } /// Whether `path` is a file Drupal reads declarations from: a services, -/// routing, links, or permissions file, or an extension's `*.info.yml`. +/// routing, links, permissions, or libraries 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()) - .is_some_and(|name| declaration_file_kind(name).is_some() || hooks::is_info_file_name(name)) + .is_some_and(|name| declaration_file_kind(name).is_some()) + && !is_test_fixture(path, web_root) + && !is_unloaded(path, web_root) +} + +/// Whether `path` is a Twig template a theme hook may be drawn with: an +/// `*.html.twig` outside test modules. +pub(crate) fn is_template_file(path: &Path, web_root: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(theme_hooks::TEMPLATE_SUFFIX)) && !is_test_fixture(path, web_root) && !is_unloaded(path, web_root) } @@ -320,6 +357,10 @@ impl Backend { self.drupal.web_roots.write().clear(); self.drupal.declaration_files.write().clear(); self.drupal.hook_php_files.write().clear(); + self.drupal.templates.write().clear(); + *self.drupal.theme_hook_index.write() = None; + *self.drupal.library_builders.write() = None; + *self.drupal.theme_suggestions.write() = None; *self.drupal.extensions.write() = hooks::ExtensionIndex::default(); self.drupal.service_replacements.write().clear(); self.drupal.default_config_files.write().clear(); @@ -340,7 +381,13 @@ impl Backend { .and_then(|name| name.to_str()) .unwrap_or_default(); if hooks::is_info_file_name(name) { - extensions.extend(hooks::read_extension(&file)); + if let Some(extension) = hooks::read_extension(&file) { + // A theme's info file attaches libraries. + if extension.is_theme { + yaml.push(file); + } + extensions.push(extension); + } } else if name.ends_with(".yml") { yaml.push(file); } else { diff --git a/src/drupal/template_variables.rs b/src/drupal/template_variables.rs new file mode 100644 index 000000000..1e34955f4 --- /dev/null +++ b/src/drupal/template_variables.rs @@ -0,0 +1,295 @@ +//! The variables a Drupal theme hook's Twig templates receive. +//! +//! A module's template has no Twig template name a render could key its +//! context by, and a render array names a hook rather than a template, so a +//! template finds its variables through the hooks that claim it (see +//! [`Backend::drupal_template_theme_hooks`]): each hook's declared variables +//! or render element, the variables Drupal adds to every template, and the +//! `#` keys of the render arrays whose `#theme` names the hook or the +//! template's own suggestion. A hook's own templates also take the render +//! arrays of a suggestion of it no theme writes a template for, which +//! Drupal draws with them. + +use std::collections::{BTreeSet, HashSet}; +use std::sync::Arc; + +use crate::Backend; +use crate::reference_index::ReferenceIndexKey; +use crate::symbol_map::{StringKeyKind, SymbolKind}; +use crate::twig::variables::{Origin, OriginKind, OriginValue}; + +const ATTRIBUTE_CLASS: &str = "\\Drupal\\Core\\Template\\Attribute"; + +/// The variables Drupal adds to every template a theme hook draws, with +/// what each holds. +const DEFAULT_VARIABLES: [(&str, &str); 11] = [ + ( + "attributes", + "The HTML attributes of the template's outer element", + ), + ( + "title_attributes", + "The HTML attributes of the template's title", + ), + ( + "content_attributes", + "The HTML attributes of the template's content", + ), + ("title_prefix", "Render arrays modules add before the title"), + ("title_suffix", "Render arrays modules add after the title"), + ("db_is_active", "Whether the database is available"), + ("is_admin", "Whether the current user is an administrator"), + ("logged_in", "Whether the current user is logged in"), + ("user", "The current user"), + ("directory", "The directory of the active theme"), + ( + "theme_hook_original", + "The theme hook the template was rendered for, before suggestions", + ), +]; + +/// The type a variable Drupal adds to every template holds. +fn default_variable_type(name: &str) -> Option<&'static str> { + Some(match name { + "attributes" | "title_attributes" | "content_attributes" => ATTRIBUTE_CLASS, + "title_prefix" | "title_suffix" => "array", + "db_is_active" | "is_admin" | "logged_in" => "bool", + "user" => "\\Drupal\\Core\\Session\\AccountInterface", + "directory" | "theme_hook_original" => "string", + _ => return None, + }) +} + +impl Backend { + /// Whether some theme hook claims the template at `uri`. + fn is_drupal_hook_template(&self, uri: &str) -> bool { + self.is_drupal_project() + && crate::util::uri_to_path(uri) + .is_some_and(|path| !self.drupal_template_theme_hooks(&path).is_empty()) + } + + /// The variables Drupal adds to the template at `uri`: every one of + /// them when a theme hook claims it, else none. + pub(crate) fn drupal_template_default_variables( + &self, + uri: &str, + ) -> &'static [(&'static str, &'static str)] { + if self.is_drupal_hook_template(uri) { + &DEFAULT_VARIABLES + } else { + &[] + } + } + + /// The type of the variable `name` Drupal adds to the template at + /// `uri`. Drupal turns whatever a render passes as one of the + /// attributes variables into an `Attribute`, so those hold one even + /// when a hook declares them. + pub(crate) fn drupal_template_default_type( + &self, + uri: &str, + name: &str, + ) -> Option<&'static str> { + default_variable_type(name).filter(|_| self.is_drupal_hook_template(uri)) + } + + /// The `Attribute` class, when `name` is an attributes variable of the + /// Drupal template at `uri` (see [`Self::drupal_template_default_type`]). + pub(crate) fn drupal_template_attribute_type( + &self, + uri: &str, + name: &str, + ) -> Option<&'static str> { + self.drupal_template_default_type(uri, name) + .filter(|ty| *ty == ATTRIBUTE_CLASS) + } + + /// Add to `origins` the variables the theme hooks that claim the + /// template at `uri` hand it: each hook's declared variables or render + /// element, and those of them the render arrays naming the hook pass. + pub(crate) fn collect_drupal_template_received(&self, uri: &str, origins: &mut Vec) { + if !self.is_drupal_project() { + return; + } + let Some(path) = crate::util::uri_to_path(uri) else { + return; + }; + let hooks = self.drupal_template_theme_hooks(&path); + if hooks.is_empty() { + return; + } + let mut names: Vec = Vec::new(); + let mut variables: HashSet = HashSet::new(); + for hook in &hooks { + if !names.iter().any(|name| name == hook.name()) { + names.push(hook.name().to_string()); + } + let source = self.drupal_theme_hook_variable_source(hook); + let declared = source + .entry + .render_element + .iter() + .chain(&source.entry.variables); + for variable in declared { + origins.push(Origin { + name: variable.name.clone(), + kind: OriginKind::ThemeHook { + hook: hook.name().to_string(), + }, + uri: source.uri.clone(), + start: variable.start, + end: variable.end, + value: OriginValue::Unknown, + }); + } + // A render element hook takes the `#` keys into its element. + if source.entry.render_element.is_none() { + variables.extend( + source + .entry + .variables + .iter() + .map(|variable| variable.name.clone()), + ); + } + } + let own = crate::drupal::theme_hooks::template_key(&path).unwrap_or_default(); + for hook in &hooks { + let is_suggestion_template = own + .strip_prefix(hook.name()) + .is_some_and(|rest| rest.starts_with("__")); + if !is_suggestion_template { + for suggestion in self.drupal_unclaimed_suggestions(hook.name()) { + if !names.contains(&suggestion) { + names.push(suggestion); + } + } + } + } + // A theme's template for a suggestion is rendered for the + // suggestion's own name as well. + if !own.is_empty() && !names.contains(&own) { + names.push(own); + } + if variables.is_empty() { + return; + } + let keys: Vec = names + .iter() + .map(|name| ReferenceIndexKey::StringKey { + kind: StringKeyKind::DrupalThemeHook, + key: name.clone(), + }) + .collect(); + let mut snapshot = self.user_file_symbol_maps_for_reference_keys(&keys); + snapshot.sort_by(|a, b| a.0.cmp(&b.0)); + for (file_uri, map) in snapshot { + if map.twig_contexts.is_empty() { + continue; + } + let starts: Vec = map + .spans + .iter() + .filter(|span| { + matches!( + &span.kind, + SymbolKind::StringKey { + kind: StringKeyKind::DrupalThemeHook, + key, + is_write: false, + .. + } if names.contains(key) + ) + }) + .map(|span| span.start) + .collect(); + if starts.is_empty() { + continue; + } + self.collect_render_site_origins( + &file_uri, + &map, + &starts, + |name| variables.contains(name), + origins, + ); + } + } + + /// The suggestions of the hook `hook` render arrays name that fall back + /// to it, and for which no theme writes a template. + fn drupal_unclaimed_suggestions(&self, hook: &str) -> Vec { + let suggestions = self.drupal_theme_suggestions(); + let prefix = format!("{hook}__"); + let candidates: Vec<&String> = suggestions + .range::(( + std::ops::Bound::Included(prefix.as_str()), + std::ops::Bound::Unbounded, + )) + .take_while(|suggestion| suggestion.starts_with(&prefix)) + .collect(); + if candidates.is_empty() { + return Vec::new(); + } + let templates = self.drupal.templates.read(); + let extensions = self.drupal.extensions.read(); + candidates + .into_iter() + .filter(|suggestion| { + !templates + .get(suggestion.as_str()) + .is_some_and(|paths| paths.iter().any(|path| extensions.owner_is_theme(path))) + }) + .filter(|suggestion| { + self.drupal_theme_hooks(suggestion) + .first() + .is_some_and(|declared| declared.name() == hook) + }) + .cloned() + .collect() + } + + /// The suggestions render arrays name, read from the reference index + /// once and added to as files name new ones (see + /// [`Self::note_drupal_theme_suggestions`]). + fn drupal_theme_suggestions(&self) -> Arc> { + if let Some(suggestions) = self.drupal.theme_suggestions.read().as_ref() { + return Arc::clone(suggestions); + } + self.ensure_workspace_index_ready_for_request(); + let Some(keys) = self.indexed_string_keys(StringKeyKind::DrupalThemeHook) else { + return Arc::default(); + }; + let suggestions: Arc> = + Arc::new(keys.into_iter().filter(|key| key.contains("__")).collect()); + *self.drupal.theme_suggestions.write() = Some(Arc::clone(&suggestions)); + suggestions + } + + /// Add the suggestions `map` names that [`Self::drupal_theme_suggestions`] + /// does not hold yet. One no file names any more stays, and reads + /// nothing. + pub(crate) fn note_drupal_theme_suggestions(&self, map: &crate::symbol_map::SymbolMap) { + let Some(known) = self.drupal.theme_suggestions.read().clone() else { + return; + }; + let new: Vec<&String> = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalThemeHook, + key, + .. + } if key.contains("__") && !known.contains(key) => Some(key), + _ => None, + }) + .collect(); + if new.is_empty() { + return; + } + let mut suggestions = (*known).clone(); + suggestions.extend(new.into_iter().cloned()); + *self.drupal.theme_suggestions.write() = Some(Arc::new(suggestions)); + } +} diff --git a/src/drupal/theme_hooks.rs b/src/drupal/theme_hooks.rs new file mode 100644 index 000000000..68fd99032 --- /dev/null +++ b/src/drupal/theme_hooks.rs @@ -0,0 +1,1512 @@ +//! Drupal's theme hooks: the names `hook_theme()` implementations declare, +//! their variables, and the Twig templates that draw them. +//! +//! Every key of the array an implementation of the hook `theme` returns +//! declares a theme hook (see [`crate::drupal::hooks`] for how the +//! implementations are found). The array is read when it is a literal, a +//! local variable assigned one or built key by key, or an `array_merge()` or +//! `+` of those; core's `ThemeCommonElements::commonElements()`, which +//! system's implementation merges in, is read as part of the implementation +//! that calls it, and so is `drupal_common_theme()`, which it merged in +//! before Drupal 11.1. Arrays built in a loop or by other calls are not +//! read. The +//! implementations are read the first time a theme hook is asked for. An +//! edit to a file that implements the hook reads that file again; a change +//! to core's common hooks, or a file changed on disk, reads them all. +//! +//! A hook's template is `