diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3eafa211a..c10393384 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Symfony services that bundles and the container builder declare.** The service IDs and parameters an installed bundle declares in its own configuration complete, jump to their declaration, and type `$container->get()`, and so do the ones a kernel, an extension, or a compiler pass declares with `register()`, `setAlias()`, `setDefinition()`, `setParameter()`, or `getKernelParameters()`. A parameter set under a name built at runtime, like `'app.' . $key`, leaves the names it can produce unreported. A configurator's `parent()` and the `%name%` placeholders in its arguments are read as the service and parameter they name. - **Drupal services.** In a Drupal project, the service IDs and container parameters that core, contributed, and custom modules declare in their `*.services.yml` complete, jump to their declaration, and list their references wherever a services file, `$container->get()`, or `\Drupal::service()` names them, and a lookup is typed by the class the service is built from, or by the class or interface a lookup names by `::class`. Rename leaves core and contributed extensions alone, as it does vendor code. Contributed by @giuseppe-esposito-87. - **Drupal entity types.** In a Drupal project, an entity type ID declared by an entity class's attribute or annotation completes, jumps to its declaration, and lists its references wherever the entity type manager is asked for it, and `getStorage()` is typed as that entity type's storage, so `load()`, `loadMultiple()`, `create()`, and the other loads return the entity class. An entity query started by `\Drupal::entityQuery()` or a storage's `getQuery()` gives the IDs of that entity type, or their number after `count()`. Contributed by @giuseppe-esposito-87. +- **Drupal route names.** In a Drupal project, a route declared by a routing file, a `#[Route]` attribute, or a route builder completes, jumps to its declaration, and lists its references wherever code, a links file, or a template names it, from `Url::fromRoute()`, `$this->redirect()`, and `$form_state->setRedirect()` to `route_name:` and `path()`. A route name nothing declares is reported. The parameter keys those calls, links files, and templates pass jump to the placeholder of the route's path and complete from it, and a controller's parameter is typed as the entity Drupal upcasts it to, with a warning when a controller's type hint unrelated to the entity keeps Drupal from upcasting it. Contributed by @giuseppe-esposito-87. - **Symfony route names.** In a Symfony project, a route name declared by a `#[Route]` attribute, the routing YAML or XML, or a PHP routing configurator completes, jumps to its declaration, and lists its references wherever a controller or a Twig template builds a URL to it, from `generateUrl()` and `redirectToRoute()` to `path()` and `url()`. Names are followed through the imports that load them, bundles' own routing files included, along with the ones Symfony derives from a route, such as `contact.en` for a localized one. A route name nothing declares is reported, unless the router is configured to load its routes some other way. Contributed by @sidux. - **Symfony translations.** In a Symfony project, a translation key declared in a YAML or XLIFF catalogue completes, jumps to its entry in every locale, and lists its references wherever PHP or a Twig template translates it, from `trans()`, `TranslatableMessage`, and `t()` to the `trans` filter. A key is looked up in the domain the call names, and a domain jumps to its catalogues. A key spelled like one (`demo.title`) that nothing declares is reported, in a domain the project's own catalogues hold and no installed bundle ships. Contributed by @sidux. - **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/todo.md b/docs/todo.md index 07761e386..f7a66d611 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -33,7 +33,6 @@ contributor even though it's short. | # | Item | Impact | Complexity | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- | | DR9 | [Drupal directories outside `contrib` and `custom`](todo/drupal.md#dr9-drupal-directories-outside-contrib-and-custom) | Medium-High | Low-Medium | -| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium | | DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium | | DR8 | [Permission and configuration names](todo/drupal.md#dr8-permission-and-configuration-names) | Low-Medium | Medium | | DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High | @@ -127,7 +126,6 @@ ships, remove every row that names it. | DR11 | [Form and render callbacks](todo/drupal.md#dr11-form-and-render-callbacks) | Low-Medium | Low-Medium | | DR12 | [Translation placeholders](todo/drupal.md#dr12-translation-placeholders) | Low-Medium | Low-Medium | | DR13 | [Module names and `*.info.yml`](todo/drupal.md#dr13-module-names-and-infoyml) | Low-Medium | Low-Medium | -| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium | | DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium | | DR10 | [Plugin IDs](todo/drupal.md#dr10-plugin-ids) | Medium | Medium | | DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium | @@ -138,6 +136,7 @@ ships, remove every row that names it. | DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium | | DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium | | DR18 | [Warn on unknown entity type IDs](todo/drupal.md#dr18-warn-on-unknown-entity-type-ids) | Medium | Low-Medium | +| DR20 | [Complete route names built in code](todo/drupal.md#dr20-complete-route-names-built-in-code) | Low-Medium | Medium | | DR21 | [Return types core's docblocks leave wide](todo/drupal.md#dr21-return-types-cores-docblocks-leave-wide) | Medium | Low-Medium | ## Yii diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 9a9f4dd4f..c2c926c9b 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -3,9 +3,10 @@ Known gaps and missing features in PHPantom's Drupal support. The web root is detected, core, contrib, and the Drupal file extensions (`.module`, `.install`, `.theme`, …) are indexed past `.gitignore`, and -the services the `*.services.yml` files declare and the entity types -entity classes declare are read. Nothing reads Drupal's other -declarations yet: fields, routes, or hooks. +the services the `*.services.yml` files declare, the entity types +entity classes declare, and the routes the routing files, route +attributes, and route builders declare are read. Nothing reads Drupal's +other declarations yet: fields or hooks. The requests in [#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the @@ -53,40 +54,6 @@ names and report a misspelt one. --- -## DR5. Route names - -**Impact: Medium-High · Complexity: Medium** - -```php -Url::fromRoute('acme.order_view', ['order' => $id]); -$form_state->setRedirect('acme.order_list'); -``` - -```yaml -acme.order_view: - path: '/order/{order}' - defaults: - _controller: '\Drupal\acme\Controller\OrderController::view' -``` - -Routes are declared in `.routing.yml`. Complete, navigate, and -find references for route names in `Url::fromRoute()`, -`Link::createFromRoute()`, `$this->redirect()`, -`$form_state->setRedirect()`, and the menu, tab, and action YAML files, -and report a name nothing declares. The `_controller`, `_form`, and -`_entity_form` values may already navigate through the resource symbol -maps that read `Class::method` strings in YAML; check before building -anything for them. The parameter keys are the same problem as Symfony's -[F27](lsp-features.md#f27-symfony-route-parameters). - -A path parameter named after an entity type (`/node/{node}`) is upcast -by Drupal's parameter converters, so the controller method's `$node` -receives a `NodeInterface`, not the ID. Type an untyped controller -parameter from the route that names it, and check a typed one against -it. - ---- - ## DR6. Hooks **Impact: Medium-High · Complexity: Medium** @@ -342,6 +309,32 @@ site where an implementation of that hook may add one stays silent. --- +## DR20. Complete route names built in code + +**Impact: Low-Medium · Complexity: Medium** + +```php +Url::fromRoute('entity.node.canonical', ['node' => $nid]); +Url::fromRoute('view.content.page_1'); +``` + +Entity routes and Views routes are accepted without a warning, but +neither completes nor goes to a definition, since no routing file declares +them. Read them from where Drupal builds them: each entity type's link +templates (`entity..`) and each view's displays +(`view..`). + +The same reading would narrow what goes unreported. Any `view..` +is accepted by its shape alone, so a misspelt view or display is not +reported. And a route builder that adds a route under a name held in a +variable or returned by a call (`$collection->add($route_name, $route)`, +as Views and JSON:API do) leaves every name under its extension +(`views.*`, `jsonapi.*`) unjudged. Following the variable to the string +the method assigns it (`"view.$view_id.$display_id"`) would keep only that +shape. + +--- + ## DR21. Return types core's docblocks leave wide **Impact: Medium · Complexity: Low-Medium** diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 1cb635f2a..daf94f117 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -40,6 +40,31 @@ Run `composer install` here first. Composer puts core and contrib into `$count`: an entity query gives the IDs of its entity type, or their number once it counts, and the ID inside `\Drupal::entityQuery('')` navigates and completes too. +- Route names navigate between the routing file that declares them and the + code, links files, and templates that name them. In + `web/modules/custom/acme/src/Controller/GreetingController.php`, go to the + definition of `'acme.greeting'` in `greetTheWorld()` or `worldLink()`, + which lands on its key in `acme.routing.yml`, or find its references from + there, `acme.links.menu.yml` and the `path()` in + `templates/acme-greeting.html.twig` included. Complete a route name inside + `redirect('')`, `Url::fromRoute('')`, or after `route_name:` in a links + file; core's routes are offered too. A route name nothing declares is + reported, except one of the shapes core builds routes in: in + `web/modules/custom/acme/src/CoreLinks.php`, `'entity.node.canonical'`, + which core's entity route provider builds, and `'view.content.page_1'`, + which Views builds for the content view's page, are not. +- Route parameters navigate from the keys that pass them to the placeholder + of the route's path. In `worldLink()`, go to the definition of `'name'`, + which lands on `{name}` in `acme.routing.yml`, or complete a key inside the + parameters array; the `route_parameters:` of `acme.links.menu.yml` and the + `path()` hash in `templates/acme-greeting.html.twig` go there too. +- A controller's parameter is typed as the entity Drupal upcasts it to. In + `web/modules/custom/acme/src/Controller/NoteController.php`, hover + `$acme_note` in `view()`, named after the `acme_note` entity type and + hinted with an interface `Note` implements, or in the untyped `title()`, + the same route's title callback: both are a `Note`. A controller hint + unrelated to the entity class, which leaves Drupal passing the raw ID, is + reported. ## What the Symfony support already reads diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 16113fe38..dfa82a607 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -14,6 +14,7 @@ require_once __DIR__ . '/vendor/autoload.php'; use Drupal\acme\Controller\GreetingController; +use Drupal\acme\Controller\NoteController; use Drupal\acme\Entity\Note; use Drupal\acme\Greeter; use Drupal\acme\Hook\AcmeHooks; @@ -28,10 +29,13 @@ use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\ContentEntityTypeInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManager; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\Extension\InfoParser; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Link; +use Drupal\Core\Url; $passed = 0; $failed = 0; @@ -119,6 +123,36 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string check('The _controller string names a public method', (new ReflectionMethod($controllerClass, $controllerMethod))->isPublic()); check('acme.greeting declares the {name} the controller takes', str_contains($routes['acme.greeting']['path'], '{name}') && (new ReflectionMethod(GreetingController::class, 'greet'))->getParameters()[0]->getName() === 'name'); check('GreetingController inherits the helpers of ControllerBase', is_subclass_of(GreetingController::class, ControllerBase::class)); +check('ControllerBase::redirect(), which greetTheWorld() calls, takes a route name first', (new ReflectionMethod(ControllerBase::class, 'redirect'))->getParameters()[0]->getName() === 'route_name'); +check('Url::fromRoute() takes a route name first', (new ReflectionMethod(Url::class, 'fromRoute'))->getParameters()[0]->getName() === 'route_name'); +check('Link::createFromRoute() takes a route name second', (new ReflectionMethod(Link::class, 'createFromRoute'))->getParameters()[1]->getName() === 'route_name'); + +// Every route the demo names is one the routing file declares. +$controllerSource = (string) file_get_contents("$module/src/Controller/GreetingController.php"); +preg_match_all("/(?:redirect|fromRoute)\\('([^']+)'|createFromRoute\\([^,]+, '([^']+)'/", $controllerSource, $named); +$namedInPhp = array_values(array_filter(array_merge($named[1], $named[2]))); +check('GreetingController names routes in redirect(), fromRoute() and createFromRoute()', count($namedInPhp) === 3); +check('every route GreetingController names is declared', array_diff($namedInPhp, array_keys($routes)) === []); +$menuLinks = Yaml::decode((string) file_get_contents("$module/acme.links.menu.yml")); +check('the acme.greeting menu link points at a declared route', isset($routes[$menuLinks['acme.greeting']['route_name'] ?? ''])); +check("the menu link's parameters are the ones the route's path declares", array_keys($menuLinks['acme.greeting']['route_parameters'] ?? []) === ['name']); +preg_match_all("/path\\('([^']+)'/", (string) file_get_contents("$module/templates/acme-greeting.html.twig"), $namedInTwig); +check('the acme_greeting template links to a declared route', $namedInTwig[1] !== [] && array_diff($namedInTwig[1], array_keys($routes)) === []); +check("the template passes the name parameter acme.greeting's path declares", str_contains((string) file_get_contents("$module/templates/acme-greeting.html.twig"), "path('acme.greeting', {'name': 'World'})")); +check('worldLink() passes the name parameter the path declares', str_contains($controllerSource, "Url::fromRoute('acme.greeting', ['name' => 'World'])")); + +// The routes CoreLinks names are built by core, not declared in a routing file. +$coreLinksSource = (string) file_get_contents("$module/src/CoreLinks.php"); +$nodeEntitySource = (string) file_get_contents("$web/core/modules/node/src/Entity/Node.php"); +$nodeRoutesSource = (string) file_get_contents("$web/core/modules/node/src/Entity/NodeRouteProvider.php"); +$entityRoutesSource = (string) file_get_contents("$web/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php"); +$contentView = Yaml::decode((string) file_get_contents("$web/core/modules/node/config/optional/views.view.content.yml")); +check('CoreLinks passes entity.node.canonical its node parameter', str_contains($coreLinksSource, "Url::fromRoute('entity.node.canonical', ['node' => 1])")); +check("DefaultHtmlRouteProvider builds entity..canonical from the entity type's ID", str_contains($entityRoutesSource, '$collection->add("entity.{$entity_type_id}.canonical", $canonical_route);')); +check("node's html route provider is NodeRouteProvider, a DefaultHtmlRouteProvider", str_contains($nodeEntitySource, "'html' => NodeRouteProvider::class") && str_contains($nodeRoutesSource, 'class NodeRouteProvider extends DefaultHtmlRouteProvider')); +check("node's canonical link takes the {node} parameter", str_contains($nodeEntitySource, "'canonical' => '/node/{node}'")); +check('CoreLinks names view.content.page_1', str_contains($coreLinksSource, "Url::fromRoute('view.content.page_1')")); +check('node ships the content view with the page_1 page display', ($contentView['id'] ?? '') === 'content' && ($contentView['display']['page_1']['display_plugin'] ?? '') === 'page'); // ─── Entity types ─────────────────────────────────────────────────────────── @@ -139,6 +173,18 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string check('NoteCounter::count() counts the entity query it starts from the storage', str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "->getQuery()") && str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "->count()")); check('NoteCounter::first() asks for acme_note', str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "getStorage('acme_note');")); +// Drupal upcasts a controller's parameter named after an entity type and +// hinted with a class or interface the entity class is (core's +// EntityResolverManager::setParametersFromReflection()). +[$noteControllerClass, $noteControllerMethod] = explode('::', ltrim($routes['acme.note']['defaults']['_controller'] ?? '', '\\')) + [1 => '']; +$noteView = new ReflectionMethod(NoteController::class, 'view'); +$noteParameter = $noteView->getParameters()[0]; +check('acme.note points at NoteController::view()', $noteControllerClass === NoteController::class && $noteControllerMethod === 'view'); +check('acme.note declares the {acme_note} parameter', str_contains($routes['acme.note']['path'], '{acme_note}')); +check('NoteController::view() names its parameter after the acme_note entity type', $noteParameter->getName() === ($noteType['id'] ?? null)); +check('NoteController::view() hints the parameter with an interface Note implements, so Drupal upcasts it', $noteParameter->getType()?->getName() === EntityInterface::class && is_subclass_of(Note::class, EntityInterface::class)); +check("acme.note's title callback is NoteController::title(), which receives the same parameter", ltrim($routes['acme.note']['defaults']['_title_callback'] ?? '', '\\') === NoteController::class . '::title' && (new ReflectionMethod(NoteController::class, 'title'))->getParameters()[0]->getName() === 'acme_note'); + // ─── Hooks and theme hooks ────────────────────────────────────────────────── require_once "$module/acme.module"; diff --git a/examples/drupal/web/modules/custom/acme/acme.links.menu.yml b/examples/drupal/web/modules/custom/acme/acme.links.menu.yml new file mode 100644 index 000000000..7a9c2b436 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/acme.links.menu.yml @@ -0,0 +1,8 @@ +# Try: go to the definition of acme.greeting, or complete a route name after +# route_name:. The name under route_parameters: goes to the route's {name}. +acme.greeting: + title: 'Greeting' + route_name: acme.greeting + route_parameters: + name: 'World' + menu_name: main diff --git a/examples/drupal/web/modules/custom/acme/acme.routing.yml b/examples/drupal/web/modules/custom/acme/acme.routing.yml index 0d7af79ee..7f9f9f3f1 100644 --- a/examples/drupal/web/modules/custom/acme/acme.routing.yml +++ b/examples/drupal/web/modules/custom/acme/acme.routing.yml @@ -1,4 +1,6 @@ acme.greeting: + # Try: find the references of {name}, the parameter this path declares, + # to see the calls, the menu link, and the template that pass it. path: '/acme/greeting/{name}' defaults: # Try: go to the definition of GreetingController, or of greet after the @@ -8,3 +10,13 @@ acme.greeting: _title: 'Greeting' requirements: _permission: 'access content' + +acme.note: + path: '/acme/note/{acme_note}' + defaults: + # Try: hover $acme_note in NoteController::view() and title(). Drupal + # loads the Note entity for the {acme_note} parameter before either runs. + _controller: '\Drupal\acme\Controller\NoteController::view' + _title_callback: '\Drupal\acme\Controller\NoteController::title' + requirements: + _permission: 'access content' 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 cc051851f..2d84da338 100644 --- a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php +++ b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php @@ -6,6 +6,9 @@ use Drupal\acme\Greeter; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Link; +use Drupal\Core\Url; +use Symfony\Component\HttpFoundation\RedirectResponse; /** * Shows the greeting for the acme.greeting route. @@ -31,4 +34,28 @@ public function greet(string $name): array '#greeting' => $this->greeter->greet($name), ]; } + + /** + * Sends the visitor to the greeting for the world. + */ + public function greetTheWorld(): RedirectResponse + { + // Try: go to the definition of 'acme.greeting', which lands on its key + // in acme.routing.yml, or complete a route name inside redirect(''). + return $this->redirect('acme.greeting', ['name' => 'World']); + } + + /** + * A link to the greeting for the world. + */ + public function worldLink(): Link + { + // Try: find the references of 'acme.greeting' to see every place that + // links or redirects to it, the links and Twig files included. Go to + // the definition of 'name', which lands on {name} in the route's path, + // or complete a parameter key inside the array. + $url = Url::fromRoute('acme.greeting', ['name' => 'World']); + + return Link::createFromRoute($this->t('Greet the world'), 'acme.greeting', $url->getRouteParameters()); + } } diff --git a/examples/drupal/web/modules/custom/acme/src/Controller/NoteController.php b/examples/drupal/web/modules/custom/acme/src/Controller/NoteController.php new file mode 100644 index 000000000..e140e4749 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/Controller/NoteController.php @@ -0,0 +1,35 @@ + + */ + public function view(EntityInterface $acme_note): array + { + // Try: hover $acme_note. The parameter is named after the acme_note + // entity type and hinted with an interface Note implements, so Drupal + // upcasts the ID in the path to the Note itself. + return ['#markup' => $acme_note->label()]; + } + + /** + * The title of the note page. + */ + public function title($acme_note): string + { + // Try: hover $acme_note. The title callback receives the same Note, + // though nothing types it here. + return (string) $acme_note->label(); + } +} diff --git a/examples/drupal/web/modules/custom/acme/src/CoreLinks.php b/examples/drupal/web/modules/custom/acme/src/CoreLinks.php new file mode 100644 index 000000000..e2466269e --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/CoreLinks.php @@ -0,0 +1,30 @@ + + */ + public function urls(): array + { + // Try: neither route name is reported, though no routing file + // declares it. Core's entity route provider builds + // entity..canonical for every entity type with a canonical + // link, and Views builds view.. for a page display, + // here the page_1 display of node's content view. A name of neither + // shape, such as 'entity.nod', is reported. + return [ + Url::fromRoute('entity.node.canonical', ['node' => 1]), + Url::fromRoute('view.content.page_1'), + ]; + } +} 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 e56f8bac3..745899960 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 @@ -10,4 +10,7 @@ * acme_theme adds in preprocess. */ #} +{# 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. #} {{ greeting }}

+
{{ 'Greet the world'|t }} diff --git a/src/backend/startup.rs b/src/backend/startup.rs index 9ff3f2f2a..11b4d7dee 100644 --- a/src/backend/startup.rs +++ b/src/backend/startup.rs @@ -656,6 +656,16 @@ impl Backend { .await; } + // A Drupal file opened before the index finished was judged + // against the route names read so far, and the route builders + // of core and contributed extensions are only read here. + if progress_backend.is_drupal_project() { + let open: Vec = + progress_backend.open_files.read().keys().cloned().collect(); + for uri in open { + progress_backend.schedule_diagnostics(uri); + } + } progress_backend.request_diagnostic_refresh().await; // Files opened before the index finished were annotated from a diff --git a/src/blade/typed_receiver.rs b/src/blade/typed_receiver.rs index 72d93f0eb..913a92d00 100644 --- a/src/blade/typed_receiver.rs +++ b/src/blade/typed_receiver.rs @@ -458,7 +458,8 @@ struct ReceiverCallWalker<'a, 'sites> { impl<'sites> ReceiverCallWalker<'_, 'sites> { /// The candidates the argument at `index` holds: in the string itself, /// or in the entries of the array a `first(['a', 'b'])` or an - /// `args(['%app.sender%'])` builds. + /// `args(['%app.sender%'])` builds, their keys included, which name + /// the parameters of a Drupal `setRedirect('route', ['node' => 1])`. fn matching_sites( &self, expr: &Expression<'_>, @@ -496,7 +497,10 @@ impl<'sites> ReceiverCallWalker<'_, 'sites> { for element in elements.iter() { match element { ArrayElement::Value(value) => self.matching_sites(value.value, index, out), - ArrayElement::KeyValue(value) => self.matching_sites(value.value, index, out), + ArrayElement::KeyValue(value) => { + self.matching_sites(value.key, index, out); + self.matching_sites(value.value, index, out); + } _ => {} } } diff --git a/src/completion/drupal_route_params.rs b/src/completion/drupal_route_params.rs new file mode 100644 index 000000000..33e0b2775 --- /dev/null +++ b/src/completion/drupal_route_params.rs @@ -0,0 +1,180 @@ +//! Completion for the parameters a Drupal route's path declares. +//! +//! The parameters array of `Url::fromRoute('entity.node.canonical', ['|' => +//! …])`, and of the other calls that take a route name and then its +//! parameters, is keyed by the placeholders of the named route's path, +//! which the route side tables keep beside each name (see +//! [`crate::symbol_map::DrupalRouteSite`]). A static call or a `new Url()` +//! is recognised by its class, and a method call by its receiver, as its +//! route name is. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::completion::eloquent_string::detect_string_call_context; +use crate::completion::source::code_context::{CodeContext, OpenBracket}; +use crate::completion::symfony_string_keys::{is_instantiation, trailing_name}; +use crate::symbol_map::StringKeyKind; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::types::FileContext; + +impl Backend { + /// Complete a key of the parameters array of a call that takes a Drupal + /// route name. + /// + /// `None` when the cursor is not on such a key, or when the route's + /// path declares no parameters. + pub(crate) fn try_drupal_route_parameter_completion( + &self, + content: &str, + position: Position, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let (quote, _) = code.open_string?; + // A key starts the array or follows a comma in it. + if !matches!(code.last_code_byte(), Some(b'[' | b',')) { + return None; + } + // The array is an argument of the call, right inside it. + let [.., call, array] = &code.open_brackets[..] else { + return None; + }; + if call.byte != b'(' || array.byte != b'[' { + return None; + } + let route_index = self.drupal_route_argument(content, cursor, call, code, ctx)?; + if call.commas != route_index + 1 { + return None; + } + let route = string_argument(content, call.offset, route_index)?; + + let prefix = content.get(quote + 1..cursor)?.as_bytes(); + let edit_range = Range { + start: offset_to_position(content, quote + 1), + end: position, + }; + let mut items: Vec = Vec::new(); + for path in self.drupal_route_paths(&route) { + for name in crate::drupal::routing::path_parameter_names(&path) { + let matches = name + .as_bytes() + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)); + if !matches || items.iter().any(|item| item.label == name) { + continue; + } + items.push(CompletionItem { + label: name.to_string(), + kind: Some(CompletionItemKind::Field), + detail: Some(path.clone()), + sort_text: Some(format!("{:05}", items.len())), + filter_text: Some(name.to_string()), + text_edit: Some(CompletionItemTextEdit::TextEdit(TextEdit { + range: edit_range, + new_text: name.to_string(), + })), + ..Default::default() + }); + } + } + (!items.is_empty()).then_some(CompletionResponse::CompletionItemList(items)) + } + + /// The argument the call whose `(` is `call` takes a Drupal route name + /// in: `Url::fromRoute()`, `new Url()`, `Link::createFromRoute()`, or a + /// method call whose receiver the type engine confirms takes one. + fn drupal_route_argument( + &self, + content: &str, + cursor: usize, + call: &OpenBracket, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let Some(operator) = &call.callee_operator else { + let written = trailing_name(&content[..call.code_before]); + let offset = call.code_before - written.len(); + let class = ctx.resolve_name_at(written, offset as u32); + return (is_instantiation(&content[..offset]) && crate::drupal::is_url_class(&class)) + .then_some(0); + }; + let found = detect_string_call_context(content, cursor, code)?; + let subject = found.subject.as_deref()?; + if operator.is_static { + let class = ctx.resolve_name_at(subject, cursor as u32); + return crate::drupal::static_route_argument(&class, &found.method_name); + } + crate::drupal::route_parameter_call(&found.method_name)?; + (self.receiver_call_key(subject, &found.method_name, 0, content, cursor, ctx) + == Some(StringKeyKind::DrupalRoute)) + .then_some(0) + } +} + +/// The string literal the call whose `(` is at `paren` passes as its +/// argument `index`, when the argument is one. +fn string_argument(content: &str, paren: usize, index: usize) -> Option { + let bytes = content.as_bytes(); + let mut argument = 0; + let mut depth = 0usize; + // Whether the argument being read holds anything before a literal. + let mut started = false; + let mut at = paren + 1; + 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 == 0 && argument == index && !started { + return content.get(at + 1..end).map(str::to_string); + } + started = true; + at = end + 1; + continue; + } + b'(' | b'[' | b'{' => { + depth += 1; + started = true; + } + b')' | b']' | b'}' => { + depth = depth.checked_sub(1)?; + } + b',' if depth == 0 => { + argument += 1; + started = false; + if argument > index { + return None; + } + } + byte if !byte.is_ascii_whitespace() => started = true, + _ => {} + } + at += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_route_argument_is_read_at_its_index() { + let php = "Link::createFromRoute($this->t('Hi'), 'acme.greeting', ['"; + let paren = php.find('(').unwrap(); + assert_eq!( + string_argument(php, paren, 1).as_deref(), + Some("acme.greeting") + ); + assert_eq!(string_argument(php, paren, 0), None); + let php = "Url::fromRoute( 'it\\'s', ["; + assert_eq!( + string_argument(php, php.find('(').unwrap(), 0).as_deref(), + Some("it\\'s") + ); + } +} diff --git a/src/completion/handler/string_arguments.rs b/src/completion/handler/string_arguments.rs index e05c2082c..085ffaba4 100644 --- a/src/completion/handler/string_arguments.rs +++ b/src/completion/handler/string_arguments.rs @@ -95,6 +95,19 @@ impl Backend { return Some(response); } + // ── Drupal route parameters ───────────────────────────── + // The keys of `Url::fromRoute('entity.node.canonical', ['|' => …])` + // are the placeholders of the named route's path. Asked first, so + // that a key after a comma is not read as an argument of the call. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = + self.try_drupal_route_parameter_completion(content, position, code, ctx) + { + return Some(response); + } + // ── Symfony and Yii names ─────────────────────────────── // `$container->get('|')`, `service('|')`, `#[Autowire(param: '|')]`, // `$translator->trans('|')`, in any project that uses Twig, diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index ab5a86159..a99410ba2 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -46,12 +46,15 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { StringKeyKind::SymfonyConfigKey => CompletionItemKind::Field, StringKeyKind::SymfonyEvent | StringKeyKind::WordPressHook => CompletionItemKind::Event, StringKeyKind::MessengerBus => CompletionItemKind::Reference, - StringKeyKind::YiiRouteArgument => CompletionItemKind::Field, + StringKeyKind::YiiRouteArgument | StringKeyKind::DrupalRouteParameter => { + CompletionItemKind::Field + } StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => CompletionItemKind::Function, StringKeyKind::TwigVariable => CompletionItemKind::Variable, StringKeyKind::Route | StringKeyKind::SymfonyRoute | StringKeyKind::YiiRoute + | StringKeyKind::DrupalRoute | StringKeyKind::Command | StringKeyKind::Section | StringKeyKind::Stack @@ -183,20 +186,23 @@ impl Backend { | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute - | StringKeyKind::DrupalEntityType => self.symfony_declared_names(*kind).into(), + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute => self.symfony_declared_names(*kind).into(), StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => { self.twig_callable_names(*kind).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. A hook name is + // the routes of the handler it is written in, and a Drupal route + // parameter from the route its call names. A hook name is // completed with its patterns as snippets, which only the // WordPress completion builds. StringKeyKind::SymfonyTranslation | StringKeyKind::WordPressHook | StringKeyKind::SymfonyConfigKey | StringKeyKind::YiiRouteArgument + | StringKeyKind::DrupalRouteParameter | StringKeyKind::Section | StringKeyKind::Stack | StringKeyKind::TwigBlock diff --git a/src/completion/mod.rs b/src/completion/mod.rs index cc4af550d..e3264f13a 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -78,6 +78,7 @@ pub(crate) mod array_callable; pub mod array_shape; pub(crate) mod builder; pub(crate) mod command_params; +mod drupal_route_params; pub(crate) mod eloquent_model_attributes; pub(crate) mod eloquent_string; pub(crate) mod handler; diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index 057b981d9..61a62bd7e 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -218,6 +218,9 @@ impl Backend { if call.arg_index == 0 && is_bus_name_stamp(&class) { return Some(StringKeyKind::MessengerBus); } + if call.arg_index == 0 && crate::drupal::is_url_class(&class) { + return Some(StringKeyKind::DrupalRoute); + } is_translatable_message(&class) } else { let function = resolve_function_name(ctx, written, offset); @@ -235,17 +238,21 @@ impl Backend { }; }; if call.is_static { - // `\Drupal::service('|')`, `\Drupal::hasService('|')`, and - // `\Drupal::entityQuery('|')`. - if call.arg_index != 0 || !self.is_drupal_project() { - return None; - } + // `\Drupal::service('|')`, `\Drupal::hasService('|')`, + // `\Drupal::entityQuery('|')`, `Url::fromRoute('|')`, and + // `Link::createFromRoute('', '|')`. let class = ctx.resolve_name_at(subject, cursor as u32); - if crate::drupal::is_service_lookup(&class, &call.method_name) { - return Some(StringKeyKind::ServiceId); + if call.arg_index == 0 && self.is_drupal_project() { + if crate::drupal::is_service_lookup(&class, &call.method_name) { + return Some(StringKeyKind::ServiceId); + } + if crate::drupal::is_entity_query(&class, &call.method_name) { + return Some(StringKeyKind::DrupalEntityType); + } } - return crate::drupal::is_entity_query(&class, &call.method_name) - .then_some(StringKeyKind::DrupalEntityType); + return (crate::drupal::static_route_argument(&class, &call.method_name) + == Some(call.arg_index)) + .then_some(StringKeyKind::DrupalRoute); } self.receiver_call_key( subject, @@ -341,9 +348,20 @@ impl Backend { let cursor = position_to_offset(content, position) as usize; let (kind, prefix_start) = if self.is_twig_template_uri(uri) { // Template names are Twig's, and route names and translation - // keys Symfony's. + // keys Symfony's; Drupal's Twig reads route names too. if !self.is_twig_project() { - return None; + if !self.is_drupal_project() { + return None; + } + let start = crate::symfony::routes::drupal_twig_route_name_start(content, cursor)?; + return self.symfony_key_response( + uri, + StringKeyKind::DrupalRoute, + content, + start, + cursor, + position, + ); } if is_symfony && let Some((start, domain)) = @@ -375,11 +393,25 @@ 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_in_drupal_web_root(uri) + .then(|| crate::symfony::routes::drupal_twig_route_name_start(content, cursor)) + .flatten() + { + // The templates in a Drupal web root name Drupal's routes, + // also when the site requires Symfony's container. + (StringKeyKind::DrupalRoute, start) } else if let Some(start) = is_symfony .then(|| crate::symfony::routes::twig_route_name_start(content, cursor)) .flatten() { (StringKeyKind::SymfonyRoute, start) + } else if let Some(start) = self + .is_drupal_project() + .then(|| crate::symfony::routes::drupal_twig_route_name_start(content, cursor)) + .flatten() + { + (StringKeyKind::DrupalRoute, start) } else { let (start, kind) = crate::twig::callables::callable_name_start(content, cursor)?; let names = self.twig_callable_names(kind); @@ -417,8 +449,15 @@ impl Backend { return (!items.is_empty()) .then_some(CompletionResponse::CompletionItemList(items)); } - } else if self.is_drupal_services_uri(uri) { - resource_key_at(content, cursor)? + } else if let Some(file) = self.drupal_declaration_file(uri) { + match file { + crate::drupal::DeclarationFile::Services => resource_key_at(content, cursor)?, + crate::drupal::DeclarationFile::Links => ( + StringKeyKind::DrupalRoute, + crate::drupal::links::links_route_name_start(content, cursor)?, + ), + crate::drupal::DeclarationFile::Routing => return None, + } } else if !is_symfony { return None; } else if crate::symfony::resources::is_container_config_uri(uri) { @@ -616,7 +655,7 @@ where } /// Whether `before`, the text before a callee's name, ends with `new`. -fn is_instantiation(before: &str) -> bool { +pub(crate) fn is_instantiation(before: &str) -> bool { let before = before.trim_end(); before.strip_suffix("new").is_some_and(|rest| { !rest @@ -627,7 +666,7 @@ fn is_instantiation(before: &str) -> bool { } /// The (possibly qualified) name `text` ends with. -fn trailing_name(text: &str) -> &str { +pub(crate) fn trailing_name(text: &str) -> &str { let start = text .bytes() .rposition(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'\\'))) diff --git a/src/diagnostics/collect.rs b/src/diagnostics/collect.rs index 43262e8dd..042d0e6a5 100644 --- a/src/diagnostics/collect.rs +++ b/src/diagnostics/collect.rs @@ -158,6 +158,10 @@ static SLOW_COLLECTORS: &[SlowCollector] = &[ "invalid_member_name", Backend::collect_dynamic_member_name_diagnostics_with_context, ), + always( + "invalid_drupal_route_parameter", + Backend::collect_drupal_route_parameter_diagnostics, + ), SlowCollector { name: "invalid_string_key", collect: Backend::collect_invalid_laravel_string_key_diagnostics, diff --git a/src/diagnostics/drupal_route_parameters.rs b/src/diagnostics/drupal_route_parameters.rs new file mode 100644 index 000000000..c8f814ac0 --- /dev/null +++ b/src/diagnostics/drupal_route_parameters.rs @@ -0,0 +1,187 @@ +//! Drupal route parameters whose type hint rejects the raw ID Drupal +//! passes. +//! +//! Drupal loads the entity a parameter stands for only when the route +//! upcasts it (see [`crate::drupal::upcasting`]). A controller parameter +//! named after an entity type and hinted with a class unrelated to its +//! entity class is not upcast, so it is passed the raw ID, which the hint +//! rejects: +//! +//! ```php +//! // node.view: path: '/node/{node}' +//! public function view(UserInterface $node): array // receives '42' +//! ``` +//! +//! The warning gives a verdict only where it is sure, and stays silent +//! otherwise: on title callbacks, on controllers PHPantom cannot reflect +//! the way core does, on parameters the route's `options` declare or an +//! `_entity_*` default may upcast, and on hints whose class hierarchy it +//! cannot load. A parameter is reported only when every route that gives +//! it a value, by its path or a default, passes it the raw ID. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::atom::{Atom, AtomSet}; +use crate::class_lookup::{Fqn, ResolvedClassLoader, is_subtype_of}; +use crate::symbol_map::{DrupalRouteSite, SymbolKind}; +use crate::text_position::offset_to_position; +use crate::types::ClassInfo; + +use super::helpers::{FileDiagnosticContext, make_diagnostic}; + +/// Diagnostic code used for a route parameter whose hint blocks upcasting. +pub(crate) const INVALID_DRUPAL_ROUTE_PARAMETER_CODE: &str = "invalid_drupal_route_parameter"; + +impl Backend { + /// Report each parameter of a route's controller whose class hint + /// rejects the raw ID every route that reaches the method passes it. + pub(crate) fn collect_drupal_route_parameter_diagnostics( + &self, + ctx: &FileDiagnosticContext, + uri: &str, + content: &str, + out: &mut Vec, + ) { + if !self.is_drupal_project() || self.is_drupal_example_or_test_code(uri) { + return; + } + let class_loader = self.resolved_class_loader(&ctx.file); + for class in &ctx.file.classes { + let fqn = class.fqn(); + for method in class.methods.iter() { + let routes = self.drupal_handler_routes(&fqn, &method.name); + if routes.is_empty() { + continue; + } + let handler = format!("{}::{}", fqn.trim_start_matches('\\'), method.name); + for parameter in method.parameters.iter() { + let name = parameter.name.trim_start_matches('$'); + let Some(hint) = parameter + .native_type_hint + .as_ref() + .and_then(crate::drupal::upcasting::hinted_class) + else { + continue; + }; + let mut passing = routes.iter().filter(|route| route.passes(name)).peekable(); + if passing.peek().is_none() + || !passing.all(|route| { + self.passes_rejected_id(route, &handler, name, hint, &class_loader) + }) + { + continue; + } + let Some((start, end)) = parameter_span(ctx, method.name_offset, name) else { + continue; + }; + out.push(make_diagnostic( + Range { + start: offset_to_position(content, start as usize), + end: offset_to_position(content, end as usize), + }, + DiagnosticSeverity::Warning, + INVALID_DRUPAL_ROUTE_PARAMETER_CODE, + format!( + "Drupal passes `${name}` the raw ID, which the `{hint}` hint rejects: no route that reaches this method upcasts it" + ), + )); + } + } + } + } + + /// Whether `route`, which passes the parameter `name` of `handler` + /// (`Class::method`), surely passes it the raw ID its class hint `hint` + /// rejects. `false` whenever that is not certain. + fn passes_rejected_id( + &self, + route: &DrupalRouteSite, + handler: &str, + name: &str, + hint: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> bool { + // Core reflects only on the method the route dispatches to, which + // `controller` names only when it is a class's. + if !route + .controller + .as_deref() + .is_some_and(|controller| controller.eq_ignore_ascii_case(handler)) + { + return false; + } + // A declared parameter is converted as declared, and an `_entity_*` + // default may upcast it. + if route.declared(name).is_some() || route.entity_type.is_some() { + return false; + } + // A `NULL` default the path does not override is passed as it is. + if route.defaults_to_null(name) + && !crate::drupal::routing::path_parameter_names(&route.path) + .any(|placeholder| placeholder == name) + { + return false; + } + let Some(hinted) = + class_loader(Fqn::new(hint)).filter(|hinted| hierarchy_loads(hinted, class_loader)) + else { + return false; + }; + let entities = self.drupal_entity_types(name); + !entities.is_empty() + && entities.iter().all(|(entity, _)| { + class_loader(Fqn::new(entity)) + .filter(|class| hierarchy_loads(class, class_loader)) + // A subclass is a bundle class, which core's reflection + // does not upcast to, but `hook_entity_type_alter()` can + // make it the entity class. + .is_some_and(|class| { + !is_subtype_of(&class, hint, class_loader) + && !is_subtype_of(&hinted, entity, class_loader) + }) + }) + } +} + +/// Whether every class and interface `class` extends or implements loads, +/// so that subtyping against it is decided rather than cut short. A +/// keyword type is no class to load: PHP's stubs declare `\Traversable` +/// to extend `iterable`, which every content entity reaches. +fn hierarchy_loads(class: &ClassInfo, class_loader: &ResolvedClassLoader<'_>) -> bool { + let mut pending: Vec = class + .parent_class + .into_iter() + .chain(class.interfaces.iter().copied()) + .collect(); + let mut seen = AtomSet::default(); + while let Some(name) = pending.pop() { + if !seen.insert(name) || !crate::php_type::is_class_like_name(name.trim_start_matches('\\')) + { + continue; + } + let Some(ancestor) = class_loader(Fqn::new(&name)) else { + return false; + }; + pending.extend( + ancestor + .parent_class + .into_iter() + .chain(ancestor.interfaces.iter().copied()), + ); + } + true +} + +/// The byte range of the parameter `name` of the method whose name starts +/// at `name_offset`: the first `$name` after it. +fn parameter_span(ctx: &FileDiagnosticContext, name_offset: u32, name: &str) -> Option<(u32, u32)> { + let spans = &ctx.symbol_map.spans; + let from = spans.partition_point(|span| span.start <= name_offset); + spans[from..].iter().find_map(|span| match &span.kind { + SymbolKind::Variable { name: variable } if variable.as_str() == name => { + Some((span.start, span.end)) + } + _ => None, + }) +} diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index fd5c907da..27e1377b1 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -47,6 +47,7 @@ enum CheckedStringKind { YiiView, YiiRoute, YiiRouteArgument, + DrupalRoute, } impl CheckedStringKind { @@ -76,6 +77,7 @@ impl CheckedStringKind { StringKeyKind::YiiView => Self::YiiView, StringKeyKind::YiiRoute => Self::YiiRoute, StringKeyKind::YiiRouteArgument => Self::YiiRouteArgument, + StringKeyKind::DrupalRoute => Self::DrupalRoute, // 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 @@ -93,8 +95,11 @@ impl CheckedStringKind { // template variable no render was found to pass may be passed // by one that builds its context at runtime. A module can add // an entity type in PHP, through `hook_entity_type_build()`. + // Drupal sends a route parameter no path declares as the query + // string. StringKeyKind::WordPressHook | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRouteParameter | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack @@ -166,6 +171,14 @@ struct DeclaredKeys<'a> { messenger_buses: Vec, /// The Yii route names the project declares, sorted. yii_routes: Vec, + /// The Drupal route names the project declares, sorted. + drupal_routes: Vec, + /// The shapes of the route names Drupal's route builders give the + /// routes they build from parts (`search.view_*`). + drupal_route_patterns: Vec, + /// The `Class::method` and `Class` names Drupal gives attribute routes, + /// sorted. + drupal_route_aliases: Vec, } impl<'a> DeclaredKeys<'a> { @@ -200,6 +213,7 @@ 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); // A package with no routes of its own, whose names are registered // by the host application, cannot be judged: the valid set is @@ -362,6 +376,16 @@ impl<'a> DeclaredKeys<'a> { Vec::new() }; + let (drupal_routes, drupal_route_patterns, drupal_route_aliases) = if has_drupal_route { + ( + backend.symfony_declared_names(crate::symbol_map::StringKeyKind::DrupalRoute), + backend.drupal_route_patterns(), + backend.drupal_route_aliases(), + ) + } else { + (Vec::new(), Vec::new(), Vec::new()) + }; + Self { backend, routes, @@ -385,6 +409,9 @@ impl<'a> DeclaredKeys<'a> { twig_callables, messenger_buses, yii_routes, + drupal_routes, + drupal_route_patterns, + drupal_route_aliases, } } @@ -467,6 +494,7 @@ impl<'a> DeclaredKeys<'a> { .map(|exists| (exists, "view", "invalid_yii_view")), CheckedStringKind::YiiRoute => self.judge_yii_route(key), CheckedStringKind::YiiRouteArgument => self.judge_yii_route_argument(key), + CheckedStringKind::DrupalRoute => self.judge_drupal_route(key), } } @@ -508,6 +536,37 @@ impl<'a> DeclaredKeys<'a> { Some((valid, "translation key", "invalid_symfony_translation")) } + /// A Drupal route is declared by a routing file, a route attribute, or + /// a route builder that writes its name out. Those a route builder + /// builds from parts, as core's entity route providers build + /// `entity..canonical`, and those Views builds for a display + /// (`view..`) cannot be told missing, and a project + /// whose routing files were not read declares none at all. + /// A `Class::method` or `Class` name is one of the aliases attribute + /// route discovery gives a controller's routes. + fn judge_drupal_route(&self, key: &str) -> Verdict { + const VERDICT: (&str, &str) = ("route", "invalid_drupal_route"); + if self.drupal_routes.is_empty() || crate::drupal::is_views_route_name(key) { + return None; + } + let declares = |names: &[String]| { + names + .binary_search_by(|name| name.as_str().cmp(key)) + .is_ok() + }; + if declares(&self.drupal_routes) || declares(&self.drupal_route_aliases) { + return Some((true, VERDICT.0, VERDICT.1)); + } + if self + .drupal_route_patterns + .iter() + .any(|pattern| crate::drupal::matches_route_pattern(pattern, key)) + { + return None; + } + Some((false, VERDICT.0, VERDICT.1)) + } + /// 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. @@ -804,6 +863,7 @@ impl Backend { let symbol_map = &ctx.symbol_map; let extra = self.typed_receiver_spans_for(uri, symbol_map); let morph_columns = self.morph_column_spans_for(uri, symbol_map); + let is_drupal_test_code = self.is_drupal_example_or_test_code(uri); let key_spans: Vec = symbol_map .spans .iter() @@ -837,6 +897,11 @@ 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 { + return None; + } Some(( CheckedStringKind::of(kind)?, key.as_str(), diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index f5b3d9ae2..1a8c59e72 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -261,6 +261,7 @@ mod deprecated; mod destructuring_offsets; mod discarded_results; mod docblock_native_mismatch; +mod drupal_route_parameters; mod dynamic_member_names; mod enum_errors; pub(crate) mod existence_guards; diff --git a/src/drupal/links.rs b/src/drupal/links.rs new file mode 100644 index 000000000..673ff4a36 --- /dev/null +++ b/src/drupal/links.rs @@ -0,0 +1,266 @@ +//! The route names a Drupal links file reads. +//! +//! A menu, task, action, or contextual links file declares one link per +//! top-level key, and places it by route: the link's `route_name`, the +//! `base_route` a task is grouped under, and the routes an action +//! `appears_on`. A `parent` or `parent_id` names another link, not a +//! route. The keys of a link's `route_parameters` are parameters of the +//! route its `route_name` names. + +use super::routing::{entries, lines, route_parameter_key, scalar}; +use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; +use crate::symfony::routes::is_drupal_route_name; + +/// The keys of a link whose value is a route name. +fn names_route(key: &str) -> bool { + matches!(key, "route_name" | "base_route") +} + +/// The route-name spans of a links file, sorted by offset. +pub(crate) fn links_route_spans(content: &str) -> Vec { + let mut spans = Vec::new(); + // The indentation of the `appears_on:` key whose list the scan is in. + let mut appears_on: 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) = appears_on { + if indent >= list + && let Some(item) = text.strip_prefix('-') + { + let item_start = line_start + indent + 1; + push_route(item, item_start, &mut spans); + continue; + } + appears_on = None; + } + if indent == 0 { + continue; + } + let Some(colon) = text.find(':') else { + continue; + }; + let key = text[..colon].trim_end(); + let value = &text[colon + 1..]; + let value_start = line_start + indent + colon + 1; + if names_route(key) { + push_route(value, value_start, &mut spans); + } else if key == "appears_on" { + match value.trim().strip_prefix('[') { + Some(_) => push_flow_routes(value, value_start, &mut spans), + None => appears_on = Some(indent), + } + } + } + spans +} + +/// The spans of the route parameters a links file passes: each key of a +/// link's `route_parameters`, as a parameter of its `route_name`, which +/// may come before or after it. +pub(crate) fn links_parameter_spans(content: &str) -> Vec { + let lines = lines(content); + let mut spans = Vec::new(); + for (_, link) in entries(&lines) { + let mut route = None; + let mut keys: Vec<(String, usize)> = Vec::new(); + for (child, nested) in entries(link) { + match child.key { + "route_name" => route = Some(scalar(child.value, child.value_start).0), + "route_parameters" if nested.is_empty() => { + keys.extend(flow_keys(child.value, child.value_start)); + } + "route_parameters" => keys.extend( + entries(nested) + .into_iter() + .map(|(parameter, _)| (parameter.key.to_string(), parameter.key_start)), + ), + _ => {} + } + } + let Some(route) = route.filter(|route| is_drupal_route_name(route)) else { + continue; + }; + for (key, start) in keys { + spans.push(SymbolSpan { + start: start as u32, + end: (start + key.len()) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: route_parameter_key(&route, &key), + is_write: false, + is_optional: false, + }, + }); + } + } + spans +} + +/// The keys of a `{ a: 1, 'b': 2 }` flow mapping written at `start`, each +/// with the absolute offset of its text. +fn flow_keys(raw: &str, start: usize) -> Vec<(String, usize)> { + let Some(open) = raw.find('{') else { + return Vec::new(); + }; + let close = raw.rfind('}').unwrap_or(raw.len()).max(open + 1); + let mut keys = Vec::new(); + let mut pair_start = open + 1; + for pair in raw[open + 1..close].split(',') { + if let Some(colon) = pair.find(':') { + let written = pair[..colon].trim(); + let leading = pair[..colon].len() - pair[..colon].trim_start().len(); + let (key, (quote, _)) = crate::framework::strip_yaml_quotes(written); + if !key.is_empty() { + keys.push((key.to_string(), start + pair_start + leading + quote)); + } + } + pair_start += pair.len() + 1; + } + keys +} + +/// Push the route `raw`, a YAML scalar starting at `start`, names. +fn push_route(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_drupal_route_name(name) { + return; + } + let name_start = start + leading + quote; + spans.push(SymbolSpan { + start: name_start as u32, + end: (name_start + name.len()) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key: name.to_string(), + is_write: false, + is_optional: false, + }, + }); +} + +/// Push each route of an `[a, 'b']` flow sequence starting at `start`. +fn push_flow_routes(raw: &str, start: usize, spans: &mut Vec) { + let Some(open) = raw.find('[') else { + return; + }; + let close = raw.rfind(']').unwrap_or(raw.len()); + let mut item_start = open + 1; + for item in raw[open + 1..close.max(open + 1)].split(',') { + push_route(item, start + item_start, spans); + item_start += item.len() + 1; + } +} + +/// Where the route name the cursor is typing in a links file starts: the +/// value of a `route_name:` or `base_route:`, or an item of an +/// `appears_on:` list. +pub(crate) fn links_route_name_start(content: &str, cursor: usize) -> 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'"')) + .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 reads_route = match opening.strip_suffix(':') { + Some(key) => indent > 0 && names_route(key), + None => opening == "-" && in_appears_on(&content[..line_start], indent), + }; + reads_route.then_some(line_start + name_start) +} + +/// Whether a list item indented by `indent`, after the text `above`, is an +/// item of an `appears_on:` list: the first line above it that is not an +/// item of the same list is the key, indented no deeper than the item. +fn in_appears_on(above: &str, indent: usize) -> bool { + 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('-')) + }) + .is_some_and(|line| line.trim() == "appears_on:") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn routes(content: &str) -> Vec<&str> { + links_route_spans(content) + .iter() + .map(|span| &content[span.start as usize..span.end as usize]) + .collect() + } + + #[test] + fn route_keys_and_appears_on_items_read_routes() { + let yaml = "a:\n route_name: one\n base_route: 'two'\n parent_id: no\n appears_on:\n - three\n - \"four\" # comment\n parent: no\nb:\n appears_on: [five, 'six']\nc:\n appears_on:\n - seven\n - eight\n title: no\nroute_name: not_a_link\n"; + assert_eq!( + routes(yaml), + [ + "one", "two", "three", "four", "five", "six", "seven", "eight" + ] + ); + } + + #[test] + fn route_parameters_are_keys_of_the_links_route() { + let yaml = "a:\n route_parameters:\n name: 'World'\n 'id': 1\n route_name: acme.greeting\nb:\n route_name: acme.note\n route_parameters: { acme_note: 1, 'mode': full }\nc:\n route_parameters:\n orphan: 1\n"; + let found: Vec<(&str, String)> = links_parameter_spans(yaml) + .iter() + .map(|span| { + let SymbolKind::StringKey { key, .. } = &span.kind else { + unreachable!() + }; + (&yaml[span.start as usize..span.end as usize], key.clone()) + }) + .collect(); + assert_eq!( + found, + [ + ("name", "acme.greeting#name".to_string()), + ("id", "acme.greeting#id".to_string()), + ("acme_note", "acme.note#acme_note".to_string()), + ("mode", "acme.note#mode".to_string()), + ] + ); + } + + #[test] + fn completion_starts_in_route_values_only() { + let yaml = "a:\n route_name: on"; + assert_eq!( + links_route_name_start(yaml, yaml.len()), + Some(yaml.len() - 2) + ); + let yaml = "a:\n appears_on:\n - 'x"; + assert_eq!( + links_route_name_start(yaml, yaml.len()), + Some(yaml.len() - 1) + ); + let yaml = "a:\n parent: x"; + assert_eq!(links_route_name_start(yaml, yaml.len()), None); + let yaml = "a:\n menu_name:\n - x"; + assert_eq!(links_route_name_start(yaml, yaml.len()), None); + let yaml = "a:\n appears_on:\n - one\n - tw"; + assert_eq!( + links_route_name_start(yaml, yaml.len()), + Some(yaml.len() - 2) + ); + } +} diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 2679106db..2be41c734 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -7,19 +7,22 @@ //! `sites/*/services.yml`. Those are read by the same scan as Symfony's //! configuration (see [`crate::symfony::resources`]), so service IDs and //! container parameters work alike in both. Nothing else Symfony declares -//! (routes, translations, events) is read here: Drupal declares those its -//! own way. +//! (translations, events) is read here, and routes are read from Drupal's +//! own routing files: Drupal declares those its own way. use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use parking_lot::RwLock; +pub(crate) mod links; +pub(crate) mod routing; pub(crate) mod types; +pub(crate) mod upcasting; use crate::Backend; use crate::atom::Atom; -use crate::symbol_map::{StringKeyKind, SymbolKind}; +use crate::symbol_map::{ReceiverClass, StringKeyKind, SymbolKind}; /// The web roots of the workspace's Drupal projects, and the declaration /// files found under them. @@ -30,15 +33,45 @@ pub(crate) struct DrupalState { /// the workspace's `.gitignore` leaves out, so the walk that finds the /// rest of the project's YAML never reaches them. declaration_files: RwLock>, + /// The project's routes by the methods they dispatch to, once read. + route_index: RwLock>>, } -/// Whether the file name at the end of `path` is one Drupal loads services -/// from: `services.yml`, or one ending in `.services.yml` other than -/// `default.services.yml`, the template a site's `services.yml` is copied -/// from. -fn is_services_file_name(path: &str) -> bool { +/// A kind of file Drupal reads declarations from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeclarationFile { + /// `services.yml`, or a file ending in `.services.yml` other than + /// `default.services.yml`, the template a site's `services.yml` is + /// copied from. + Services, + /// `.routing.yml`. + Routing, + /// `.links.{menu,task,action,contextual}.yml`. + Links, +} + +/// The kind of declaration file the file name at the end of `path` is, if +/// any. +fn declaration_file_kind(path: &str) -> Option { let name = path.rsplit('/').next().unwrap_or(path); - name == "services.yml" || (name.ends_with(".services.yml") && name != "default.services.yml") + if name == "services.yml" || (name.ends_with(".services.yml") && name != "default.services.yml") + { + Some(DeclarationFile::Services) + } else if name.ends_with(".routing.yml") { + Some(DeclarationFile::Routing) + } else if [ + ".links.menu.yml", + ".links.task.yml", + ".links.action.yml", + ".links.contextual.yml", + ] + .iter() + .any(|suffix| name.ends_with(suffix)) + { + Some(DeclarationFile::Links) + } else { + None + } } /// The folders of a web root whose YAML Drupal never loads as it stands: @@ -81,15 +114,114 @@ pub(crate) fn is_entity_query(class: &str, method: &str) -> bool { || method.eq_ignore_ascii_case("entityQueryAggregate")) } +/// The argument a static call of `method` on `class`, a resolved class +/// name, takes a route name in: the first of `Url::fromRoute()`, the second +/// of `Link::createFromRoute()`. +pub(crate) fn static_route_argument(class: &str, method: &str) -> Option { + let class = class.strip_prefix('\\').unwrap_or(class); + if class.eq_ignore_ascii_case("Drupal\\Core\\Url") && method.eq_ignore_ascii_case("fromRoute") { + Some(0) + } else if class.eq_ignore_ascii_case("Drupal\\Core\\Link") + && method.eq_ignore_ascii_case("createFromRoute") + { + Some(1) + } else { + None + } +} + +/// The calls on a typed receiver that take a route's parameters after the +/// route name they take first, with the receiver that confirms them. Each +/// one's route name is read through the receiver +/// `symbol_map::extraction::symfony_receiver_method` gives it. +const ROUTE_PARAMETER_CALLS: &[(&str, ReceiverClass, &str)] = &[ + ( + "setRedirect", + ReceiverClass::FormRedirectParameters, + "route_parameters", + ), + ( + "redirect", + ReceiverClass::RouteRedirectParameters, + "route_parameters", + ), + ( + "generateFromRoute", + ReceiverClass::UrlGeneratorParameters, + "parameters", + ), + ( + "generate", + ReceiverClass::UrlGeneratorParameters, + "parameters", + ), + ( + "checkNamedRoute", + ReceiverClass::AccessCheckParameters, + "parameters", + ), +]; + +/// The receiver that confirms the route parameters a call of `method` +/// passes second, and the name of their argument, when the call takes +/// them. +pub(crate) fn route_parameter_call(method: &str) -> Option<(ReceiverClass, &'static str)> { + ROUTE_PARAMETER_CALLS + .iter() + .find(|(name, _, _)| method.eq_ignore_ascii_case(name)) + .map(|(_, receiver, parameter)| (*receiver, *parameter)) +} + +/// Whether `class`, a resolved class name, is `Drupal\Core\Url`, whose +/// constructor takes a route name first. +pub(crate) fn is_url_class(class: &str) -> bool { + class + .strip_prefix('\\') + .unwrap_or(class) + .eq_ignore_ascii_case("Drupal\\Core\\Url") +} + +/// Whether the route name `name` has the shape `pattern`, in which each `*` +/// stands for any run of characters. +pub(crate) fn matches_route_pattern(pattern: &str, name: &str) -> bool { + let mut parts = pattern.split('*'); + let first = parts.next().unwrap_or_default(); + let Some(mut rest) = name.strip_prefix(first) else { + return false; + }; + let parts: Vec<&str> = parts.collect(); + let Some((last, middle)) = parts.split_last() else { + return rest.is_empty(); + }; + for part in middle { + match rest.find(part) { + Some(at) => rest = &rest[at + part.len()..], + None => return false, + } + } + rest.len() >= last.len() && rest.ends_with(last) +} + /// Whether `path` is a file Drupal reads declarations from. pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) - .is_some_and(is_services_file_name) + .is_some_and(|name| declaration_file_kind(name).is_some()) && !is_test_fixture(path, web_root) && !is_unloaded(path, web_root) } +/// Whether `name` has the shape of the route Views builds for a display of +/// a view: `view..`, neither ID holding a dot. +pub(crate) fn is_views_route_name(name: &str) -> bool { + let mut parts = name.split('.'); + parts.next() == Some("view") + && matches!( + (parts.next(), parts.next(), parts.next()), + (Some(view), Some(display), None) if !view.is_empty() && !display.is_empty() + ) +} + /// Whether `path` lies under a `tests` folder of the web root: it belongs to /// a test module, which no real site enables. Roughly half of core's /// declaration files are. @@ -200,6 +332,68 @@ impl Backend { found } + /// The shapes of the route names the project's route builders give the + /// routes they build from parts, sorted. + pub(crate) fn drupal_route_patterns(&self) -> Vec { + let mut patterns = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + patterns.extend(map.drupal_route_patterns.iter().cloned()); + }); + patterns.sort_unstable(); + patterns.dedup(); + patterns + } + + /// The `Class::method` and `Class` names Drupal's attribute route + /// discovery gives the project's controller routes, sorted. + pub(crate) fn drupal_route_aliases(&self) -> Vec { + let mut aliases = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + aliases.extend(map.drupal_route_aliases.iter().cloned()); + }); + aliases.sort_unstable(); + aliases.dedup(); + aliases + } + + /// The paths of the routes the project names `name`, sorted. + pub(crate) fn drupal_route_paths(&self, name: &str) -> Vec { + let mut paths = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + paths.extend( + map.drupal_routes + .iter() + .filter(|route| route.name == name) + .map(|route| route.path.clone()), + ); + }); + paths.sort_unstable(); + paths.dedup(); + paths + } + + /// Whether `uri` is code a Drupal site never runs against its own + /// declarations: a file under a `tests` folder of one of its web roots, + /// whose route names are a test module's or invented for a mock, or an + /// extension's `*.api.php`, which documents hooks with example code. + pub(crate) fn is_drupal_example_or_test_code(&self, uri: &str) -> bool { + if !self.is_drupal_project() { + return false; + } + let Some(path) = crate::util::uri_to_path(uri) else { + return false; + }; + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".api.php")) + || self + .drupal + .web_roots + .read() + .iter() + .any(|root| path.starts_with(root) && is_test_fixture(&path, root)) + } + /// Whether `uri` belongs to a test module of a Drupal project and is not /// open: what it declares is left out, since no real site enables it. pub(crate) fn is_closed_drupal_test_fixture(&self, uri: &str) -> bool { @@ -217,30 +411,39 @@ impl Backend { && !self.open_files.read().contains_key(uri) } - /// Whether `uri` is a file a Drupal project loads services from: a - /// services file inside one of its web roots, outside test modules - /// unless it is open. + /// Whether `uri` is inside one of the web roots of the workspace's + /// Drupal projects, where Drupal's own reading of a file wins over + /// Symfony's. + pub(crate) fn is_in_drupal_web_root(&self, uri: &str) -> bool { + self.is_drupal_project() + && crate::util::uri_to_path(uri).is_some_and(|path| { + self.drupal + .web_roots + .read() + .iter() + .any(|root| path.starts_with(root)) + }) + } + + /// The kind of declaration file `uri` is in a Drupal project: one + /// inside one of its web roots, outside test modules unless it is open. /// /// Other YAML files in a Drupal project have a `services:` key with /// another meaning, or none at all. - pub(crate) fn is_drupal_services_uri(&self, uri: &str) -> bool { - if !self.is_drupal_project() || !is_services_file_name(uri) { - return false; + pub(crate) fn drupal_declaration_file(&self, uri: &str) -> Option { + if !self.is_drupal_project() { + return None; } - let Some(path) = crate::util::uri_to_path(uri) else { - return false; - }; - let Some((in_fixture, unloaded)) = self + let kind = declaration_file_kind(uri)?; + let path = crate::util::uri_to_path(uri)?; + let (in_fixture, unloaded) = self .drupal .web_roots .read() .iter() .find(|root| path.starts_with(root)) - .map(|root| (is_test_fixture(&path, root), is_unloaded(&path, root))) - else { - return false; - }; - !unloaded && (!in_fixture || self.open_files.read().contains_key(uri)) + .map(|root| (is_test_fixture(&path, root), is_unloaded(&path, root)))?; + (!unloaded && (!in_fixture || self.open_files.read().contains_key(uri))).then_some(kind) } } diff --git a/src/drupal/routing.rs b/src/drupal/routing.rs new file mode 100644 index 000000000..8be113bb3 --- /dev/null +++ b/src/drupal/routing.rs @@ -0,0 +1,560 @@ +//! What a Drupal routing file declares besides route names: the +//! parameters each route's path declares, what Drupal upcasts them by (see +//! [`crate::symbol_map::DrupalRouteSite`]), and the names the route +//! defaults read: the entity type an `_entity_form`, `_entity_list`, or +//! `_entity_view` works on, and the service a `service:method` controller +//! or title callback calls. +//! +//! A parameter only means something under its route, so its span's key +//! names the route as well as the parameter (see [`route_parameter_key`]). + +use crate::symbol_map::{ + DeclaredParameter, DrupalRouteSite, StringKeyKind, SymbolKind, SymbolSpan, +}; + +/// The key of a [`StringKeyKind::DrupalRouteParameter`] span: the +/// parameter `name` under the route `route`. +pub(crate) fn route_parameter_key(route: &str, name: &str) -> String { + format!("{route}#{name}") +} + +/// The route and the parameter name of a route parameter's key. +pub(crate) fn split_route_parameter_key(key: &str) -> (&str, &str) { + key.rsplit_once('#').unwrap_or(("", key)) +} + +/// The byte range of the name of each placeholder of a route path, the way +/// Symfony's route compiler, which Drupal's extends, reads them: a name of +/// word characters that does not start with a digit, after an optional `!` +/// and before an optional inline requirement (`{id<\d+>}`) or default +/// (`{page?1}`). +pub(crate) fn path_parameters(path: &str) -> Vec<(usize, usize)> { + let bytes = path.as_bytes(); + let mut found = Vec::new(); + let mut cursor = 0; + while let Some(relative) = memchr::memchr(b'{', &bytes[cursor..]) { + let mut start = cursor + relative + 1; + if bytes.get(start) == Some(&b'!') { + start += 1; + } + let mut end = start; + while bytes + .get(end) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte >= 0x80) + { + end += 1; + } + cursor = end; + if end == start || bytes[start].is_ascii_digit() { + continue; + } + let mut after = end; + if bytes.get(after) == Some(&b'<') { + let Some(close) = memchr::memchr(b'>', &bytes[after..]) else { + continue; + }; + after += close + 1; + } + if bytes.get(after) == Some(&b'?') { + let Some(close) = memchr::memchr(b'}', &bytes[after..]) else { + continue; + }; + after += close; + } + if bytes.get(after) == Some(&b'}') { + found.push((start, end)); + cursor = after + 1; + } + } + found +} + +/// The names of the placeholders a route path declares, in order. +pub(crate) fn path_parameter_names(path: &str) -> impl Iterator { + path_parameters(path) + .into_iter() + .map(move |(start, end)| &path[start..end]) +} + +/// The spans of the route parameters a Twig template's `path()` and +/// `url()` pass: the keys of the hash after each route name `routes` +/// holds, as `{{ path('entity.node.canonical', {node: 1, 'mode': 'x'}) }}` +/// writes them. +pub(crate) fn twig_parameter_spans(content: &str, routes: &[SymbolSpan]) -> Vec { + let bytes = content.as_bytes(); + let skip_space = |mut at: usize| { + while bytes.get(at).is_some_and(u8::is_ascii_whitespace) { + at += 1; + } + at + }; + let mut spans = Vec::new(); + for route in routes { + let SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key: name, + .. + } = &route.kind + else { + continue; + }; + // Past the route name's closing quote, a comma and a hash. + let comma = skip_space(route.end as usize + 1); + if bytes.get(comma) != Some(&b',') { + continue; + } + let open = skip_space(comma + 1); + if bytes.get(open) != Some(&b'{') { + continue; + } + let mut at = open + 1; + let mut depth = 1usize; + let mut wants_key = true; + while at < bytes.len() && depth > 0 { + if depth == 1 && wants_key { + at = skip_space(at); + let (start, end) = match bytes.get(at) { + Some(quote @ (b'\'' | b'"')) => { + let Some(len) = memchr::memchr(*quote, &bytes[at + 1..]) else { + break; + }; + (at + 1, at + 1 + len) + } + Some(byte) if byte.is_ascii_alphabetic() || *byte == b'_' => { + let len = bytes[at..] + .iter() + .take_while(|byte| byte.is_ascii_alphanumeric() || **byte == b'_') + .count(); + (at, at + len) + } + // A computed key, or the end of the hash. + _ => break, + }; + let after = if bytes[at] == b'\'' || bytes[at] == b'"' { + end + 1 + } else { + end + }; + let colon = skip_space(after); + if bytes.get(colon) != Some(&b':') || start == end { + break; + } + spans.push(SymbolSpan { + start: start as u32, + end: end as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: route_parameter_key(name, &content[start..end]), + is_write: false, + is_optional: false, + }, + }); + wants_key = false; + at = colon + 1; + continue; + } + match bytes[at] { + quote @ (b'\'' | b'"') => { + at = memchr::memchr(quote, &bytes[at + 1..]) + .map_or(bytes.len(), |len| at + len + 2); + continue; + } + b'{' | b'[' | b'(' => depth += 1, + b'}' | b']' | b')' => depth -= 1, + b',' if depth == 1 => wants_key = true, + _ => {} + } + at += 1; + } + } + spans +} + +/// The method a `_controller`, `_form`, or `_title_callback` value names, +/// as [`DrupalRouteSite::controller`] spells it: `Class::method` as it is, +/// and a class alone as its `__invoke()`. A `service:method` is `None`. +pub(crate) fn handler_of(value: &str) -> Option { + let value = value.trim_start_matches('\\'); + if value.is_empty() { + return None; + } + if value.contains("::") { + Some(value.to_string()) + } else if value.contains(':') { + None + } else { + Some(format!("{value}::__invoke")) + } +} + +/// One `key: value` line of a routing or links file. +pub(super) struct Line<'a> { + pub(super) indent: usize, + pub(super) key: &'a str, + /// Absolute offset of [`Self::key`], quotes excluded. + pub(super) key_start: usize, + pub(super) value: &'a str, + /// Absolute offset of [`Self::value`]. + pub(super) value_start: usize, +} + +/// The `key: value` lines of a YAML file, comments and list items left +/// out. +pub(super) fn lines(content: &str) -> Vec> { + let mut lines = Vec::new(); + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + let indent = crate::framework::leading_spaces(semantic); + let text = &semantic[indent..]; + if text.is_empty() || text.starts_with('-') { + continue; + } + let Some(colon) = text.find(':') else { + continue; + }; + let (key, (quote, _)) = crate::framework::strip_yaml_quotes(text[..colon].trim_end()); + lines.push(Line { + indent, + key, + key_start: line_start + indent + quote, + value: &text[colon + 1..], + value_start: line_start + indent + colon + 1, + }); + } + lines +} + +/// The entries of the mapping `block` holds, each with the lines nested +/// under it. +pub(super) fn entries<'b, 'a>(block: &'b [Line<'a>]) -> Vec<(&'b Line<'a>, &'b [Line<'a>])> { + let Some(first) = block.first() else { + return Vec::new(); + }; + let indent = first.indent; + let mut found = Vec::new(); + for (at, line) in block.iter().enumerate() { + if line.indent != indent { + continue; + } + let nested = block[at + 1..] + .iter() + .take_while(|nested| nested.indent > indent) + .count(); + found.push((line, &block[at + 1..at + 1 + nested])); + } + found +} + +/// A YAML scalar written at `start`, without its quotes and the space +/// around it, with the absolute offset of its text. +pub(super) fn scalar(value: &str, start: usize) -> (String, usize) { + let trimmed = value.trim_start(); + let leading = value.len() - trimmed.len(); + let (text, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); + // A double-quoted scalar escapes its backslashes. + let text = if trimmed.starts_with('"') { + text.replace("\\\\", "\\") + } else { + text.to_string() + }; + (text, start + leading + quote) +} + +/// The routes a routing file declares, in source order, and the spans of +/// the parameters their paths declare. +/// +/// A route is a top-level key whose mapping has a `path:`; the +/// `route_callbacks:` list names the classes that build more. +pub(crate) fn routing_routes(content: &str) -> (Vec, Vec) { + let lines = lines(content); + let mut spans = Vec::new(); + let mut routes = Vec::new(); + for (index, entry) in lines.iter().enumerate() { + if entry.indent != 0 + || entry.key == "route_callbacks" + || !crate::symfony::routes::is_drupal_route_name(entry.key) + { + continue; + } + let block_len = lines[index + 1..] + .iter() + .take_while(|line| line.indent > 0) + .count(); + let block = &lines[index + 1..index + 1 + block_len]; + let mut path = None; + let mut defaults: &[Line<'_>] = &[]; + let mut options: &[Line<'_>] = &[]; + for (child, nested) in entries(block) { + match child.key { + "path" => path = Some(scalar(child.value, child.value_start)), + "defaults" => defaults = nested, + "options" => options = nested, + _ => {} + } + } + let Some((path, path_start)) = path else { + continue; + }; + for (start, end) in path_parameters(&path) { + spans.push(SymbolSpan { + start: (path_start + start) as u32, + end: (path_start + end) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: route_parameter_key(entry.key, &path[start..end]), + is_write: true, + is_optional: false, + }, + }); + } + + let mut controller = None; + let mut form = None; + let mut title_callback = None; + let mut entity_view = None; + let mut entity_form = None; + let mut values = Vec::new(); + for (default, _) in entries(defaults) { + let (value, _) = scalar(default.value, default.value_start); + if !default.key.starts_with('_') { + // Read as written: a quoted `'~'` or `''` is a string. + values.push((default.key.to_string(), is_yaml_null(default.value.trim()))); + } + match default.key { + "_controller" => controller = handler_of(&value), + "_form" => { + form = (!value.is_empty() && !value.contains(':')) + .then(|| format!("{}::buildForm", value.trim_start_matches('\\'))); + } + "_title_callback" => title_callback = handler_of(&value), + "_entity_view" => entity_view = entity_type_of(&value), + "_entity_form" => entity_form = entity_type_of(&value), + _ => {} + } + } + let mut parameters = Vec::new(); + for (option, nested) in entries(options) { + if option.key != "parameters" { + continue; + } + for (parameter, declared) in entries(nested) { + let settings = entries(declared); + parameters.push(DeclaredParameter { + name: parameter.key.to_string(), + ty: settings + .iter() + .find(|(setting, _)| setting.key == "type") + .map(|(ty, _)| scalar(ty.value, ty.value_start).0), + converted_otherwise: settings + .iter() + .any(|(setting, _)| matches!(setting.key, "converter" | "tempstore")), + }); + } + } + routes.push(DrupalRouteSite { + name: entry.key.to_string(), + path, + controller: form.or(controller), + title_callback, + parameters, + defaults: values, + entity_type: entity_view.or(entity_form), + }); + } + (spans, routes) +} + +/// Whether a YAML scalar, as written with its quotes, is `null`. +fn is_yaml_null(value: &str) -> bool { + matches!(value, "" | "~" | "null" | "Null" | "NULL") +} + +/// The entity type an `_entity_view` or `_entity_form` value names: the +/// part before its first `.`. +pub(crate) fn entity_type_of(value: &str) -> Option { + value + .split('.') + .next() + .filter(|id| is_plain_name(id)) + .map(str::to_string) +} + +/// The entity type and service spans of a routing file, in source order. +pub(crate) fn routing_value_spans(content: &str) -> Vec { + let mut spans = Vec::new(); + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + let indent = crate::framework::leading_spaces(semantic); + if indent == 0 { + continue; + } + let text = &semantic[indent..]; + let Some(colon) = text.find(':') else { + continue; + }; + let key = text[..colon].trim_end(); + let raw = &text[colon + 1..]; + let trimmed = raw.trim_start(); + let (value, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); + let value_start = line_start + indent + colon + 1 + (raw.len() - trimmed.len()) + quote; + let found = match key { + // `node.edit` is the form `edit` of the entity type `node`, and + // `node.full` its view mode `full`. + "_entity_form" | "_entity_view" | "_entity_list" => value + .split('.') + .next() + .map(|id| (StringKeyKind::DrupalEntityType, id)), + // `acme.greeter:greet` calls `greet()` on the service + // `acme.greeter`; a `Class::method` names a class instead. + "_controller" | "_title_callback" if !value.contains("::") => value + .split_once(':') + .map(|(service, _)| (StringKeyKind::ServiceId, service)), + _ => None, + }; + let Some((kind, name)) = found.filter(|(_, name)| is_plain_name(name)) else { + continue; + }; + spans.push(SymbolSpan { + start: value_start as u32, + end: (value_start + name.len()) as u32, + kind: SymbolKind::StringKey { + kind, + key: name.to_string(), + is_write: false, + is_optional: false, + }, + }); + } + spans +} + +/// Whether `name` can be an entity type ID or a service ID. +fn is_plain_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entity_and_service_defaults_read_their_names() { + let yaml = "a:\n path: '/a'\n defaults:\n _entity_form: 'node.edit'\n _entity_view: node.full\n _entity_list: node_type\n _controller: acme.greeter:greet\n _title_callback: '\\Drupal\\acme\\Title::get'\n requirements:\n _entity_access: node.update\n"; + let found: Vec<(&str, StringKeyKind)> = routing_value_spans(yaml) + .iter() + .map(|span| { + let SymbolKind::StringKey { kind, .. } = span.kind else { + unreachable!() + }; + (&yaml[span.start as usize..span.end as usize], kind) + }) + .collect(); + assert_eq!( + found, + [ + ("node", StringKeyKind::DrupalEntityType), + ("node", StringKeyKind::DrupalEntityType), + ("node_type", StringKeyKind::DrupalEntityType), + ("acme.greeter", StringKeyKind::ServiceId), + ] + ); + } + + #[test] + fn twig_hashes_pass_the_parameters_of_their_route() { + let twig = "{{ path('acme.note', {acme_note: note.id, 'mode': {a: 1}|first, other: x(1, 2)}) }}\n{{ url('acme.note') }}{{ path('acme.note', vars) }}"; + let routes = crate::symfony::routes::twig_route_spans(twig, StringKeyKind::DrupalRoute); + let found: Vec<(&str, String)> = twig_parameter_spans(twig, &routes) + .iter() + .map(|span| { + let SymbolKind::StringKey { key, .. } = &span.kind else { + unreachable!() + }; + (&twig[span.start as usize..span.end as usize], key.clone()) + }) + .collect(); + assert_eq!( + found, + [ + ("acme_note", "acme.note#acme_note".to_string()), + ("mode", "acme.note#mode".to_string()), + ("other", "acme.note#other".to_string()), + ] + ); + } + + fn names(path: &str) -> Vec<&str> { + path_parameter_names(path).collect() + } + + #[test] + fn path_parameters_are_read_the_way_symfony_compiles_them() { + assert_eq!( + names("/node/{node}/revisions/{node_revision}/view"), + ["node", "node_revision"] + ); + assert_eq!( + names("/a/{id<\\d+>}/{page?1}/{!slug}"), + ["id", "page", "slug"] + ); + assert!(names("/a/{1x}/{}/{ x }/{open").is_empty()); + } + + #[test] + fn routes_keep_their_path_handlers_and_upcasting() { + let yaml = "acme.note:\n path: '/note/{acme_note}/{mode}'\n defaults:\n _controller: '\\Drupal\\acme\\Controller\\NoteController::view'\n _title_callback: 'Drupal\\acme\\Controller\\NoteController::title'\n _entity_view: 'acme_note.full'\n mode: ~\n options:\n parameters:\n acme_note:\n type: entity:acme_note\n mode:\n converter: x\nacme.form:\n path: /form/{node}\n defaults:\n _controller: 'acme.runner:run'\n _form: '\\Drupal\\acme\\Form\\NoteForm'\nacme.invoke:\n path: /invoke\n defaults:\n _controller: \"\\\\Drupal\\\\acme\\\\Controller\\\\Invoked\"\n _title_callback: acme.titles:title\nnot_a_route:\n defaults: {}\nroute_callbacks:\n - '\\Drupal\\acme\\Routing\\Routes::routes'\n"; + let (spans, routes) = routing_routes(yaml); + let declared: Vec<(&str, String)> = spans + .iter() + .map(|span| { + let SymbolKind::StringKey { key, is_write, .. } = &span.kind else { + unreachable!() + }; + assert!(is_write); + (&yaml[span.start as usize..span.end as usize], key.clone()) + }) + .collect(); + assert_eq!( + declared, + [ + ("acme_note", "acme.note#acme_note".to_string()), + ("mode", "acme.note#mode".to_string()), + ("node", "acme.form#node".to_string()), + ] + ); + let note = DrupalRouteSite { + name: "acme.note".to_string(), + path: "/note/{acme_note}/{mode}".to_string(), + controller: Some("Drupal\\acme\\Controller\\NoteController::view".to_string()), + title_callback: Some("Drupal\\acme\\Controller\\NoteController::title".to_string()), + parameters: vec![ + DeclaredParameter { + name: "acme_note".to_string(), + ty: Some("entity:acme_note".to_string()), + converted_otherwise: false, + }, + DeclaredParameter { + name: "mode".to_string(), + ty: None, + converted_otherwise: true, + }, + ], + defaults: vec![("mode".to_string(), true)], + entity_type: Some("acme_note".to_string()), + }; + assert_eq!(routes[0], note); + assert_eq!( + routes[1].controller.as_deref(), + Some("Drupal\\acme\\Form\\NoteForm::buildForm") + ); + assert_eq!( + routes[2].controller.as_deref(), + Some("Drupal\\acme\\Controller\\Invoked::__invoke") + ); + assert_eq!(routes[2].title_callback, None); + assert_eq!(routes.len(), 3); + } +} diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index 937a12e91..9d3da8564 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -67,6 +67,82 @@ fn services_files_in_the_web_root_declare_services_and_parameters() { assert_eq!(classes, vec!["Drupal\\acme\\Greeter".to_string()]); } +#[test] +fn a_routing_file_under_a_config_directory_is_drupals_in_a_symfony_site() { + // Core's `config` module puts a `config` segment on the way to its + // routing file, which a Symfony site reads as container configuration. + let (backend, dir) = drupal_backend(&[]); + backend.set_is_symfony(true); + backend.update_resource_symbol_index( + &uri(dir.path(), "web/core/modules/config/config.routing.yml"), + "config.sync:\n path: '/admin/config/development/configuration'\n", + ); + assert_eq!( + backend.symfony_declared_names(StringKeyKind::DrupalRoute), + vec!["config.sync".to_string()] + ); +} + +/// The route names `content`, published as the template at `path`, reads, +/// with the kind of route each is read as. +fn template_routes( + backend: &crate::Backend, + root: &Path, + path: &str, + content: &str, +) -> Vec<(StringKeyKind, String)> { + let template = uri(root, path); + backend.update_resource_symbol_index(&template, content); + let map = backend + .symbol_map_for(&template) + .expect("the template's map"); + map.spans + .iter() + .filter_map(|span| match &span.kind { + crate::symbol_map::SymbolKind::StringKey { kind, key, .. } + if matches!( + kind, + StringKeyKind::DrupalRoute | StringKeyKind::SymfonyRoute + ) => + { + Some((*kind, key.clone())) + } + _ => None, + }) + .collect() +} + +#[test] +fn a_drupal_sites_templates_name_drupal_routes_in_a_symfony_project() { + let (backend, dir) = drupal_backend(&[]); + backend.set_is_symfony(true); + assert_eq!( + template_routes( + &backend, + dir.path(), + "web/modules/custom/acme/templates/acme.html.twig", + "{{ path('acme.greeting') }}\n", + ), + vec![(StringKeyKind::DrupalRoute, "acme.greeting".to_string())] + ); +} + +#[test] +fn a_template_outside_the_web_root_names_symfony_routes() { + // A Symfony application beside the Drupal site, in the same workspace. + let (backend, dir) = drupal_backend(&[]); + backend.set_is_symfony(true); + assert_eq!( + template_routes( + &backend, + dir.path(), + "app/templates/base.html.twig", + "{{ path('blog_index') }}\n", + ), + vec![(StringKeyKind::SymfonyRoute, "blog_index".to_string())] + ); +} + #[test] fn other_yaml_is_not_read_as_services() { let (backend, _dir) = drupal_backend(&[ @@ -245,3 +321,70 @@ fn a_namespace_a_vendor_package_shares_is_renamed_outside_drupal() { assert!(edit.contains("src/Shared/Mine.php"), "{edit}"); assert!(!edit.contains("vendor/acme"), "{edit}"); } + +#[test] +fn route_patterns_match_any_run_for_each_star() { + use super::matches_route_pattern; + assert!(matches_route_pattern( + "search.view_*", + "search.view_node_search" + )); + assert!(matches_route_pattern( + "*.bc", + "entity.block_content.edit_form.bc" + )); + assert!(matches_route_pattern( + "layout_builder.*.view", + "layout_builder.overrides.node.view" + )); + assert!(matches_route_pattern("acme.*", "acme.")); + assert!(!matches_route_pattern("search.view_*", "search.help_x")); + assert!(!matches_route_pattern( + "layout_builder.*.view", + "layout_builder.overrides.node.edit" + )); + assert!(!matches_route_pattern("a*a", "a")); + assert!(matches_route_pattern("exact", "exact")); + assert!(!matches_route_pattern("exact", "exactly")); +} + +/// The key of the hash a Drupal template's `path()` passes is a parameter +/// of the route, never a Twig variable, also in a project that reads +/// Twig's variables; the value after it stays a variable. +#[test] +fn a_path_hash_key_is_a_route_parameter_not_a_variable() { + let (backend, dir) = drupal_backend(&[]); + backend.set_is_twig(true); + let content = "{{ path('entity.node.canonical', {node: node.id}) }}\n"; + let template = uri( + dir.path(), + "web/modules/custom/acme/templates/acme.html.twig", + ); + backend.update_resource_symbol_index(&template, content); + let map = backend + .symbol_map_for(&template) + .expect("the template's map"); + let kinds_at = |offset: usize| -> Vec<(StringKeyKind, String)> { + map.spans + .iter() + .filter(|span| span.start as usize == offset) + .filter_map(|span| match &span.kind { + crate::symbol_map::SymbolKind::StringKey { kind, key, .. } => { + Some((*kind, key.clone())) + } + _ => None, + }) + .collect() + }; + assert_eq!( + kinds_at(content.find("node:").unwrap()), + [( + StringKeyKind::DrupalRouteParameter, + "entity.node.canonical#node".to_string() + )] + ); + assert_eq!( + kinds_at(content.find("node.id").unwrap()), + [(StringKeyKind::TwigVariable, "node".to_string())] + ); +} diff --git a/src/drupal/upcasting.rs b/src/drupal/upcasting.rs new file mode 100644 index 000000000..d5ebdddb5 --- /dev/null +++ b/src/drupal/upcasting.rs @@ -0,0 +1,289 @@ +//! The entities Drupal upcasts route parameters to. +//! +//! Drupal's entity converter loads the entity a route parameter names +//! before the controller runs, for the parameters core's +//! `EntityResolverManager::setRouteOptions()` gives an `entity:` +//! type: the ones the route's `options.parameters` declare; failing that, +//! the controller's parameters that are named after an entity type and +//! type-hinted with its class or a class or interface it extends +//! (`NodeInterface $node`); and when that finds none, the parameter named +//! after the entity type an `_entity_view` or `_entity_form` default names. +//! A parameter is only converted when the route gives it a value, by its +//! path or a default, and only by the entity converter when its definition +//! names no converter or tempstore of its own. +//! +//! The routes are looked up by the method they dispatch to, in an index +//! read once until a file that declares routes changes (see +//! [`Backend::note_drupal_routes`]). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crate::Backend; +use crate::atom::Atom; +use crate::class_lookup::{Fqn, ResolvedClassLoader}; +use crate::php_type::{PhpType, TypeKind}; +use crate::symbol_map::{DrupalRouteSite, SymbolMap}; + +/// The project's routes, by the methods they dispatch to. +#[derive(Default)] +pub(crate) struct RouteIndex { + /// Each route whose controller or title callback is the method, + /// keyed by it as `Class::method`, lowercased. + by_handler: HashMap>>, + /// The files the routes were read from. + uris: HashSet, +} + +/// The class a native type hint names, the way Drupal's +/// `Reflection::getParameterClassName()` reads it: a single class, which +/// may be nullable, but not a union or a builtin type. +pub(crate) fn hinted_class(hint: &PhpType) -> Option<&str> { + let named = match hint.kind() { + TypeKind::Nullable(inner) => inner, + TypeKind::Union(members) if members.len() == 2 && members.iter().any(PhpType::is_null) => { + members.iter().find(|member| !member.is_null())? + } + _ => hint, + }; + named.class_name().map(|name| name.trim_start_matches('\\')) +} + +/// The entity type a declared parameter type converts to: the one an +/// `entity:` or `entity_revision:` names, written out. +fn entity_type_of_parameter_type(ty: &str) -> Option<&str> { + ty.strip_prefix("entity:") + .or_else(|| ty.strip_prefix("entity_revision:")) + .filter(|entity_type| !entity_type.is_empty() && !entity_type.contains('{')) +} + +impl Backend { + /// The routes whose controller or title callback is `class::method`. + pub(crate) fn drupal_handler_routes( + &self, + class: &str, + method: &str, + ) -> Vec> { + let index = self.drupal_route_index(); + if index.by_handler.is_empty() { + return Vec::new(); + } + let mut key = class.trim_start_matches('\\').to_ascii_lowercase(); + key.push_str("::"); + key.push_str(&method.to_ascii_lowercase()); + index.by_handler.get(&key).cloned().unwrap_or_default() + } + + /// What each parameter of `class::method` receives that every route + /// dispatching to the method, or naming it its title callback, upcasts: + /// the parameter's name without its `$`, the entity class, and whether a + /// route's default leaves it `NULL`. Empty for a method no route + /// reaches. + pub(crate) fn drupal_upcast_parameters( + &self, + class: &str, + method: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec<(String, Atom, bool)> { + if !self.is_drupal_project() { + return Vec::new(); + } + let routes = self.drupal_handler_routes(class, method); + let mut upcast: Option> = None; + for route in &routes { + let found = self.drupal_route_upcasts(route, class_loader); + upcast = Some(match upcast { + None => found, + Some(kept) => kept + .into_iter() + .filter(|parameter| found.contains(parameter)) + .collect(), + }); + } + upcast + .unwrap_or_default() + .into_iter() + .filter_map(|(parameter, entity_type)| { + let (class, _) = self.drupal_entity_types(&entity_type).into_iter().next()?; + let nullable = routes + .iter() + .any(|route| route.defaults_to_null(¶meter)); + Some((parameter, class, nullable)) + }) + .collect() + } + + /// The parameters `route` upcasts, each with the entity type it is + /// converted to, by the rules of `EntityResolverManager` and + /// `ParamConverterManager`: a parameter the route gives a value, by the + /// path or a default, whose definition ends up with an `entity:` or + /// `entity_revision:` type and no converter of its own. + pub(crate) fn drupal_route_upcasts( + &self, + route: &DrupalRouteSite, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec<(String, String)> { + // A type the routing file gives a parameter stays, and a converter + // or a tempstore it names converts it some other way. + let takes_a_type = |name: &str| { + route + .declared(name) + .is_none_or(|declared| declared.ty.is_none() && !declared.converted_otherwise) + }; + let mut upcast: Vec<(String, String)> = route + .parameters + .iter() + .filter(|declared| !declared.converted_otherwise) + .filter_map(|declared| { + entity_type_of_parameter_type(declared.ty.as_deref()?) + .map(|entity_type| (declared.name.clone(), entity_type.to_string())) + }) + .collect(); + + // A parameter named after an entity type and hinted with a class its + // entity class is takes the type. + let mut reflected = false; + if let Some((class, method)) = route + .controller + .as_deref() + .and_then(|controller| controller.split_once("::")) + && let Some(controller) = class_loader(Fqn::new(class)) + && let Some(method) = controller.get_method_ci(method) + { + for parameter in method.parameters.iter() { + let name = parameter.name.trim_start_matches('$'); + if parameter + .native_type_hint + .as_ref() + .and_then(hinted_class) + .is_some_and(|hint| self.drupal_entity_class_is(name, hint, class_loader)) + { + reflected = true; + if takes_a_type(name) { + upcast.push((name.to_string(), name.to_string())); + } + } + } + } + + // Failing that, the entity an `_entity_view` or `_entity_form` names, + // unless a parameter already has its `entity:` type. + if !reflected + && let Some(entity_type) = &route.entity_type + && crate::drupal::routing::path_parameter_names(&route.path) + .any(|name| name == entity_type.as_str()) + { + let own_type = format!("entity:{entity_type}"); + if !route + .parameters + .iter() + .any(|declared| declared.ty.as_deref() == Some(own_type.as_str())) + && takes_a_type(entity_type) + && !self.drupal_entity_types(entity_type).is_empty() + { + upcast.push((entity_type.clone(), entity_type.clone())); + } + } + + upcast.retain(|(name, _)| route.passes(name)); + upcast + } + + /// Whether the entity class of the entity type `entity_type` is the + /// class `hint`, or extends or implements it. `false` for an entity + /// type the project does not declare, or whose class is not found. + pub(crate) fn drupal_entity_class_is( + &self, + entity_type: &str, + hint: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> bool { + self.drupal_entity_types(entity_type) + .first() + .and_then(|(class, _)| class_loader(Fqn::new(class))) + .is_some_and(|class| crate::class_lookup::is_subtype_of(&class, hint, class_loader)) + } + + /// The project's routes by the methods they dispatch to, read once until + /// a file that declares routes changes (see + /// [`Backend::note_drupal_routes`]). + fn drupal_route_index(&self) -> Arc { + if let Some(index) = self.drupal.route_index.read().as_ref() { + return Arc::clone(index); + } + let mut index = RouteIndex::default(); + self.visit_declaring_symbol_maps(|uri, map| { + if !map.drupal_routes.is_empty() { + index.uris.insert(uri.to_string()); + } + for route in &map.drupal_routes { + let route = Arc::new(route.clone()); + for handler in [&route.controller, &route.title_callback] + .into_iter() + .flatten() + { + index + .by_handler + .entry(handler.to_ascii_lowercase()) + .or_default() + .push(Arc::clone(&route)); + } + } + }); + let index = Arc::new(index); + *self.drupal.route_index.write() = Some(Arc::clone(&index)); + index + } + + /// Forget what [`Backend::drupal_route_index`] read when the routes of + /// `uri` may have changed: `map`, the map just published for it, declares + /// some, or the index read some from it. `None` for a file that is gone. + pub(crate) fn note_drupal_routes(&self, uri: &str, map: Option<&SymbolMap>) { + let stale = match self.drupal.route_index.read().as_ref() { + None => false, + Some(index) => { + map.is_some_and(|map| !map.drupal_routes.is_empty()) || index.uris.contains(uri) + } + }; + if stale { + *self.drupal.route_index.write() = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_hint_names_a_class_when_it_is_one_maybe_nullable() { + let named = |name: &str| PhpType::named(crate::atom::atom(name)); + assert_eq!( + hinted_class(&named("Drupal\\node\\NodeInterface")), + Some("Drupal\\node\\NodeInterface") + ); + assert_eq!( + hinted_class(&PhpType::nullable(named("Drupal\\node\\Node"))), + Some("Drupal\\node\\Node") + ); + assert_eq!(hinted_class(&named("string")), None); + assert_eq!( + hinted_class(&PhpType::union(vec![ + named("Drupal\\node\\Node"), + named("string") + ])), + None + ); + } + + #[test] + fn declared_types_convert_to_an_entity_type_written_out() { + assert_eq!(entity_type_of_parameter_type("entity:node"), Some("node")); + assert_eq!( + entity_type_of_parameter_type("entity_revision:node"), + Some("node") + ); + assert_eq!(entity_type_of_parameter_type("entity:{entity_type}"), None); + assert_eq!(entity_type_of_parameter_type("tempstore"), None); + } +} diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index ad3e89e4d..2b8e2f669 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -291,12 +291,13 @@ impl Backend { | StringKeyKind::SymfonyRoute | StringKeyKind::SymfonyTranslation | StringKeyKind::TranslationDomain - | StringKeyKind::DrupalEntityType => { + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute => { let label = match kind { StringKeyKind::ServiceId => "Service", StringKeyKind::DrupalEntityType => "Entity type", StringKeyKind::ContainerParameter => "Parameter", - StringKeyKind::SymfonyRoute => "Route", + StringKeyKind::SymfonyRoute | StringKeyKind::DrupalRoute => "Route", StringKeyKind::SymfonyTranslation => "Translation", _ => "Translation domain", }; @@ -372,6 +373,21 @@ impl Backend { }; ("Route argument", detail) } + // A parameter is shown by its name, under the paths of its + // route. + StringKeyKind::DrupalRouteParameter => { + let (route, name) = crate::drupal::routing::split_route_parameter_key(key); + shown = name; + let mut detail = format!("Route `{}`", route); + let paths = self.drupal_route_paths(route); + if paths.is_empty() { + detail.push_str("\n\nNot declared in the project's routing files"); + } + for path in paths { + detail.push_str(&format!("\n\nPath `{}`", path)); + } + ("Route parameter", detail) + } // An installed bundle's schema is not read, so a key no schema // of the project's declares is only not one of its own. StringKeyKind::SymfonyConfigKey => { diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 189bc2e95..8aa5429e5 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1402,6 +1402,33 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { sm.container_values.capacity() * size_of::(), ); sym.add(sm.entity_types.capacity() * size_of::()); + sym.add(sm.drupal_route_patterns.capacity() * size_of::()); + sym.add(sm.drupal_route_aliases.capacity() * size_of::()); + for name in sm + .drupal_route_patterns + .iter() + .chain(&sm.drupal_route_aliases) + { + sym.add(name.capacity()); + } + sym.add(sm.drupal_routes.capacity() * size_of::()); + for route in &sm.drupal_routes { + sym.add(route.name.capacity() + route.path.capacity()); + sym.add(route.controller.as_ref().map_or(0, String::capacity)); + sym.add(route.title_callback.as_ref().map_or(0, String::capacity)); + sym.add(route.entity_type.as_ref().map_or(0, String::capacity)); + sym.add( + route.parameters.capacity() * size_of::(), + ); + for parameter in &route.parameters { + sym.add(parameter.name.capacity()); + sym.add(parameter.ty.as_ref().map_or(0, String::capacity)); + } + sym.add(route.defaults.capacity() * size_of::<(String, bool)>()); + for (name, _) in &route.defaults { + sym.add(name.capacity()); + } + } sym.add(sm.routes.capacity() * size_of::()); for route in &sm.routes { sym.add(route.path.as_ref().map_or(0, String::capacity)); diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index 859d6fbcc..2194eee3b 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -256,6 +256,15 @@ impl Backend { let spans = if is_template { if self.is_twig_project() { self.twig_template_spans(uri, content, is_symfony) + } else if self.is_drupal_project() { + let mut spans = crate::symfony::routes::twig_route_spans( + content, + StringKeyKind::DrupalRoute, + ); + let parameters = crate::drupal::routing::twig_parameter_spans(content, &spans); + spans.extend(parameters); + spans.sort_by_key(|span| span.start); + spans } else { Vec::new() } @@ -274,15 +283,31 @@ impl Backend { let mut spans = Vec::new(); let mut container_values = Vec::new(); let mut route_imports = Vec::new(); + let mut drupal_routes = Vec::new(); let mut declares_twig_loader = false; // A Drupal declaration file is Drupal's, also in a Drupal site that // requires Symfony's container and has a `config` directory on the // way to it, as core's `config` module does. - if self.is_drupal_services_uri(uri) { - let (container_spans, values) = - crate::symfony::resources::container_declarations(uri, content); - spans = container_spans; - container_values = values; + if let Some(file) = self.drupal_declaration_file(uri) { + match file { + crate::drupal::DeclarationFile::Services => { + let (container_spans, values) = + crate::symfony::resources::container_declarations(uri, content); + spans = container_spans; + container_values = values; + } + crate::drupal::DeclarationFile::Routing => { + spans = crate::symfony::routes::drupal_routing_spans(content); + spans.extend(crate::drupal::routing::routing_value_spans(content)); + let (parameters, routes) = crate::drupal::routing::routing_routes(content); + spans.extend(parameters); + drupal_routes = routes; + } + crate::drupal::DeclarationFile::Links => { + spans = crate::drupal::links::links_route_spans(content); + spans.extend(crate::drupal::links::links_parameter_spans(content)); + } + } } else if is_symfony && crate::symfony::resources::is_container_config_uri(uri) { declares_twig_loader = crate::symfony::tags::tags_twig_loader(content); // A bus is declared by a key under `config/packages/`, which is @@ -367,6 +392,7 @@ impl Backend { member_access_indices, container_values, route_imports, + drupal_routes, declares_twig_loader, source_len: u32::try_from(content.len()).unwrap_or(u32::MAX), ..SymbolMap::default() @@ -376,16 +402,36 @@ impl Backend { impl Backend { /// The spans of a Twig template: the names it is loaded by, the - /// templates, blocks, filters, functions, and variables it names, and in - /// a Symfony project the route names and translation keys it reads. + /// templates, blocks, filters, functions, and variables it names, the + /// route names it reads, Drupal's inside a Drupal web root and + /// Symfony's elsewhere in a Symfony project, and in a Symfony project + /// its translation keys. fn twig_template_spans(&self, uri: &str, content: &str, is_symfony: bool) -> Vec { let roots = self.twig_roots(); let names = crate::util::uri_to_path(uri) .map(|path| roots.template_names(&path)) .unwrap_or_default(); let mut spans = crate::twig::templates::template_declaration_spans(names); + // A Drupal site that requires Symfony's container is a Symfony + // project too, but the templates in its web root name Drupal's + // routes. A Symfony application beside it keeps its own. + let route_kind = if self.is_in_drupal_web_root(uri) { + Some(StringKeyKind::DrupalRoute) + } else if is_symfony { + Some(StringKeyKind::SymfonyRoute) + } else if self.is_drupal_project() { + Some(StringKeyKind::DrupalRoute) + } else { + None + }; + if let Some(kind) = route_kind { + let routes = crate::symfony::routes::twig_route_spans(content, kind); + spans.extend(crate::drupal::routing::twig_parameter_spans( + content, &routes, + )); + spans.extend(routes); + } if is_symfony { - spans.extend(crate::symfony::routes::twig_route_spans(content)); spans.extend(crate::symfony::translations::twig_translation_spans( content, )); diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index 20e6724b5..35973ab33 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -24,6 +24,8 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC emit_twig_global_declarations(class, ctx); emit_yii_route_argument_spans(class, ctx); emit_drupal_entity_type_spans(class, ctx); + emit_drupal_route_attribute_spans(class, ctx); + emit_drupal_route_builder_spans(&class.members, class.name.span.start.offset, ctx); if let Some(ref extends) = class.extends { for ident in extends.types.iter() { @@ -197,6 +199,7 @@ pub(super) fn extract_from_trait<'a>(trait_def: &'a Trait<'a>, ctx: &mut Extract }); extract_from_attribute_lists(&trait_def.attribute_lists, ctx, 0); + emit_drupal_route_builder_spans(&trait_def.members, trait_def.name.span.start.offset, ctx); if let Some((doc_text, doc_offset)) = get_docblock_text_with_offset(ctx.trivias, ctx.content, trait_def) diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index 16ee49651..e31fb87c8 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -1,8 +1,12 @@ //! The entity types a Drupal entity class declares, by attribute or by the -//! annotation older code uses (see [`crate::drupal`]). +//! annotation older code uses, and the route names Drupal's static and +//! constructor calls read (see [`crate::drupal`]). use super::*; -use crate::symbol_map::{EntityTypeSite, StringKeyKind}; +use crate::symbol_map::extraction::symfony::{ + RouteDeclaration, RouteName, is_route_attribute, push_route_spans, route_declarations, +}; +use crate::symbol_map::{DeclaredParameter, DrupalRouteSite, EntityTypeSite, StringKeyKind}; use crate::virtual_members::laravel::helpers::resolved_string_literal; const CONTENT_STORAGE: &str = "Drupal\\Core\\Entity\\Sql\\SqlContentEntityStorage"; @@ -100,6 +104,729 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( } } +/// Record the routes the `#[Route]` attributes of a controller declare, the +/// way Drupal's attribute route discovery reads them. +/// +/// Drupal looks for them in the classes under an extension's `Controller` +/// namespace. A route on a method is named by its `name:`, after the name +/// of the class's own first `#[Route]`; one without a name is rejected. A +/// class with no routes on its methods and an `__invoke()` is a single +/// action, and its own `#[Route]`s are its routes. A method with exactly +/// one route is also reachable as `Class::method`, and a single-action +/// class with one route as `Class` and `Class::__invoke`: those aliases +/// have no text of their own, so they are kept beside the spans. +/// +/// Drupal skips an abstract class but reads the routes its subclasses +/// inherit, so an abstract controller's routes are declared where they are +/// written, under its own prefix, and without aliases, whose class name +/// would be the subclass's. +/// +/// Each placeholder of a route's path declares a parameter of the route, +/// the class route's path included, which comes before every method +/// route's. A route is kept beside the spans with the method it +/// dispatches to, for its parameters to be upcast by (see +/// [`DrupalRouteSite`]); an abstract controller's are not, since that +/// method is the subclass's. +pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( + class: &Class<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Some(fqn) = ctx + .resolved_name_at(class.name.span.start.offset) + .map(|fqn| fqn.trim_start_matches('\\')) + .filter(|fqn| is_controller_class(fqn)) + else { + return; + }; + let is_abstract = class.modifiers.contains_abstract(); + // Drupal's discovery reads attributes only, never `@Route` annotations. + let class_routes = route_declarations(&class.attribute_lists, None, class, ctx); + let class_details = route_details(&class.attribute_lists, ctx); + let prefix = match class_routes.first().map(|route| &route.name) { + Some(RouteName::Literal(name, _, _)) => name.as_str(), + _ => "", + }; + let is_named = |route: &&RouteDeclaration| matches!(route.name, RouteName::Literal(..)); + let mut spans = Vec::new(); + let mut sites = Vec::new(); + let mut aliases = Vec::new(); + let mut route_count = 0usize; + let mut any_method_route = false; + let mut invokable = false; + let mut fqcn_alias = false; + for member in class.members.iter() { + let ClassLikeMember::Method(method) = member else { + continue; + }; + let method_name = bytes_to_str(method.name.value); + let is_invoke = method_name.eq_ignore_ascii_case("__invoke"); + invokable |= is_invoke; + let routes = route_declarations(&method.attribute_lists, None, class, ctx); + let details = route_details(&method.attribute_lists, ctx); + any_method_route |= !routes.is_empty(); + let named = routes.iter().filter(is_named).count(); + route_count += named; + fqcn_alias |= is_invoke && named > 0; + if named == 1 { + aliases.push(format!("{fqn}::{method_name}")); + } + for (route, own) in routes.iter().zip(&details) { + let RouteName::Literal(name, _, _) = &route.name else { + continue; + }; + push_route_spans( + route, + prefix, + None, + StringKeyKind::DrupalRoute, + &[], + &mut spans, + ); + sites.extend(attribute_route( + &format!("{prefix}{name}"), + class_details.first(), + own, + format!("{fqn}::{method_name}"), + &mut spans, + )); + } + } + if !any_method_route && invokable && !is_abstract { + for (route, own) in class_routes.iter().zip(&class_details) { + let RouteName::Literal(name, _, _) = &route.name else { + continue; + }; + route_count += 1; + fqcn_alias = true; + push_route_spans(route, "", None, StringKeyKind::DrupalRoute, &[], &mut spans); + sites.extend(attribute_route( + name, + None, + own, + format!("{fqn}::__invoke"), + &mut spans, + )); + } + } + if fqcn_alias && route_count == 1 { + aliases.push(fqn.to_string()); + aliases.push(format!("{fqn}::__invoke")); + } + if !is_abstract { + for alias in aliases { + if !ctx.drupal_route_aliases.contains(&alias) { + ctx.drupal_route_aliases.push(alias); + } + } + ctx.drupal_routes.extend(sites); + } + ctx.spans.extend(spans); +} + +/// What a `#[Route]` gives besides its name: its path, as written with the +/// offset of its text, and the defaults and options Drupal upcasts the +/// route's parameters by. +#[derive(Default)] +struct RouteDetails { + path: Option<(String, u32)>, + /// Whether the path is written as something other than a literal. + path_unreadable: bool, + /// The parameters `options` declares, or `None` when it has no + /// `parameters`. + parameters: Option>, + /// The `defaults` keys that are not Drupal's own, with whether each is + /// `NULL`. + defaults: Vec<(String, bool)>, + title_callback: Option, + entity_view: Option, + entity_form: Option, +} + +/// The details of each `#[Route]` among `attribute_lists`, in the order +/// [`route_declarations`] reads them. +fn route_details( + attribute_lists: &Sequence<'_, AttributeList<'_>>, + ctx: &ExtractionCtx<'_>, +) -> Vec { + let content = ctx.content; + let literal = |value: &Expression<'_>| { + resolved_string_literal(value, content).map(|(text, _, _)| text.to_string()) + }; + let mut found = Vec::new(); + for attribute in attribute_lists + .iter() + .flat_map(|list| list.attributes.iter()) + { + let written = bytes_to_str(attribute.name.value()); + let resolved = ctx + .resolved_name_at(attribute.name.span().start.offset) + .unwrap_or(written); + if !is_route_attribute(resolved) { + continue; + } + let mut details = RouteDetails::default(); + for (parameter, value) in attribute_arguments( + attribute, + &["path", "name", "requirements", "options", "defaults"], + ) { + match parameter { + "path" => match resolved_string_literal(value, content) { + Some((path, start, _)) => details.path = Some((path.to_string(), start as u32)), + None => details.path_unreadable = true, + }, + "options" => { + details.parameters = array_entry(value, "parameters", content) + .map(|parameters| declared_parameters(parameters, content)); + } + "defaults" => { + let default = |key: &str| array_entry(value, key, content).and_then(literal); + details.title_callback = default("_title_callback") + .and_then(|callback| crate::drupal::routing::handler_of(&callback)); + details.entity_view = default("_entity_view") + .and_then(|view| crate::drupal::routing::entity_type_of(&view)); + details.entity_form = default("_entity_form") + .and_then(|form| crate::drupal::routing::entity_type_of(&form)); + details.defaults = default_values(value, content); + } + _ => {} + } + } + found.push(details); + } + found +} + +/// The route an attribute declares under `name`, dispatching to +/// `controller`, after the class route `globals`: the spans of the +/// parameters its path declares, pushed to `spans`, and the route, unless +/// its path is not all written out. +/// +/// Drupal puts the class route's path in front of the route's, and lets the +/// route's own defaults and options replace the class route's key by key. +fn attribute_route( + name: &str, + globals: Option<&RouteDetails>, + own: &RouteDetails, + controller: String, + spans: &mut Vec, +) -> Option { + let mut path = String::new(); + let mut readable = true; + for details in globals.into_iter().chain([own]) { + readable &= !details.path_unreadable; + let Some((written, start)) = &details.path else { + continue; + }; + for (from, to) in crate::drupal::routing::path_parameters(written) { + spans.push(SymbolSpan { + start: start + from as u32, + end: start + to as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: crate::drupal::routing::route_parameter_key(name, &written[from..to]), + is_write: true, + is_optional: false, + }, + }); + } + path.push_str(written); + } + if !readable { + return None; + } + let entity_view = own + .entity_view + .clone() + .or_else(|| globals.and_then(|globals| globals.entity_view.clone())); + let entity_form = own + .entity_form + .clone() + .or_else(|| globals.and_then(|globals| globals.entity_form.clone())); + let title_callback = own + .title_callback + .clone() + .or_else(|| globals.and_then(|globals| globals.title_callback.clone())); + let mut defaults = own.defaults.clone(); + for (name, is_null) in globals.iter().flat_map(|globals| &globals.defaults) { + if !defaults.iter().any(|(own, _)| own == name) { + defaults.push((name.clone(), *is_null)); + } + } + Some(DrupalRouteSite { + name: name.to_string(), + path, + controller: Some(controller), + title_callback, + parameters: own + .parameters + .clone() + .or_else(|| globals.and_then(|globals| globals.parameters.clone())) + .unwrap_or_default(), + defaults, + entity_type: entity_view.or(entity_form), + }) +} + +/// The value an array literal gives the string key `key`. +fn array_entry<'a, 'arena>( + array: &'a Expression<'arena>, + key: &str, + content: &str, +) -> Option<&'a Expression<'arena>> { + crate::parser::array_literal_elements(array)? + .iter() + .find_map(|element| match element { + ArrayElement::KeyValue(entry) => resolved_string_literal(entry.key, content) + .filter(|(written, _, _)| *written == key) + .map(|_| entry.value), + _ => None, + }) +} + +/// Each parameter an `options` array's `parameters` declares, with the +/// `type` it gives and whether it names its own converter. +fn declared_parameters(parameters: &Expression<'_>, content: &str) -> Vec { + let Some(entries) = crate::parser::array_literal_elements(parameters) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|element| { + let ArrayElement::KeyValue(entry) = element else { + return None; + }; + let (name, _, _) = resolved_string_literal(entry.key, content)?; + let ty = array_entry(entry.value, "type", content) + .and_then(|ty| resolved_string_literal(ty, content)) + .map(|(ty, _, _)| ty.to_string()); + let converted_otherwise = ["converter", "tempstore"] + .iter() + .any(|key| array_entry(entry.value, key, content).is_some()); + Some(DeclaredParameter { + name: name.to_string(), + ty, + converted_otherwise, + }) + }) + .collect() +} + +/// The keys of a `defaults` array that are not Drupal's own, with whether +/// each is `NULL`. +fn default_values(defaults: &Expression<'_>, content: &str) -> Vec<(String, bool)> { + let Some(entries) = crate::parser::array_literal_elements(defaults) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|element| { + let ArrayElement::KeyValue(entry) = element else { + return None; + }; + let (name, _, _) = resolved_string_literal(entry.key, content)?; + (!name.starts_with('_')).then(|| { + ( + name.to_string(), + matches!(entry.value, Expression::Literal(Literal::Null(_))), + ) + }) + }) + .collect() +} + +/// Whether `fqn` is a class under an extension's (or core's) `Controller` +/// namespace: `Drupal\\Controller\…`. +fn is_controller_class(fqn: &str) -> bool { + let mut segments = fqn.trim_start_matches('\\').split('\\'); + segments.next() == Some("Drupal") + && segments.next().is_some() + && segments.next() == Some("Controller") + && segments.next().is_some() +} + +/// What a route builder names the route it adds: a name written out, or +/// the shape of one built from parts. +enum BuiltName { + Literal(String, u32, u32), + Pattern(String), +} + +/// Record the routes a route builder adds: a class or trait whose file +/// uses Symfony's routing component, as the classes a routing file's +/// `route_callbacks` name, route subscribers, entity route providers, and +/// the traits they share do, or core's entity routing, as an entity route +/// provider that extends core's does. `$collection->add(NAME, $route)`, +/// `$routes[NAME] = new Route(…)` (or a variable holding one), and an +/// array literal's `NAME => new Route(…)` add the route `NAME`: written +/// out, it is declared; built from parts, its shape is kept as a pattern. +/// A shape that does not start with written-out text stands for any route +/// under the extension's own prefix, which is where Drupal's own builders +/// put such routes. +pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( + members: &Sequence<'_, ClassLikeMember<'_>>, + name_offset: u32, + ctx: &mut ExtractionCtx<'_>, +) { + // An entity route provider that extends core's renames the routes it + // inherits with `$routes->add('node.add', $route)`, and needs no + // Symfony import to do it. + if !ctx.drupal + || !(ctx.content.contains("Symfony\\Component\\Routing\\") + || ctx.content.contains("Drupal\\Core\\Entity\\Routing\\")) + { + return; + } + let mut found = BuilderSites::default(); + for member in members.iter() { + if let ClassLikeMember::Method(method) = member { + mago_syntax::walker::Walker::walk_method(&BuilderWalker, method, &mut found); + } + } + let is_route_class = |class: &Expression<'_>| { + let Expression::Identifier(identifier) = class else { + return false; + }; + ctx.resolved_name_at(identifier.span().start.offset) + .unwrap_or_else(|| bytes_to_str(identifier.value())) + .trim_start_matches('\\') + .eq_ignore_ascii_case("Symfony\\Component\\Routing\\Route") + }; + let route_variables: Vec<&[u8]> = found + .route_variables + .iter() + .filter(|(_, class)| is_route_class(class)) + .map(|(name, _)| *name) + .collect(); + let holds_route = |value: &Expression<'_>| match value { + Expression::Instantiation(instantiation) => is_route_class(instantiation.class), + Expression::Variable(Variable::Direct(variable)) => { + route_variables.contains(&variable.name) + } + _ => false, + }; + // A route collection's `add()` takes the route second: a `new Route`, + // or a variable or a call holding one (`static::getEntryPointRoute()`), + // a parameter included, which the file alone cannot type. Any other + // `add()` adds something else. + let mut names: Vec<&Expression<'_>> = found + .added + .into_iter() + .filter(|(_, route)| { + holds_route(route) + || matches!( + route, + Expression::Variable(Variable::Direct(_)) | Expression::Call(_) + ) + }) + .map(|(name, _)| name) + .collect(); + names.extend( + found + .keyed + .into_iter() + .filter(|(_, value)| holds_route(value)) + .map(|(name, _)| name), + ); + let extension = ctx.resolved_name_at(name_offset).and_then(extension_of); + for name in names { + match built_name(name, ctx.content) { + BuiltName::Literal(key, start, end) => ctx.spans.push(SymbolSpan { + start, + end, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key, + is_write: true, + is_optional: false, + }, + }), + BuiltName::Pattern(pattern) => { + let pattern = if pattern.starts_with('*') { + let Some(extension) = extension else { + continue; + }; + format!("{extension}.*") + } else { + pattern + }; + if !ctx.drupal_route_patterns.contains(&pattern) { + ctx.drupal_route_patterns.push(pattern); + } + } + } + } +} + +/// The calls, assignments, and array entries of a route builder's methods +/// that may add a route. +#[derive(Default)] +struct BuilderSites<'ast, 'arena> { + /// The first two arguments of each `->add()` with two or more. + added: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, + /// The key and the value of each `$routes[KEY] = VALUE` and each + /// `KEY => VALUE` array entry. + keyed: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, + /// Each variable assigned a `new Class(…)`, with the class. + route_variables: Vec<(&'arena [u8], &'ast Expression<'arena>)>, +} + +struct BuilderWalker; + +impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BuilderSites<'ast, 'arena>> + for BuilderWalker +{ + fn walk_in_method_call( + &self, + call: &'ast MethodCall<'arena>, + found: &mut BuilderSites<'ast, 'arena>, + ) { + let ClassLikeMemberSelector::Identifier(name) = &call.method else { + return; + }; + let arguments = &call.argument_list.arguments; + if name.value.eq_ignore_ascii_case(b"add") + && let (Some(Argument::Positional(first)), Some(Argument::Positional(second))) = + (arguments.first(), arguments.get(1)) + { + found.added.push((first.value, second.value)); + } + } + + fn walk_in_assignment( + &self, + assignment: &'ast Assignment<'arena>, + found: &mut BuilderSites<'ast, 'arena>, + ) { + match (assignment.lhs, assignment.rhs) { + (Expression::ArrayAccess(access), value) => found.keyed.push((access.index, value)), + ( + Expression::Variable(Variable::Direct(variable)), + Expression::Instantiation(instantiation), + ) => found + .route_variables + .push((variable.name, instantiation.class)), + _ => {} + } + } + + fn walk_in_array(&self, array: &'ast Array<'arena>, found: &mut BuilderSites<'ast, 'arena>) { + push_keyed_entries(array.elements.iter(), found); + } + + fn walk_in_legacy_array( + &self, + array: &'ast LegacyArray<'arena>, + found: &mut BuilderSites<'ast, 'arena>, + ) { + push_keyed_entries(array.elements.iter(), found); + } +} + +/// Record each `KEY => VALUE` entry of an array literal. +fn push_keyed_entries<'ast, 'arena>( + elements: impl Iterator>, + found: &mut BuilderSites<'ast, 'arena>, +) where + 'arena: 'ast, +{ + for element in elements { + if let ArrayElement::KeyValue(entry) = element { + found.keyed.push((entry.key, entry.value)); + } + } +} + +/// The name a route builder gives a route: written out, or the shape of +/// the parts it is built from. +fn built_name(expression: &Expression<'_>, content: &str) -> BuiltName { + if let Some((key, start, end)) = resolved_string_literal(expression, content) { + return BuiltName::Literal(key.to_string(), start as u32, end as u32); + } + let mut pattern = String::new(); + push_name_parts(expression, &mut pattern); + BuiltName::Pattern(pattern) +} + +/// Append the shape of `expression` to `pattern`: its text where it is a +/// string, `*` where it is anything else. +fn push_name_parts(expression: &Expression<'_>, pattern: &mut String) { + let unknown = |pattern: &mut String| { + if !pattern.ends_with('*') { + pattern.push('*'); + } + }; + match expression { + Expression::Literal(Literal::String(string)) => match string.value { + Some(value) => pattern.push_str(&String::from_utf8_lossy(value)), + None => unknown(pattern), + }, + Expression::CompositeString(CompositeString::Interpolated(string)) => { + for part in string.parts.iter() { + match part { + StringPart::Literal(literal) => match literal.value { + Some(value) => pattern.push_str(&String::from_utf8_lossy(value)), + None => pattern.push_str(&String::from_utf8_lossy(literal.raw)), + }, + _ => unknown(pattern), + } + } + } + Expression::Binary(binary) if binary.operator.is_concatenation() => { + push_name_parts(binary.lhs, pattern); + push_name_parts(binary.rhs, pattern); + } + Expression::Parenthesized(inner) => push_name_parts(inner.expression, pattern), + _ => unknown(pattern), + } +} + +/// The extension a class of `Drupal\\…` belongs to, other than +/// core's own `Drupal\Core` and `Drupal\Component`. +fn extension_of(fqn: &str) -> Option<&str> { + let mut segments = fqn.trim_start_matches('\\').split('\\'); + let extension = (segments.next() == Some("Drupal")).then(|| segments.next())??; + (!matches!(extension, "Core" | "Component") && segments.next().is_some()).then_some(extension) +} + +/// Record the route name `new Url('…')` reads. +pub(in crate::symbol_map::extraction) fn emit_drupal_url_spans( + class: &Expression<'_>, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Expression::Identifier(identifier) = class else { + return; + }; + let resolved = ctx + .resolved_name_at(identifier.span().start.offset) + .unwrap_or_else(|| bytes_to_str(identifier.value())); + if crate::drupal::is_url_class(resolved) { + emit_drupal_route_span(0, argument_list, ctx); + } +} + +/// Record the route name a call reads in its positional argument `index`, +/// or in the `route_name:` argument that names it, and the parameters of +/// that route it passes in the argument after it, or in the +/// `route_parameters:` argument. +pub(in crate::symbol_map::extraction) fn emit_drupal_route_span( + index: usize, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + let Some(route) = argument_at(argument_list, index, b"route_name") else { + return; + }; + push_laravel_string_span( + StringKeyKind::DrupalRoute, + false, + false, + route, + ctx.content, + &mut ctx.spans, + ); + let Some((route, _, _)) = resolved_string_literal(route, ctx.content) else { + return; + }; + let Some(parameters) = argument_at(argument_list, index + 1, b"route_parameters") else { + return; + }; + for (key, start, end) in parameter_keys(parameters, ctx.content) { + ctx.spans.push(SymbolSpan { + start, + end, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: crate::drupal::routing::route_parameter_key(route, key), + is_write: false, + is_optional: false, + }, + }); + } +} + +/// Record the route parameters a call on a typed receiver passes after the +/// route name it reads (see [`crate::drupal::route_parameter_call`]), for +/// the type engine to confirm against its receiver like the name. +pub(in crate::symbol_map::extraction) fn record_drupal_route_parameter_sites( + member_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Some((receiver, parameter)) = crate::drupal::route_parameter_call(member_name) else { + return; + }; + // The route name is read only when it is written first, as the + // receiver's own sites read it. + let Some(Argument::Positional(route)) = argument_list.arguments.first() else { + return; + }; + let Some((route, _, _)) = resolved_string_literal(route.value, ctx.content) else { + return; + }; + let Some(parameters) = argument_at(argument_list, 1, parameter.as_bytes()) else { + return; + }; + for (key, start, end) in parameter_keys(parameters, ctx.content) { + ctx.typed_receiver_sites.push(TypedReceiverSite { + start, + end, + key: crate::drupal::routing::route_parameter_key(route, key), + is_write: false, + is_optional: false, + guards: 0, + receiver, + }); + } +} + +/// The argument a call passes its parameter `index` in: positional, or +/// named `name`. +fn argument_at<'a, 'arena>( + argument_list: &'a ArgumentList<'arena>, + index: usize, + name: &[u8], +) -> Option<&'a Expression<'arena>> { + argument_list + .arguments + .iter() + .enumerate() + .find_map(|(at, argument)| match argument { + Argument::Positional(positional) if at == index => Some(positional.value), + Argument::Named(named) if named.name.value == name => Some(named.value), + _ => None, + }) +} + +/// The keys of an array literal of route parameters that are written out, +/// each with the byte range of its text. +fn parameter_keys<'arena>( + parameters: &Expression<'arena>, + content: &str, +) -> Vec<(&'arena str, u32, u32)> { + let Some(entries) = crate::parser::array_literal_elements(parameters) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|entry| match entry { + ArrayElement::KeyValue(entry) => resolved_string_literal(entry.key, content), + _ => None, + }) + .filter(|(key, _, _)| !key.is_empty()) + .map(|(key, start, end)| (key, start as u32, end as u32)) + .collect() +} + /// The class a `handlers` array gives the handler `name`, as a class /// constant or a string. fn handler_class(handlers: &Expression<'_>, name: &str, ctx: &ExtractionCtx<'_>) -> Option { @@ -299,6 +1026,416 @@ mod tests { .collect() } + /// The route names `php`'s route builders declare, and the patterns + /// they keep. + fn built(php: &str) -> (Vec, Vec) { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + }, + None, + ); + let declared = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key, + is_write: true, + .. + } => Some(key.clone()), + _ => None, + }) + .collect(); + (declared, map.drupal_route_patterns.clone()) + } + + #[test] + fn route_builders_declare_names_and_keep_patterns() { + let php = r#"add('search.literal', new Route('/x')); + $collection->add('search.called', static::route($id)); + $collection->add("{$name}.bc", new Route('/y')); + $collection->add($name, new Route('/z')); + $routes['not_a_route'] = new \ArrayObject(); + $form->add('not_a_route_either'); + $bag->add('nor_this', 1); + $bag->add($id . '.nor_this', [1]); + return $routes; + } +} +"#; + let (mut declared, mut patterns) = built(php); + declared.sort(); + patterns.sort(); + assert_eq!(declared, ["search.called", "search.literal", "search.view"]); + // `"{$name}.bc"` and `$name` start with no written-out text, so + // they stand for the extension's own routes. + assert_eq!(patterns, ["search.*", "search.help_*", "search.view_*"]); + } + + #[test] + fn routes_held_in_variables_array_literals_and_traits_are_built() { + let php = r#"add("$prefix.view", $route); + $options['not_a_route'] = $prefix; + return ['layout_builder.literal' => new Route('/y'), 'not_either' => [1]]; + } +} +"#; + let (mut declared, patterns) = built(php); + declared.sort(); + assert_eq!(declared, ["layout_builder.held", "layout_builder.literal"]); + assert_eq!(patterns, ["layout_builder.*"]); + } + + /// The route names and aliases `php`'s controllers declare. + fn attribute_routes(php: &str) -> (Vec, Vec) { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + }, + None, + ); + let mut declared: Vec = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key, + is_write: true, + .. + } => Some(key.clone()), + _ => None, + }) + .collect(); + declared.sort(); + let mut aliases = map.drupal_route_aliases.clone(); + aliases.sort(); + (declared, aliases) + } + + #[test] + fn controllers_alias_single_routes_by_class_and_method() { + let php = r#" (Vec, Vec<(String, crate::symbol_map::ReceiverClass)>) { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + }, + None, + ); + let mut passed: Vec = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key, + is_write: false, + .. + } => { + assert_eq!( + key.rsplit('#').next(), + php.get(span.start as usize..span.end as usize) + ); + Some(key.clone()) + } + _ => None, + }) + .collect(); + passed.sort(); + let sites = map + .typed_receiver_sites + .iter() + .filter(|site| { + site.receiver + .targets() + .iter() + .any(|(_, kind)| *kind == StringKeyKind::DrupalRouteParameter) + }) + .map(|site| (site.key.clone(), site.receiver)) + .collect(); + (passed, sites) + } + + #[test] + fn route_calls_pass_the_parameters_of_their_route() { + let php = r#" $id, 'mode' => 'full', 0 => 'x']); + new Url('acme.note', route_parameters: ['acme_note' => 1]); + Link::createFromRoute('Note', 'acme.note', ['acme_note' => 2]); + Url::fromRoute($id, ['acme_note' => 3]); + $form_state->setRedirect('acme.note', ['acme_note' => 4]); + $urls->generateFromRoute('acme.note', parameters: ['mode' => 'teaser']); + $form_state->setRedirect('acme.note', 'not_an_array'); + $form_state->other('acme.note', ['nope' => 1]); + } +} +"#; + let (passed, sites) = route_parameters(php); + assert_eq!( + passed, + [ + "acme.note#acme_note", + "acme.note#acme_note", + "acme.note#acme_note", + "acme.note#mode", + ] + ); + assert_eq!( + sites, + [ + ( + "acme.note#acme_note".to_string(), + crate::symbol_map::ReceiverClass::FormRedirectParameters + ), + ( + "acme.note#mode".to_string(), + crate::symbol_map::ReceiverClass::UrlGeneratorParameters + ), + ] + ); + } + + /// A call's parameters are confirmed by a receiver of the same classes + /// as its route name, so neither can be read without the other. + #[test] + fn route_parameter_receivers_reach_the_classes_of_the_route_name() { + let classes = |receiver: crate::symbol_map::ReceiverClass, wanted: StringKeyKind| { + receiver + .targets() + .iter() + .filter(|(_, kind)| *kind == wanted) + .map(|(class, _)| *class) + .collect::>() + }; + for method in [ + "setRedirect", + "redirect", + "generateFromRoute", + "generate", + "checkNamedRoute", + ] { + let (names, _) = crate::symbol_map::extraction::symfony_receiver_method(method, 0) + .unwrap_or_else(|| panic!("{method} names no route")); + let (parameters, _) = crate::drupal::route_parameter_call(method) + .unwrap_or_else(|| panic!("{method} passes no parameters")); + assert_eq!( + classes(names, StringKeyKind::DrupalRoute), + classes(parameters, StringKeyKind::DrupalRouteParameter), + "{method}" + ); + } + } + + #[test] + fn route_attributes_declare_their_parameters_and_routes() { + let php = r#" ['acme_note' => ['type' => 'entity:acme_note']]])] + public function view($acme_note, string $mode): array { return []; } +} +#[Route('/about/{node}', name: 'acme.about', defaults: ['_entity_view' => 'node.full'])] +final class About { + public function __invoke(): array { return []; } +} +"#; + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + }, + None, + ); + let declared: Vec<(&str, &str)> = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key, + is_write: true, + .. + } => Some((&php[span.start as usize..span.end as usize], key.as_str())), + _ => None, + }) + .collect(); + assert_eq!( + declared, + [ + ("acme_note", "acme.notes.view#acme_note"), + ("mode", "acme.notes.view#mode"), + ("node", "acme.about#node"), + ] + ); + assert_eq!( + map.drupal_routes, + [ + DrupalRouteSite { + name: "acme.notes.view".to_string(), + path: "/notes/{acme_note}/view/{mode}".to_string(), + controller: Some("Drupal\\acme\\Controller\\Notes::view".to_string()), + title_callback: None, + parameters: vec![DeclaredParameter { + name: "acme_note".to_string(), + ty: Some("entity:acme_note".to_string()), + converted_otherwise: false, + }], + defaults: Vec::new(), + entity_type: None, + }, + DrupalRouteSite { + name: "acme.about".to_string(), + path: "/about/{node}".to_string(), + controller: Some("Drupal\\acme\\Controller\\About::__invoke".to_string()), + title_callback: None, + parameters: Vec::new(), + defaults: Vec::new(), + entity_type: Some("node".to_string()), + }, + ] + ); + } + + #[test] + fn an_entity_route_provider_declares_the_routes_it_renames() { + let php = r#"get('entity.node.add_form'); + $routes->remove('entity.node.add_form'); + $routes->add('node.add', $addFormRoute); + return $routes; + } +} +"#; + assert_eq!(built(php), (vec!["node.add".to_string()], Vec::new())); + } + + #[test] + fn a_file_without_symfony_routes_builds_none() { + let php = "add('acme.thing', 1); }\n}\n"; + assert_eq!(built(php), (Vec::new(), Vec::new())); + } + #[test] fn an_attribute_declares_the_id_class_and_storage() { let php = " NodeStorage::class, 'form' => ['default' => 'X']],\n)]\nclass Node {}\n"; diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index 6d6ac6783..311ada797 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -44,6 +44,7 @@ pub(super) fn extract_instantiation_expr<'a>( } emit_symfony_instantiation_spans(inst.class, args, ctx); emit_twig_callable_instantiation(inst.class, args, ctx); + emit_drupal_url_spans(inst.class, args, ctx); if !class_text.is_empty() { emit_call_site( format!("new {}", class_text), @@ -487,6 +488,7 @@ fn extract_method_call<'a>( let member_name = crate::atom::atom_bytes(ident.value); emit_laravel_method_call_spans(object, &member_name, argument_list, ctx); record_symfony_receiver_sites(object, &member_name, argument_list, ctx); + record_drupal_route_parameter_sites(&member_name, argument_list, ctx); record_yii_view_sites(object, &member_name, argument_list, ctx); emit_mock_method_spans(object, &member_name, argument_list, ctx); // Recorded with `->` for both spellings so callable resolution @@ -615,6 +617,12 @@ fn extract_static_method_call<'a>( &mut ctx.spans, ); } + if ctx.drupal + && let Some(index) = + crate::drupal::static_route_argument(semantic_subject, &member_name) + { + emit_drupal_route_span(index, &static_call.argument_list, ctx); + } } extract_from_arguments(&static_call.argument_list.arguments, ctx, scope_start); } diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 22af1f79e..3b2ec56fa 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -104,6 +104,10 @@ struct ExtractionCtx<'a> { container_values: Vec, /// The entity types the file's entity classes declare. entity_types: Vec, + drupal_route_patterns: Vec, + drupal_route_aliases: Vec, + /// The Drupal routes the file's `#[Route]` attributes declare. + drupal_routes: Vec, /// Current conditional nesting depth (if/else, switch, while, for, etc.). /// Incremented when entering a conditional block, decremented when leaving. cond_nesting_depth: u16, @@ -477,6 +481,9 @@ fn extract_symbol_map_inner( twig_contexts: Vec::new(), container_values: Vec::new(), entity_types: Vec::new(), + drupal_route_patterns: Vec::new(), + drupal_route_aliases: Vec::new(), + drupal_routes: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, @@ -670,6 +677,9 @@ fn extract_symbol_map_inner( twig_contexts: ctx.twig_contexts, container_values: ctx.container_values, entity_types: ctx.entity_types, + drupal_route_patterns: ctx.drupal_route_patterns, + drupal_route_aliases: ctx.drupal_route_aliases, + drupal_routes: ctx.drupal_routes, routes: ctx.routes, declares_twig_loader: ctx.declares_twig_loader, route_imports: ctx diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index 469a9430e..84c1c324b 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -75,7 +75,7 @@ pub(crate) fn symfony_receiver_method( // turned away on a single byte. if !matches!( member_name.as_bytes().first().map(u8::to_ascii_lowercase), - Some(b'g' | b'h' | b's' | b'a' | b'd' | b'r' | b't' | b'p' | b'f') + Some(b'g' | b'h' | b's' | b'a' | b'd' | b'r' | b't' | b'p' | b'f' | b'c') ) { return None; } @@ -102,7 +102,13 @@ pub(crate) fn symfony_receiver_method( 0 if is("generateUrl") || is("redirectToRoute") => { Some((ReceiverClass::ControllerRouting, false)) } - 0 if is("generate") || is("generateAbsolute") => Some((ReceiverClass::UrlGenerator, false)), + 0 if is("generate") || is("generateAbsolute") || is("generateFromRoute") => { + Some((ReceiverClass::UrlGenerator, false)) + } + 0 if is("setRedirect") => Some((ReceiverClass::FormRedirect, false)), + 0 if is("redirect") => Some((ReceiverClass::RouteRedirect, false)), + 0 if is("checkNamedRoute") => Some((ReceiverClass::AccessCheck, false)), + 0 if is("getRouteByName") => Some((ReceiverClass::RouteLookup, false)), 3 if is("generateFromCurrent") => Some((ReceiverClass::UrlGenerator, false)), 0 if is("add") => Some((ReceiverClass::RoutingConfigurator, true)), 0 if is("getParameter") || is("hasParameter") => { @@ -1430,7 +1436,7 @@ pub(in crate::symbol_map::extraction) fn emit_template_attribute_span( /// Whether `class_name`, as resolved, is Symfony's `#[Route]`, under its /// current name or the `Annotation` one older applications import. -fn is_route_attribute(class_name: &str) -> bool { +pub(in crate::symbol_map::extraction) fn is_route_attribute(class_name: &str) -> bool { let class_name = class_name.trim_start_matches('\\'); ROUTE_ATTRIBUTES .iter() @@ -1438,7 +1444,7 @@ fn is_route_attribute(class_name: &str) -> bool { } /// A route's name, as its declaration writes it. -enum RouteName { +pub(in crate::symbol_map::extraction) enum RouteName { /// No name: Symfony derives one from the class and the method. Absent, /// A literal, with the byte range of its text. @@ -1449,8 +1455,8 @@ enum RouteName { /// One `#[Route]` attribute or `@Route` annotation, by what bears on the /// names it declares. -struct RouteDeclaration { - name: RouteName, +pub(in crate::symbol_map::extraction) struct RouteDeclaration { + pub(in crate::symbol_map::extraction) name: RouteName, /// The byte range a derived name spans: the path, or the whole /// declaration when the path is not a literal. at: (usize, usize), @@ -1532,7 +1538,7 @@ fn route_name_value( /// The routes the `#[Route]` attributes in `attribute_lists` declare, and /// the `@Route` annotations in `docblock` (its text and offset), on `class` /// or one of its methods. -fn route_declarations( +pub(in crate::symbol_map::extraction) fn route_declarations( attribute_lists: &Sequence<'_, AttributeList<'_>>, docblock: Option<(&str, u32)>, class: &Class<'_>, @@ -1912,7 +1918,12 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_route_spans( if action.count == 1 && let Some(class) = derive { - declare_route(&mut spans, format!("{class}::{method_name}"), action.at); + declare_route( + &mut spans, + StringKeyKind::SymfonyRoute, + format!("{class}::{method_name}"), + action.at, + ); } count += action.count; last_at = action.at; @@ -1936,19 +1947,34 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_route_spans( && count == 1 && let Some(class) = derive { - declare_route(&mut spans, class.to_string(), last_at); - declare_route(&mut spans, format!("{class}::__invoke"), last_at); + declare_route( + &mut spans, + StringKeyKind::SymfonyRoute, + class.to_string(), + last_at, + ); + declare_route( + &mut spans, + StringKeyKind::SymfonyRoute, + format!("{class}::__invoke"), + last_at, + ); } ctx.spans.extend(spans); } -/// Declare the route `key` over the byte range `at`. -fn declare_route(spans: &mut Vec, key: String, at: (usize, usize)) { +/// Declare the route `key`, a `kind` key, over the byte range `at`. +fn declare_route( + spans: &mut Vec, + kind: StringKeyKind, + key: String, + at: (usize, usize), +) { spans.push(SymbolSpan { start: at.0 as u32, end: at.1 as u32, kind: SymbolKind::StringKey { - kind: StringKeyKind::SymfonyRoute, + kind, key, is_write: true, is_optional: false, @@ -2409,32 +2435,40 @@ fn push_action_route_spans( } else { &route.locales }; - action.unreadable |= push_route_spans(route, prefix, derived, locales, spans); + action.unreadable |= push_route_spans( + route, + prefix, + derived, + StringKeyKind::SymfonyRoute, + locales, + spans, + ); action.count += locales.len().max(1); action.at = route.at; } action } -/// The spans one route declares: its name, or the `derived` one, and its -/// aliases, each under every one of its `locales`. A localized route's own +/// The spans one route declares as `kind` keys: its name, or the `derived` +/// one, and its aliases, each under every one of its `locales`. A localized route's own /// name still generates the current locale's route; an alias's does not. /// Whether its name cannot be read. -fn push_route_spans( +pub(in crate::symbol_map::extraction) fn push_route_spans( route: &RouteDeclaration, prefix: &str, derived: Option, + kind: StringKeyKind, locales: &[String], spans: &mut Vec, ) -> bool { let localized = |spans: &mut Vec, name: &str, at: (usize, usize)| { for locale in locales { - declare_route(spans, format!("{name}.{locale}"), at); + declare_route(spans, kind, format!("{name}.{locale}"), at); } }; let declare = |spans: &mut Vec, name: String, at: (usize, usize)| { localized(spans, &name, at); - declare_route(spans, name, at); + declare_route(spans, kind, name, at); }; let unreadable = match &route.name { RouteName::Literal(name, start, end) => { @@ -2451,7 +2485,7 @@ fn push_route_spans( }; for (alias, start, end) in &route.aliases { if locales.is_empty() { - declare_route(spans, alias.clone(), (*start, *end)); + declare_route(spans, kind, alias.clone(), (*start, *end)); } else { localized(spans, alias, (*start, *end)); } @@ -2603,11 +2637,27 @@ mod tests { ("getRouteProviders", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), ("hasHandler", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), ("getHandler", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("generate", 0, URL_GENERATOR, ROUTE), + ("generateFromRoute", 0, URL_GENERATOR, ROUTE), + ("setRedirect", 0, FORM_STATE, ROUTE), + ("redirect", 0, CONTROLLER_BASE, ROUTE), + ("redirect", 0, FORM_BASE, ROUTE), + ("redirect", 0, ENTITY_CONTROLLER, ROUTE), + ("checkNamedRoute", 0, ACCESS_MANAGER, ROUTE), + ("getRouteByName", 0, ROUTE_PROVIDER, ROUTE), ]; const ENTITY_TYPE_MANAGER: &str = "Drupal\\Core\\Entity\\EntityTypeManagerInterface"; const ENTITY_TYPE: crate::symbol_map::StringKeyKind = crate::symbol_map::StringKeyKind::DrupalEntityType; + const URL_GENERATOR: &str = "Drupal\\Core\\Routing\\UrlGeneratorInterface"; + const FORM_STATE: &str = "Drupal\\Core\\Form\\FormStateInterface"; + const CONTROLLER_BASE: &str = "Drupal\\Core\\Controller\\ControllerBase"; + const FORM_BASE: &str = "Drupal\\Core\\Form\\FormBase"; + const ENTITY_CONTROLLER: &str = "Drupal\\Core\\Entity\\Controller\\EntityController"; + const ACCESS_MANAGER: &str = "Drupal\\Core\\Access\\AccessManagerInterface"; + const ROUTE_PROVIDER: &str = "Drupal\\Core\\Routing\\RouteProviderInterface"; + const ROUTE: crate::symbol_map::StringKeyKind = crate::symbol_map::StringKeyKind::DrupalRoute; #[test] fn every_drupal_receiver_method_reaches_its_drupal_class() { diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index 805664f9b..841a102c9 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -307,6 +307,19 @@ pub(crate) struct SymbolMap { /// The entity types the file's entity classes declare, sorted by /// `start`. Empty for every file that declares none. pub entity_types: Vec, + /// The shapes of the Drupal route names the file's route builders give + /// routes they build from parts, `*` for each part that is not + /// written out (`search.view_*`). Empty for every file that builds + /// none. + pub drupal_route_patterns: Vec, + /// The `Class::method` and `Class` names Drupal's attribute route + /// discovery gives the file's controller routes besides their own, + /// which no text of the file spells out. Empty for every file that + /// declares none. + pub drupal_route_aliases: Vec, + /// The Drupal routes the file declares, in source order. Empty for + /// every file that declares none. + pub drupal_routes: Vec, /// The routes the file declares, in source order. Empty for every file /// that declares none. pub routes: Vec, diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index c990ffa45..c01b36923 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -160,6 +160,18 @@ pub(crate) enum StringKeyKind { /// the entity type manager's `getStorage()` and the other methods that /// take one. DrupalEntityType, + /// A Drupal route name: declared by a top-level key of a routing file, + /// and read by `Url::fromRoute()`, `Link::createFromRoute()`, a form + /// state's `setRedirect()`, a controller's or form's `redirect()`, and + /// the URL generator, access manager, and route provider methods that + /// take one. + DrupalRoute, + /// A parameter of a Drupal route, keyed by the route as well as the + /// name (see `drupal::routing::route_parameter_key`): a `{node}` only + /// means something under its route. Declared by each placeholder of + /// the route's path, and read by the keys of the parameters a route + /// call or a links file's `route_parameters:` passes. + DrupalRouteParameter, } impl StringKeyKind { @@ -206,7 +218,9 @@ impl StringKeyKind { | Self::TwigVariable => KeyFramework::Twig, Self::YiiView | Self::YiiRoute | Self::YiiRouteArgument => KeyFramework::Yii, Self::WordPressHook => KeyFramework::WordPress, - Self::DrupalEntityType => KeyFramework::Drupal, + Self::DrupalEntityType | Self::DrupalRoute | Self::DrupalRouteParameter => { + KeyFramework::Drupal + } } } @@ -657,6 +671,73 @@ pub(crate) struct RouteSite { pub handler: Option, } +/// A route a Drupal routing file or `#[Route]` declares, with what Drupal +/// reads from it besides its name to upcast its parameters (see +/// `drupal::upcasting`): the path, the controller and title callback, the +/// parameters its `options` declare, the values its `defaults` give, and +/// the entity type an `_entity_view` or `_entity_form` default names. +/// +/// Kept beside the spans, which hold the name and the path's parameters, +/// since only the type engine and hover read the rest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DrupalRouteSite { + pub name: String, + pub path: String, + /// The method the route dispatches to, as `Class::method` without the + /// leading `\`: its `_form`'s `buildForm()`, else its `_controller` + /// (a class alone is its `__invoke()`). `None` for a controller + /// written as `service:method`, whose class only the container knows. + pub controller: Option, + /// The `_title_callback`, spelled like [`Self::controller`]. + pub title_callback: Option, + /// Each parameter `options.parameters` declares, with or without a + /// `type`. + pub parameters: Vec, + /// Each `defaults` key that is not one of Drupal's own (`_controller`, + /// `_title`, …), with whether its value is `NULL`. Drupal converts a + /// parameter only when the path or a default gives it a value. + pub defaults: Vec<(String, bool)>, + /// The entity type the `_entity_view` default names, else the + /// `_entity_form` one. + pub entity_type: Option, +} + +/// A parameter a route's `options.parameters` declares. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeclaredParameter { + pub name: String, + /// The `type` it declares, as written (`entity:node`). + pub ty: Option, + /// Whether it names a `converter` or a `tempstore`, which convert it + /// some other way than Drupal's entity converter. + pub converted_otherwise: bool, +} + +impl DrupalRouteSite { + /// What `options.parameters` declares for the parameter `name`. + pub(crate) fn declared(&self, name: &str) -> Option<&DeclaredParameter> { + self.parameters + .iter() + .find(|parameter| parameter.name == name) + } + + /// Whether Drupal gives the parameter `name` a value to convert: the + /// path declares it, or a default gives it one. + pub(crate) fn passes(&self, name: &str) -> bool { + crate::drupal::routing::path_parameter_names(&self.path) + .any(|placeholder| placeholder == name) + || self.defaults.iter().any(|(default, _)| default == name) + } + + /// Whether a default gives the parameter `name` the value `NULL`, which + /// Drupal leaves as it is. + pub(crate) fn defaults_to_null(&self, name: &str) -> bool { + self.defaults + .iter() + .any(|(default, is_null)| *is_null && default == name) + } +} + /// One `{name}` in a route's path. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RoutePlaceholder { @@ -728,6 +809,13 @@ const YII_LEGACY_VIEW_RENDERER: &str = "Yiisoft\\Yii\\View\\ViewRenderer"; const YII_VIEW: &str = "Yiisoft\\View\\ViewInterface"; pub(crate) const DRUPAL_ENTITY_TYPE_MANAGER: &str = "Drupal\\Core\\Entity\\EntityTypeManagerInterface"; +const DRUPAL_URL_GENERATOR: &str = "Drupal\\Core\\Routing\\UrlGeneratorInterface"; +const DRUPAL_FORM_STATE: &str = "Drupal\\Core\\Form\\FormStateInterface"; +const DRUPAL_CONTROLLER_BASE: &str = "Drupal\\Core\\Controller\\ControllerBase"; +const DRUPAL_FORM_BASE: &str = "Drupal\\Core\\Form\\FormBase"; +const DRUPAL_ENTITY_CONTROLLER: &str = "Drupal\\Core\\Entity\\Controller\\EntityController"; +const DRUPAL_ACCESS_MANAGER: &str = "Drupal\\Core\\Access\\AccessManagerInterface"; +const DRUPAL_ROUTE_PROVIDER: &str = "Drupal\\Core\\Routing\\RouteProviderInterface"; /// The namespace of the PHP configurators that build Symfony's service /// container. @@ -784,8 +872,9 @@ pub(crate) enum ReceiverClass { /// the shortcuts a Symfony controller inherits. ControllerRouting, /// A URL generator, the router included, whose `generate()` takes a - /// route name: Symfony's, and Yii's, whose `generateAbsolute()` takes - /// one too and whose `generateFromCurrent()` takes a fallback. + /// route name: Symfony's, Drupal's, whose `generateFromRoute()` takes + /// one too, and Yii's, whose `generateAbsolute()` takes one too and + /// whose `generateFromCurrent()` takes a fallback. UrlGenerator, /// A PHP routing configurator, a collection of one, or the route one /// of them just added, whose `add()` declares a route. @@ -822,6 +911,29 @@ pub(crate) enum ReceiverClass { /// both Symfony's container builder and Drupal's entity type manager /// have: the one takes a service ID, the other an entity type ID. DefinitionLookup, + /// A Drupal form state, whose `setRedirect()` takes a route name. + FormRedirect, + /// What has Drupal's `redirect()` shortcut, which takes a route name: a + /// controller or a form. + RouteRedirect, + /// Drupal's access manager, whose `checkNamedRoute()` takes a route + /// name. + AccessCheck, + /// Drupal's route provider, whose `getRouteByName()` takes a route + /// name. + RouteLookup, + /// A Drupal form state again, for the route parameters its + /// `setRedirect()` takes after the route name. + FormRedirectParameters, + /// What has Drupal's `redirect()` shortcut again, for the route + /// parameters it takes after the route name. + RouteRedirectParameters, + /// Drupal's URL generator, for the route parameters its + /// `generateFromRoute()` and `generate()` take after the route name. + UrlGeneratorParameters, + /// Drupal's access manager, for the route parameters its + /// `checkNamedRoute()` takes after the route name. + AccessCheckParameters, } impl ReceiverClass { @@ -870,7 +982,10 @@ impl ReceiverClass { Self::ConfiguratorArgument => &[(SYMFONY_ABSTRACT_CONFIGURATOR, ContainerParameter)], Self::ContainerBuilder => &[(SYMFONY_CONTAINER_BUILDER, ServiceId)], Self::ControllerRouting => &[(SYMFONY_ABSTRACT_CONTROLLER, SymfonyRoute)], + // Drupal's generator extends Symfony's, so it has to be asked + // about first. Self::UrlGenerator => &[ + (DRUPAL_URL_GENERATOR, StringKeyKind::DrupalRoute), (SYMFONY_URL_GENERATOR, SymfonyRoute), (YII_URL_GENERATOR, YiiRoute), ], @@ -907,6 +1022,31 @@ impl ReceiverClass { (SYMFONY_CONTAINER_BUILDER, ServiceId), (DRUPAL_ENTITY_TYPE_MANAGER, StringKeyKind::DrupalEntityType), ], + Self::FormRedirect => &[(DRUPAL_FORM_STATE, StringKeyKind::DrupalRoute)], + Self::RouteRedirect => &[ + (DRUPAL_CONTROLLER_BASE, StringKeyKind::DrupalRoute), + (DRUPAL_FORM_BASE, StringKeyKind::DrupalRoute), + (DRUPAL_ENTITY_CONTROLLER, StringKeyKind::DrupalRoute), + ], + Self::AccessCheck => &[(DRUPAL_ACCESS_MANAGER, StringKeyKind::DrupalRoute)], + Self::RouteLookup => &[(DRUPAL_ROUTE_PROVIDER, StringKeyKind::DrupalRoute)], + Self::FormRedirectParameters => { + &[(DRUPAL_FORM_STATE, StringKeyKind::DrupalRouteParameter)] + } + Self::RouteRedirectParameters => &[ + (DRUPAL_CONTROLLER_BASE, StringKeyKind::DrupalRouteParameter), + (DRUPAL_FORM_BASE, StringKeyKind::DrupalRouteParameter), + ( + DRUPAL_ENTITY_CONTROLLER, + StringKeyKind::DrupalRouteParameter, + ), + ], + Self::UrlGeneratorParameters => { + &[(DRUPAL_URL_GENERATOR, StringKeyKind::DrupalRouteParameter)] + } + Self::AccessCheckParameters => { + &[(DRUPAL_ACCESS_MANAGER, StringKeyKind::DrupalRouteParameter)] + } } } diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 380e78c00..dfa35c444 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -125,8 +125,8 @@ pub(crate) struct SymfonyState { /// Whether `map` declares a service ID, a container parameter, a route, a /// translation key, a template, a Twig filter or function, a configuration -/// key, an event listener, a Messenger bus, or a Drupal entity type, -/// outright or through a configurator call still to be +/// key, an event listener, a Messenger bus, a Drupal entity type, or a +/// Drupal route, outright or through a configurator call still to be /// confirmed, or registers a Messenger handler, or imports routes, or /// declares a Twig loader. /// @@ -148,6 +148,8 @@ fn declares_symfony_keys(map: &SymbolMap, enabled: impl Fn(StringKeyKind) -> boo || (!map.routes.is_empty() && enabled(StringKeyKind::YiiRoute)) || (!map.route_imports.is_empty() && enabled(StringKeyKind::SymfonyRoute)) || (map.declares_twig_loader && enabled(StringKeyKind::TwigTemplate)) + || ((!map.drupal_route_patterns.is_empty() || !map.drupal_route_aliases.is_empty()) + && enabled(StringKeyKind::DrupalRoute)) || map.spans.iter().any(|span| { matches!( &span.kind, @@ -210,6 +212,7 @@ impl Backend { .lock() .iter() .any(|prefix| uri.starts_with(prefix.as_str())); + self.note_drupal_routes(uri, Some(map)); // Only take the write lock when the answer changes. if self.symfony.declaring_uris.read().contains(uri) == declares { return; @@ -224,6 +227,7 @@ impl Backend { /// Forget a file that no longer exists. pub(crate) fn forget_symfony_declarations(&self, uri: &str) { + self.note_drupal_routes(uri, None); if self.symfony.declaring_uris.read().contains(uri) { self.symfony.declaring_uris.write().remove(uri); } diff --git a/src/symfony/resources.rs b/src/symfony/resources.rs index 3e7fbbe08..b929dde57 100644 --- a/src/symfony/resources.rs +++ b/src/symfony/resources.rs @@ -318,7 +318,7 @@ pub(super) fn yaml_mapping_key(line: &str, line_start: usize) -> Option<(&str, u } /// `line` without a trailing `# comment`, leaving a `#` inside quotes. -pub(super) fn yaml_content_before_comment(line: &str) -> &str { +pub(crate) fn yaml_content_before_comment(line: &str) -> &str { let mut quote = None; for (index, byte) in line.bytes().enumerate() { match (quote, byte) { diff --git a/src/symfony/routes.rs b/src/symfony/routes.rs index df2f3c09a..fab6cffec 100644 --- a/src/symfony/routes.rs +++ b/src/symfony/routes.rs @@ -56,7 +56,9 @@ pub(crate) fn routing_declarations( let mut spans = Vec::new(); let mut imports = Vec::new(); match super::config_format(uri) { - Some(super::ConfigFormat::Yaml) => scan_yaml(content, &mut spans, &mut imports), + Some(super::ConfigFormat::Yaml) => { + scan_yaml(content, SYMFONY_ROUTES, &mut spans, &mut imports); + } Some(super::ConfigFormat::Xml) => scan_xml(content, &mut spans, &mut imports), None => {} } @@ -64,12 +66,48 @@ pub(crate) fn routing_declarations( (spans, imports) } +/// The route-name spans of a Drupal routing file, sorted by offset. +pub(crate) fn drupal_routing_spans(content: &str) -> Vec { + let mut spans = Vec::new(); + let mut imports = Vec::new(); + scan_yaml(content, DRUPAL_ROUTES, &mut spans, &mut imports); + spans.sort_by_key(|span| (span.start, span.end)); + spans +} + +/// The kind of key a routing file declares, what a name of that kind may +/// look like, and whether the file can import other routes. +#[derive(Clone, Copy)] +struct RouteNaming { + kind: StringKeyKind, + is_name: fn(&str) -> bool, + /// Whether a `resource:` imports routes: true for Symfony's routing + /// files, false for Drupal's, which have no imports. + reads_imports: bool, +} + +const SYMFONY_ROUTES: RouteNaming = RouteNaming { + kind: StringKeyKind::SymfonyRoute, + is_name: is_route_name, + reads_imports: true, +}; + +const DRUPAL_ROUTES: RouteNaming = RouteNaming { + kind: StringKeyKind::DrupalRoute, + is_name: is_drupal_route_name, + reads_imports: false, +}; + fn route_span(key: &str, start: usize, is_write: bool) -> SymbolSpan { + route_span_of(StringKeyKind::SymfonyRoute, key, start, is_write) +} + +fn route_span_of(kind: StringKeyKind, key: &str, start: usize, is_write: bool) -> SymbolSpan { SymbolSpan { start: start as u32, end: (start + key.len()) as u32, kind: SymbolKind::StringKey { - kind: StringKeyKind::SymfonyRoute, + kind, key: key.to_string(), is_write, is_optional: false, @@ -86,6 +124,17 @@ fn is_route_name(name: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) } +/// Whether `name` can be a Drupal route name. Drupal's own routes include +/// `` and ``, and a route attribute's alias is a class name +/// (`Drupal\acme\Controller\Foo::bar`), so only what YAML or PHP could not +/// hold in a plain name is ruled out. +pub(crate) fn is_drupal_route_name(name: &str) -> bool { + !name.is_empty() + && !name + .bytes() + .any(|byte| byte.is_ascii_whitespace() || matches!(byte, b'\'' | b'"' | b'{' | b'}')) +} + // ─── YAML ─────────────────────────────────────────────────────────────────── /// One `key: value` line of a routing YAML file. @@ -128,7 +177,12 @@ fn yaml_lines(content: &str) -> Vec> { /// The route declarations of a routing YAML file: every top-level key, and /// every key under a top-level `when@env:`, whose mapping has a `path:` or /// an `alias:` rather than a `resource:`, which imports other routes. -fn scan_yaml(content: &str, spans: &mut Vec, imports: &mut Vec) { +fn scan_yaml( + content: &str, + naming: RouteNaming, + spans: &mut Vec, + imports: &mut Vec, +) { let lines = yaml_lines(content); // The indentation of the entries of the `when@env:` section the scan is // in, from its first one. @@ -148,7 +202,7 @@ fn scan_yaml(content: &str, spans: &mut Vec, imports: &mut Vec &str { fn scan_yaml_route( entry: &YamlLine<'_>, rest: &[YamlLine<'_>], + naming: RouteNaming, spans: &mut Vec, imports: &mut Vec, ) { - if !is_route_name(entry.key) { + if !(naming.is_name)(entry.key) { return; } let mut is_route = false; @@ -226,7 +281,7 @@ fn scan_yaml_route( } } - if let Some(mut import) = import { + if let Some(mut import) = import.filter(|_| naming.reads_imports) { import.loader = loader .filter(|loader| !loader.is_empty()) .map(str::to_string); @@ -239,14 +294,14 @@ fn scan_yaml_route( let trimmed = value.trim_start(); let leading = value.len() - trimmed.len(); let (name, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); - is_route_name(name).then(|| (name, value_start + leading + quote)) + (naming.is_name)(name).then(|| (name, value_start + leading + quote)) }); if !(is_route || alias.is_some()) { return; } - spans.push(route_span(entry.key, entry.key_start, true)); + spans.push(route_span_of(naming.kind, entry.key, entry.key_start, true)); if let Some((name, start)) = alias { - spans.push(route_span(name, start, false)); + spans.push(route_span_of(naming.kind, name, start, false)); } } @@ -327,8 +382,9 @@ fn scan_xml(content: &str, spans: &mut Vec, imports: &mut Vec Vec { +/// read, as `kind` keys (Symfony's and Drupal's Twig both have the two +/// functions), sorted by offset. +pub(crate) fn twig_route_spans(content: &str, kind: StringKeyKind) -> Vec { let bytes = content.as_bytes(); let mut spans = Vec::new(); let mut cursor = 0usize; @@ -362,8 +418,12 @@ pub(crate) fn twig_route_spans(content: &str) -> Vec { break; }; let name = &content[start..start + len]; - if is_route_name(name) { - spans.push(route_span(name, start, false)); + let is_name = match kind { + StringKeyKind::DrupalRoute => is_drupal_route_name(name), + _ => is_route_name(name), + }; + if is_name { + spans.push(route_span_of(kind, name, start, false)); } cursor = start + len + 1; } @@ -386,10 +446,28 @@ fn route_function_before(before: &str) -> Option<&str> { /// Where the route name a Twig `path('` or `url('` the cursor is in starts, /// when the name typed so far runs up to `cursor`. pub(crate) fn twig_route_name_start(content: &str, cursor: usize) -> Option { + twig_route_name_start_where(content, cursor, |byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-') + }) +} + +/// [`twig_route_name_start`] for a Drupal route name, which may be one of +/// the angle-bracketed routes such as ``. +pub(crate) fn drupal_twig_route_name_start(content: &str, cursor: usize) -> Option { + twig_route_name_start_where(content, cursor, |byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'<' | b'>') + }) +} + +fn twig_route_name_start_where( + content: &str, + cursor: usize, + is_name_byte: impl Fn(u8) -> bool, +) -> Option { let before = content.get(..cursor)?; let name_start = before .bytes() - .rposition(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))) + .rposition(|byte| !is_name_byte(byte)) .map_or(0, |index| index + 1); let before_name = before[..name_start].strip_suffix(['\'', '"'])?; let before_paren = before_name.trim_end().strip_suffix('(')?; @@ -436,6 +514,27 @@ mod tests { ); } + #[test] + fn a_drupal_entry_with_a_resource_and_a_path_is_a_route() { + let yaml = "acme.export:\n path: '/acme/export'\n resource: 'not an import'\n"; + assert_eq!( + routes(&drupal_routing_spans(yaml), yaml), + vec![("acme.export".to_string(), true)] + ); + } + + #[test] + fn drupal_routing_files_declare_their_keys_with_a_path() { + let yaml = "'':\n path: '/'\nacme.greeting:\n path: '/acme/greeting/{name}'\n defaults:\n _controller: '\\Drupal\\acme\\Controller\\GreetingController::greet'\n\nroute_callbacks:\n - '\\Drupal\\acme\\Routing\\Routes::routes'\n"; + assert_eq!( + routes(&drupal_routing_spans(yaml), yaml), + vec![ + ("".to_string(), true), + ("acme.greeting".to_string(), true), + ] + ); + } + #[test] fn xml_declares_routes_and_reads_aliases() { let xml = r#" @@ -498,7 +597,7 @@ mod tests { fn twig_reads_path_and_url_calls() { let twig = "{# path('commented') #}\n{{ url( \"blog_post\", {slug: post.slug}) }}\n{{ app.request.path('not_a_route') }}{{ asset_path('nor_this') }}\n"; assert_eq!( - routes(&twig_route_spans(twig), twig), + routes(&twig_route_spans(twig, StringKeyKind::SymfonyRoute), twig), vec![ ("blog_index".to_string(), false), ("blog_post".to_string(), false), @@ -506,6 +605,13 @@ mod tests { ); } + #[test] + fn drupal_twig_completion_reads_angle_brackets() { + let twig = "{{ path('( }); let trait_prototype = trait_prototype_method(method_name, ctx); + // The parameters Drupal upcasts to an entity before a controller runs, + // looked up once, when there is a parameter it could be. + let mut drupal_upcasts: Option> = None; for (index, param) in parameters.enumerate() { let pname = bytes_to_str(param.variable.name).to_string(); @@ -161,6 +164,19 @@ pub(crate) fn seed_params<'b>( ctx, ); + if let Some(entity) = drupal_upcast_entity( + &pname, + native_type.as_ref(), + ¶m_results, + crate::parser::param_default_is_null(param), + method_name, + &mut drupal_upcasts, + ctx, + ) { + scope.seed(&pname, ctx.resolved_types_for(entity)); + continue; + } + // A variadic parameter collects the arguments into an array // rather than taking the one at its index, so the call site's // types don't line up with it. @@ -183,6 +199,77 @@ pub(crate) fn seed_params<'b>( } } +/// The type a Drupal route's controller receives for the parameter `name` +/// (written with its `$`) of the method being seeded, when Drupal upcasts +/// it (see [`crate::drupal::upcasting`]) and its native hint, if any, +/// accepts the entity: the entity class, or `null` too when the hint is +/// nullable, the parameter defaults to `null`, or a route's default leaves +/// it `NULL`. `None` when `declared`, the type the signature or the +/// docblock gives it, already names a subclass of the entity class, such as +/// a bundle class. `upcasts` caches the method's upcast parameters. +fn drupal_upcast_entity( + name: &str, + native_type: Option<&PhpType>, + declared: &[ResolvedType], + defaults_to_null: bool, + method_name: Option<&str>, + upcasts: &mut Option>, + ctx: &ForwardWalkCtx<'_>, +) -> Option { + let method = method_name?; + let name = name.strip_prefix('$').unwrap_or(name); + let backend = ctx + .file + .backend + .filter(|backend| backend.is_drupal_project())?; + let (entity, route_null) = upcasts + .get_or_insert_with(|| { + backend.drupal_upcast_parameters( + &ctx.current_class.fqn(), + method, + ctx.file.class_loader, + ) + }) + .iter() + .find(|(parameter, _, _)| parameter.as_str() == name) + .map(|(_, entity, route_null)| (*entity, *route_null))?; + let narrower = |resolved: &ResolvedType| { + resolved.type_string.is_null() + || resolved.class_info.as_ref().is_some_and(|class| { + class.fqn().trim_start_matches('\\') != entity.trim_start_matches('\\') + && crate::class_lookup::is_subtype_of(class, &entity, ctx.file.class_loader) + }) + }; + if declared + .iter() + .any(|resolved| !resolved.type_string.is_null()) + && declared.iter().all(narrower) + { + return None; + } + let mut nullable = route_null || defaults_to_null; + if let Some(written) = native_type { + // A builtin or union hint takes no entity, nor a class the entity's + // is not. + let resolved = ctx.written_names().resolve_hint(written); + let hint = crate::drupal::upcasting::hinted_class(&resolved)?; + let fits = + (ctx.file.class_loader)(crate::class_lookup::Fqn::new(&entity)).is_some_and(|class| { + crate::class_lookup::is_subtype_of(&class, hint, ctx.file.class_loader) + }); + if !fits { + return None; + } + nullable |= resolved.non_null_type().is_some(); + } + let entity = PhpType::named(entity); + Some(if nullable { + PhpType::nullable(entity) + } else { + entity + }) +} + /// The scope entry a parameter gets from the argument the call site /// passed it, or `None` when the declaration already says as much. /// diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index d914dd22c..f72a38f66 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -78,7 +78,9 @@ pub(crate) fn resolve_laravel_string_key( | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute - | StringKeyKind::DrupalEntityType => backend.symfony_declaration_locations(*kind, key), + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute + | StringKeyKind::DrupalRouteParameter => backend.symfony_declaration_locations(*kind, key), StringKeyKind::YiiView => backend.resolve_yii_view_definitions(key), StringKeyKind::TwigBlock => backend.twig_block_definitions(uri, None, key), StringKeyKind::TwigVariable => backend.twig_variable_definitions(uri, None, key), @@ -314,7 +316,9 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute - | StringKeyKind::DrupalEntityType => { + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute + | StringKeyKind::DrupalRouteParameter => { return find_string_key_usages(kind, key, backend, snapshot, include_declaration); } // The catalogues named after a domain declare it, and the paths of diff --git a/tests/integration/drupal_mixed_workspace.rs b/tests/integration/drupal_mixed_workspace.rs index f2e93fbbb..412f2a7b6 100644 --- a/tests/integration/drupal_mixed_workspace.rs +++ b/tests/integration/drupal_mixed_workspace.rs @@ -109,6 +109,55 @@ final class Notes { const DRUPAL_NOTE_PATH: &str = "drupal/web/modules/custom/acme/src/Entity/Note.php"; const DRUPAL_NOTES_PATH: &str = "drupal/web/modules/custom/acme/src/Notes.php"; +/// Each framework's routes, and the templates and calls that name them; +/// and a Twig filter a Drupal module declares. +const ROUTE_FILES: &[(&str, &str)] = &[ + (LARAVEL_ROUTES_PATH, LARAVEL_ROUTES), + (LARAVEL_LINKS_PATH, LARAVEL_LINKS), + (DRUPAL_ROUTING_PATH, DRUPAL_ROUTING), + (DRUPAL_TEMPLATE_PATH, DRUPAL_TEMPLATE), + (DRUPAL_TWIG_EXTENSION_PATH, DRUPAL_TWIG_EXTENSION), + (SYMFONY_ROUTES_PATH, SYMFONY_ROUTES), + (SYMFONY_TEMPLATE_PATH, SYMFONY_TEMPLATE), +]; + +const LARAVEL_ROUTES: &str = "name('home');\n"; +const LARAVEL_LINKS: &str = "Home\n"; +const SYMFONY_ROUTES_PATH: &str = "symfony/config/routes.yaml"; +const SYMFONY_TEMPLATE_PATH: &str = "symfony/templates/home.html.twig"; + // ─── Symfony, in `symfony/` ───────────────────────────────────────────────── const SYMFONY_COMPOSER: &str = r#"{ @@ -285,13 +334,14 @@ fn mixed_files() -> Vec<(String, &'static str)> { files.extend( ENTITY_FILES .iter() + .chain(ROUTE_FILES) .map(|(path, content)| (path.to_string(), *content)), ); files } -/// An initialized workspace holding `files`, with every PHP and YAML file -/// among them open. +/// An initialized workspace holding `files`, with every PHP, YAML, and +/// Twig file among them open. async fn workspace(files: &[(String, &str)]) -> (Backend, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); for (path, content) in files { @@ -306,6 +356,8 @@ async fn workspace(files: &[(String, &str)]) -> (Backend, tempfile::TempDir) { "php" } else if path.ends_with(".yml") || path.ends_with(".yaml") { "yaml" + } else if path.ends_with(".twig") { + "twig" } else { continue; }; @@ -542,6 +594,66 @@ async fn a_symfony_container_builder_still_names_services_beside_a_drupal_site() ); } +/// A Symfony template outside the Drupal web root names Symfony's routes, +/// and Laravel's `route()` names Laravel's. +#[tokio::test] +async fn symfony_and_laravel_routes_keep_working_beside_a_drupal_site() { + let (backend, dir) = workspace(&mixed_files()).await; + + let found = definitions( + &backend, + &uri_for(&dir, SYMFONY_TEMPLATE_PATH), + position_in(SYMFONY_TEMPLATE, "path('app_home')", 7), + ) + .await; + assert_eq!( + found, + vec![( + uri_for(&dir, SYMFONY_ROUTES_PATH), + line_of(SYMFONY_ROUTES, "app_home:") + )] + ); + + let found = definitions( + &backend, + &uri_for(&dir, LARAVEL_LINKS_PATH), + position_in(LARAVEL_LINKS, "route('home')", 8), + ) + .await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].0, uri_for(&dir, LARAVEL_ROUTES_PATH)); +} + +/// A Drupal template names Drupal's routes, and still completes the Twig +/// filters an extension declares. +#[tokio::test] +async fn drupal_templates_work_in_the_drupal_site() { + let (backend, dir) = workspace(&mixed_files()).await; + let template = uri_for(&dir, DRUPAL_TEMPLATE_PATH); + + let found = definitions( + &backend, + &template, + position_in(DRUPAL_TEMPLATE, "path('acme.greeting')", 8), + ) + .await; + assert_eq!( + found, + vec![( + uri_for(&dir, DRUPAL_ROUTING_PATH), + line_of(DRUPAL_ROUTING, "acme.greeting:") + )] + ); + + let labels = completion_labels( + &backend, + &template, + position_in(DRUPAL_TEMPLATE, "|acme_", 6), + ) + .await; + assert!(labels.contains(&"acme_shout".to_string()), "{labels:?}"); +} + /// A Drupal lookup reads every project's service declarations, the /// Symfony application's PHP configurator among them; confirming its calls /// from the Drupal site must not resolve them against the Drupal site's diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs new file mode 100644 index 000000000..659149dcc --- /dev/null +++ b/tests/integration/drupal_routes.rs @@ -0,0 +1,1620 @@ +//! Drupal's route names: the keys a routing file declares, read by the +//! calls that build a URL or a redirect to a route. + +use crate::common::{completion_response, hover_text_at, response_labels}; +use crate::drupal_services::{ + definitions_at, position_in, references_at, uri_for, workspace, workspace_in, +}; +use tower_lsp_server::gen_lsp_types::Position; + +/// Just enough of core's routing API, where Composer would put core. +const ROUTING_STUBS: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal/Core/Url.php", + "setRedirect('acme.greeting'); + $this->redirect('acme.greeting'); + $this->urls->generateFromRoute('acme.greeting'); + $this->access->checkNamedRoute('acme.greeting'); + $this->routes->getRouteByName('acme.greeting'); + $other->setRedirect('acme.greeting'); + } +} + +final class Redirect { + public function setRedirect(string $path): void {} +} +"#; + +const CONSUMER_PATH: &str = "web/modules/custom/acme/src/Controller/GreetingController.php"; + +fn files<'a>(extra: &[(&'a str, &'a str)]) -> Vec<(&'a str, &'a str)> { + ROUTING_STUBS + .iter() + .copied() + .chain(extra.iter().copied()) + .collect() +} + +#[tokio::test] +async fn a_route_name_goes_to_its_routing_file_and_lists_its_readers() { + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (CONSUMER_PATH, CONSUMER), + ])) + .await; + let php = uri_for(&dir, CONSUMER_PATH); + let yaml = uri_for(&dir, ROUTING_PATH); + + let found = definitions_at( + &backend, + &php, + position_in(CONSUMER, "fromRoute('acme.greeting')", 12), + ) + .await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!((&found[0].uri, found[0].range.start.line), (&yaml, 0)); + + let found = references_at(&backend, &yaml, position_in(ROUTING, "acme.greeting", 2)).await; + // Every reader but the redirect entity's own `setRedirect()`. + let mut expected: Vec<_> = (19..=26).map(|line| (php.clone(), line)).collect(); + expected.push((yaml, 0)); + expected.sort(); + assert_eq!(found, expected); + + let position = position_in(CONSUMER, "getRouteByName('acme.greeting')", 18); + let hover = hover_text_at(&backend, &php, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("Route"), "{hover}"); +} + +/// Core sits in a folder the project's `.gitignore` leaves out, and its +/// test modules declare routes no real site has. +#[tokio::test] +async fn core_routes_complete_and_test_modules_do_not() { + let consumer = CONSUMER + .replace("Url::fromRoute('acme.greeting')", "Url::fromRoute('')") + .replace("'Hello', 'acme.greeting'", "'Hello', ''") + .replace("setRedirect('acme.greeting')", "setRedirect('')"); + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ( + "web/core/modules/system/system.routing.yml", + "'':\n path: '/'\nsystem.admin_content:\n path: '/admin/content'\n", + ), + ( + "web/core/modules/system/tests/modules/router_test/router_test.routing.yml", + "router_test.1:\n path: '/router_test/test1'\n", + ), + ( + "web/core/modules/node/node.links.action.yml", + "node.add_page:\n route_name: node.add_page\n appears_on:\n - system.admin_content\n", + ), + ] { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let (backend, dir) = workspace_in( + dir, + &files(&[(ROUTING_PATH, ROUTING), (CONSUMER_PATH, consumer.as_str())]), + ) + .await; + let php = uri_for(&dir, CONSUMER_PATH); + // Finding references indexes the workspace, core's routing files + // included. + references_at( + &backend, + &php, + position_in(&consumer, "new Url('acme.greeting')", 10), + ) + .await; + let system = uri_for(&dir, "web/core/modules/system/system.routing.yml"); + let found = references_at(&backend, &system, Position::new(2, 3)).await; + assert!( + found.contains(&( + uri_for(&dir, "web/core/modules/node/node.links.action.yml"), + 3 + )), + "{found:?}" + ); + + for (needle, inside) in [ + ("fromRoute('')", 11), + ("'Hello', ''", 10), + ("$form_state->setRedirect('')", 26), + ] { + let position = position_in(&consumer, needle, inside); + let labels = response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ); + for declared in ["acme.greeting", "", "system.admin_content"] { + assert!( + labels.contains(&declared.to_string()), + "{needle}: {labels:?}" + ); + } + assert!(!labels.contains(&"router_test.1".to_string()), "{labels:?}"); + assert!( + !labels.contains(&"route_callbacks".to_string()), + "{labels:?}" + ); + } +} + +/// `route_name:` names a route like the positional argument does. +#[tokio::test] +async fn a_named_route_argument_is_read() { + let consumer = r#"{{ url('acme.about') }}\n{{ path('') }}\n"; +const TEMPLATE_PATH: &str = "web/modules/custom/acme/templates/acme-notes.html.twig"; + +/// Drupal reads `#[Route]` on the classes of an extension's `Controller` +/// namespace, and its Twig's `path()` and `url()` read route names. +#[tokio::test] +async fn route_attributes_declare_and_templates_read() { + let (backend, dir) = workspace(&files(&[ + ROUTE_ATTRIBUTE, + (ATTRIBUTE_PATH, ATTRIBUTE_CONTROLLER), + ( + "web/modules/custom/acme/src/Form/Elsewhere.php", + NOT_A_CONTROLLER, + ), + (TEMPLATE_PATH, TEMPLATE), + ])) + .await; + let php = uri_for(&dir, ATTRIBUTE_PATH); + let twig = uri_for(&dir, TEMPLATE_PATH); + + for (needle, line) in [("path('acme.notes.list')", 7), ("url('acme.about')", 14)] { + let found = definitions_at( + &backend, + &twig, + position_in(TEMPLATE, needle, needle.find('\'').unwrap() + 2), + ) + .await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!( + (&found[0].uri, found[0].range.start.line), + (&php, line), + "{needle}" + ); + } + + let position = position_in(TEMPLATE, "path('')", 6); + let labels = response_labels( + completion_response(&backend, &twig, position.line, position.character, None).await, + ); + let mut routes: Vec<_> = labels + .iter() + .filter(|label| label.starts_with("acme.")) + .collect(); + routes.sort(); + assert_eq!(routes, ["acme.about", "acme.notes.list"]); +} + +const BUILDER: &str = r#" Vec { + let (backend, dir) = workspace(&files(files_in)).await; + let content = files_in + .iter() + .find(|(file, _)| *file == path) + .map(|(_, content)| *content) + .unwrap(); + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri_for(&dir, path).as_str(), content, &mut diagnostics); + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(tower_lsp_server::gen_lsp_types::Code::String(code)) + if code == "invalid_drupal_route" + ) + }) + .map(|diagnostic| crate::common::message_text(&diagnostic.message).to_string()) + .collect() +} + +const CHECKED: &str = r#"id(); + if ($canonical_route = $this->getCanonicalRoute($entity_type)) { + $collection->add("entity.{$entity_type_id}.canonical", $canonical_route); + } + if ($edit_route = $this->getEditFormRoute($entity_type)) { + $collection->add("entity.{$entity_type_id}.edit_form", $edit_route); + } + return $collection; + } + + protected function getCanonicalRoute($entity_type): ?Route { + return new Route('/canonical'); + } + + protected function getEditFormRoute($entity_type): ?Route { + return new Route('/edit'); + } +} +"#; + +const ENTITY_AND_VIEWS: &str = r#".`: a name of neither shape is +/// reported. +#[tokio::test] +async fn entity_and_views_routes_are_judged_by_their_shape() { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ( + "web/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php", + ENTITY_ROUTE_PROVIDER, + ), + ] { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let path = "web/modules/custom/acme/src/EntityAndViews.php"; + let (backend, dir) = workspace_in( + dir, + &files(&[(ROUTING_PATH, ROUTING), (path, ENTITY_AND_VIEWS)]), + ) + .await; + let php = uri_for(&dir, path); + // Finding references indexes the workspace, core's route providers + // included. + references_at( + &backend, + &php, + position_in(ENTITY_AND_VIEWS, "'entity.nod'", 3), + ) + .await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(php.as_str(), ENTITY_AND_VIEWS, &mut diagnostics); + let reported: Vec<&str> = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(tower_lsp_server::gen_lsp_types::Code::String(code)) + if code == "invalid_drupal_route" + ) + }) + .map(|diagnostic| crate::common::message_text(&diagnostic.message)) + .collect(); + assert_eq!( + reported, + [ + "Unknown route: 'entity'", + "Unknown route: 'entity.nod'", + "Unknown route: 'view'", + "Unknown route: 'view.content'", + ] + ); +} + +/// Test code passes invented route names to mocks, `*.api.php` examples +/// name invented routes, and a project whose routing files were not read +/// has nothing to judge against. +#[tokio::test] +async fn test_code_and_unread_projects_are_not_judged() { + let test_path = "web/modules/custom/acme/tests/src/Unit/CheckedTest.php"; + assert!( + unknown_routes(&[(ROUTING_PATH, ROUTING), (test_path, CHECKED)], test_path) + .await + .is_empty() + ); + let path = "web/modules/custom/acme/src/Checked.php"; + assert!(unknown_routes(&[(path, CHECKED)], path).await.is_empty()); + let api_path = "web/modules/custom/acme/acme.api.php"; + assert!( + unknown_routes(&[(ROUTING_PATH, ROUTING), (api_path, CHECKED)], api_path) + .await + .is_empty() + ); +} + +const PARAMETERS: &str = r#" 'World']); + new Url('acme.greeting', ['name' => 'World']); + Link::createFromRoute('Hello', 'acme.greeting', ['name' => 'World']); + $form_state->setRedirect('acme.greeting', ['name' => 'World']); + $this->redirect('acme.greeting', ['name' => 'World']); + $this->urls->generateFromRoute('acme.greeting', ['name' => 'World']); + $this->access->checkNamedRoute('acme.greeting', ['name' => 'World']); + $other->setRedirect('acme.greeting', ['name' => 'World']); + } +} + +final class Redirect { + public function setRedirect(string $path, array $query = []): void {} +} +"#; + +const PARAMETERS_PATH: &str = "web/modules/custom/acme/src/Controller/Parameters.php"; + +const MENU: &str = "acme.greeting:\n title: 'Greeting'\n route_parameters:\n name: 'World'\n route_name: acme.greeting\n"; +const MENU_PATH: &str = "web/modules/custom/acme/acme.links.menu.yml"; + +/// A key of the parameters a route call or a links file passes goes to +/// the placeholder of the route's path, which lists every one of them. +#[tokio::test] +async fn route_parameters_go_to_the_path_that_declares_them() { + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (PARAMETERS_PATH, PARAMETERS), + (MENU_PATH, MENU), + ])) + .await; + let php = uri_for(&dir, PARAMETERS_PATH); + let yaml = uri_for(&dir, ROUTING_PATH); + let menu = uri_for(&dir, MENU_PATH); + + let found = definitions_at( + &backend, + &php, + position_in(PARAMETERS, "fromRoute('acme.greeting', ['name'", 30), + ) + .await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!( + (&found[0].uri, found[0].range.start), + (&yaml, Position::new(1, 25)) + ); + let found = definitions_at(&backend, &menu, Position::new(3, 5)).await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!((&found[0].uri, found[0].range.start.line), (&yaml, 1)); + + let found = references_at(&backend, &yaml, Position::new(1, 26)).await; + // Every call but the other class's `setRedirect()`. + let mut expected: Vec<_> = (17..=23).map(|line| (php.clone(), line)).collect(); + expected.push((yaml, 1)); + expected.push((menu, 3)); + expected.sort(); + assert_eq!(found, expected); + + let position = position_in(PARAMETERS, "fromRoute('acme.greeting', ['name'", 30); + let hover = hover_text_at(&backend, &php, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("Route parameter"), "{hover}"); + assert!(hover.contains("/acme/greeting/{name}"), "{hover}"); +} + +/// The keys of the parameters complete from the route's path, in every +/// call that takes a route name and its parameters. +#[tokio::test] +async fn route_parameters_complete_from_the_path() { + let consumer = PARAMETERS.replace("['name' => 'World']", "['' => 'World']"); + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (PARAMETERS_PATH, consumer.as_str()), + ])) + .await; + let php = uri_for(&dir, PARAMETERS_PATH); + for (needle, inside) in [ + ("fromRoute('acme.greeting', [''", 29), + ("new Url('acme.greeting', [''", 27), + ("'Hello', 'acme.greeting', [''", 28), + ("$form_state->setRedirect('acme.greeting', [''", 44), + ("$this->redirect('acme.greeting', [''", 35), + ("generateFromRoute('acme.greeting', [''", 37), + ("checkNamedRoute('acme.greeting', [''", 35), + ] { + let position = position_in(&consumer, needle, inside); + let labels = response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ); + assert_eq!(labels, ["name"], "{needle}"); + } + let position = position_in(&consumer, "$other->setRedirect('acme.greeting', [''", 39); + let labels = response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ); + assert!(!labels.contains(&"name".to_string()), "{labels:?}"); +} + +/// Drupal sends a parameter no path declares as the query string, so no +/// key is reported. +#[tokio::test] +async fn route_parameters_no_path_declares_are_not_reported() { + let consumer = PARAMETERS.replace("['name' => 'World']", "['page' => 2]"); + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (PARAMETERS_PATH, consumer.as_str()), + ])) + .await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics( + uri_for(&dir, PARAMETERS_PATH).as_str(), + &consumer, + &mut diagnostics, + ); + assert!( + diagnostics + .iter() + .all(|diagnostic| !crate::common::message_text(&diagnostic.message).contains("page")), + "{diagnostics:?}" + ); +} + +const NOTE: &str = r#" Vec<(&'static str, &'static str)> { + files(&[ + (ENTITY_INTERFACE_PATH, ENTITY_INTERFACE), + (CONTENT_ENTITY_INTERFACE_PATH, CONTENT_ENTITY_INTERFACE), + (CONTENT_ENTITY_BASE_PATH, CONTENT_ENTITY_BASE), + (PHP_ITERATION_PATH, PHP_ITERATION), + (USER_INTERFACE_PATH, USER_INTERFACE), + (NOTE_PATH, NOTE), + ("web/modules/custom/acme/acme.routing.yml", NOTE_ROUTING), + (PAGE_CONTROLLER_PATH, PAGE_CONTROLLER), + (NOTE_FORM_PATH, NOTE_FORM), + ROUTE_ATTRIBUTE, + (ATTRIBUTE_NOTE_CONTROLLER_PATH, ATTRIBUTE_NOTE_CONTROLLER), + ]) +} + +/// What hovering the variable just before `marker` in `content` shows. +async fn hover_before( + backend: &phpantom_lsp::Backend, + uri: &tower_lsp_server::gen_lsp_types::Uri, + content: &str, + marker: &str, +) -> String { + let position = position_in(content, marker, 2); + hover_text_at(backend, uri, position.line, position.character) + .await + .unwrap_or_default() +} + +/// A controller's parameter is the entity Drupal loads for it only where +/// Drupal upcasts it: a type the route declares, a hint the entity class +/// fits, or the entity type an `_entity_view` or `_entity_form` names, for +/// a parameter the path or a default gives a value, and its title callback +/// receives the same. A parameter another converter takes, one nothing +/// upcasts, and one a route reaching the method leaves alone keep their +/// declared type. +#[tokio::test] +async fn controller_parameters_are_the_entities_drupal_upcasts() { + let (backend, dir) = workspace(¬e_files()).await; + let php = uri_for(&dir, PAGE_CONTROLLER_PATH); + for (marker, expected) in [ + ("$acme_note; // view", "$acme_note = Note\n"), + ("$acme_note; // title", "$acme_note = Note\n"), + ("$note; // edit", "$note = Note\n"), + ("$acme_note; // page", "$acme_note = Note\n"), + ("$acme_note; // deleteTitle", "$acme_note = Note\n"), + ("$acme_note; // revisionTitle", "$acme_note = Note\n"), + ( + "$acme_note_revision; // revisionTitle revision", + "$acme_note_revision = Note\n", + ), + ("$note; // latest", "$note = Note\n"), + ("$acme_note; // content", "$acme_note = Note\n"), + // A hint the entity class does not fit blocks upcasting, and a + // method no route reaches keeps its hint. + ("$acme_note; // blocked", "$acme_note = UserInterface\n"), + ("$acme_note; // unrouted", "$acme_note = EntityInterface\n"), + ] { + let text = hover_before(&backend, &php, PAGE_CONTROLLER, marker).await; + assert!(text.contains(expected), "{marker}: {text}"); + } + // A route's `NULL` default leaves the parameter `NULL` when the path + // gives it no value. + let text = hover_before(&backend, &php, PAGE_CONTROLLER, "$acme_note; // optional").await; + assert!( + text.contains("$acme_note = ?Note\n") || text.contains("$acme_note = Note|null\n"), + "{text}" + ); + // Untyped and left alone: by nothing, by a tempstore's own converter, + // by a route that gives it no value, or by one of the routes that + // reach the method. + for (marker, variable) in [ + ("$acme_note; // raw", "$acme_note"), + ("$note; // draft", "$note"), + ("$note; // missing", "$note"), + ("$note; // shared", "$note"), + ] { + let text = hover_before(&backend, &php, PAGE_CONTROLLER, marker).await; + assert!( + text.contains(&format!(" Vec { + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri_for(dir, path).as_str(), content, &mut diagnostics); + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(tower_lsp_server::gen_lsp_types::Code::String(code)) + if code == "invalid_drupal_route_parameter" + ) + }) + .map(|diagnostic| diagnostic.range.start) + .collect() +} + +/// A hint the entity class does not fit leaves Drupal passing the raw ID, +/// which is reported; an untyped parameter, one no route declares, and a +/// method no route reaches are not. +#[tokio::test] +async fn a_hint_that_blocks_upcasting_is_reported() { + let (backend, dir) = workspace(¬e_files()).await; + assert_eq!( + blocked_upcasts(&backend, &dir, PAGE_CONTROLLER_PATH, PAGE_CONTROLLER).await, + [position_in(PAGE_CONTROLLER, "UserInterface $acme_note", 14)] + ); +} + +/// A parameter with its own converter, and a title callback, are not +/// judged. +#[tokio::test] +async fn converters_and_title_callbacks_are_not_judged() { + let mut files = note_files(); + files.extend([ + (TITLE_ROUTING_PATH, TITLE_ROUTING), + (TITLE_CONTROLLER_PATH, TITLE_CONTROLLER), + ]); + let (backend, dir) = workspace(&files).await; + assert_eq!( + blocked_upcasts(&backend, &dir, TITLE_CONTROLLER_PATH, TITLE_CONTROLLER).await, + Vec::::new() + ); +} + +const MEMO: &str = " String { + format!( + " (phpantom_lsp::Backend, tempfile::TempDir) { + workspace(&files(&[ + (ENTITY_INTERFACE_PATH, ENTITY_INTERFACE), + (CONTENT_ENTITY_INTERFACE_PATH, CONTENT_ENTITY_INTERFACE), + (CONTENT_ENTITY_BASE_PATH, CONTENT_ENTITY_BASE), + (PHP_ITERATION_PATH, PHP_ITERATION), + (USER_INTERFACE_PATH, USER_INTERFACE), + (NOTE_PATH, NOTE), + (MEMO_PATH, MEMO), + (BROKEN_PATH, BROKEN), + ROUTE_ATTRIBUTE, + (CHECK_ROUTING_PATH, routing), + (path, php), + ])) + .await +} + +/// The warnings the diagnostics of `CheckController`, holding `methods`, +/// report with `routing` declaring its routes. +async fn check_warnings(routing: &str, methods: &str) -> (String, Vec) { + let php = check_controller(methods); + let (backend, dir) = check_workspace(routing, CHECK_CONTROLLER_PATH, &php).await; + let found = blocked_upcasts(&backend, &dir, CHECK_CONTROLLER_PATH, &php).await; + (php, found) +} + +/// What hovering `$acme_note` before `marker` in `CheckController`, +/// holding `methods`, shows with `routing` declaring its routes. +async fn check_hover(routing: &str, methods: &str, marker: &str) -> String { + let php = check_controller(methods); + let (backend, dir) = check_workspace(routing, CHECK_CONTROLLER_PATH, &php).await; + hover_before( + &backend, + &uri_for(&dir, CHECK_CONTROLLER_PATH), + &php, + marker, + ) + .await +} + +/// Whether `hover` shows `$acme_note` as a `Note` or `null`. +fn is_nullable_note(hover: &str) -> bool { + hover.contains("$acme_note = ?Note\n") || hover.contains("$acme_note = Note|null\n") +} + +/// A `Class::method` controller hinting an entity-type-named parameter +/// with an interface unrelated to the entity class gets the raw ID. +#[tokio::test] +async fn a_controller_hint_unrelated_to_the_entity_is_reported() { + let routing = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n"; + let (php, found) = check_warnings( + routing, + " public function view(UserInterface $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, [position_in(&php, "UserInterface $acme_note", 14)]); +} + +/// A `_form` class's `buildForm()` is reflected like a controller. +#[tokio::test] +async fn a_form_hint_unrelated_to_the_entity_is_reported() { + let routing = "acme_check.form:\n path: '/check/{acme_note}/form'\n defaults:\n _form: '\\Drupal\\acme_check\\Form\\CheckForm'\n"; + let path = "web/modules/custom/acme_check/src/Form/CheckForm.php"; + let php = "::new()); +} + +/// A declared `entity:` type converts the parameter as declared, whatever +/// its hint. +#[tokio::test] +async fn a_declared_parameter_with_an_unrelated_hint_is_not_judged() { + let routing = "acme_check.unrelated:\n path: '/check/{acme_note}/unrelated'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::unrelated'\n options:\n parameters:\n acme_note:\n type: entity:acme_note\n"; + let (_, found) = check_warnings( + routing, + " public function unrelated(UserInterface $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A bundle class hint is not reported, since an alter hook can make it +/// the entity class. +#[tokio::test] +async fn a_bundle_class_hint_is_not_reported() { + let routing = "acme_check.bundle:\n path: '/check/{acme_note}/bundle'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::bundle'\n"; + let (_, found) = check_warnings( + routing, + " public function bundle(Memo $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A docblock that names a bundle class of the entity says more than the +/// entity class Drupal's upcasting gives, and is kept. +#[tokio::test] +async fn a_bundle_class_docblock_wins_over_the_upcast_entity() { + let routing = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let methods = " /** @param Memo $acme_note */\n public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n /** @param Memo $acme_note */\n public function title($acme_note): string {\n $acme_note; // title\n return '';\n }\n"; + for marker in ["$acme_note; // view", "$acme_note; // title"] { + let text = check_hover(routing, methods, marker).await; + assert!(text.contains("$acme_note = Memo\n"), "{marker}: {text}"); + } +} + +/// A title callback is never reported, nor reflected by core. +#[tokio::test] +async fn a_title_callback_is_not_reported() { + let routing = "acme_check.titled:\n path: '/check/{acme_note}/titled'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let (_, found) = check_warnings( + routing, + " public function view($acme_note): array { return []; }\n public function title(UserInterface $acme_note): string { return ''; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A title callback on a route whose controller is a service's method is +/// not reported either. +#[tokio::test] +async fn a_title_callback_beside_a_service_controller_is_not_reported() { + let routing = "acme_check.service:\n path: '/check/{acme_note}/service'\n defaults:\n _controller: 'acme_check.pages:show'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let (_, found) = check_warnings( + routing, + " public function title(UserInterface $acme_note): string { return ''; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A function controller is not judged. +#[tokio::test] +async fn a_function_controller_is_not_judged() { + let routing = "acme_check.function:\n path: '/check/{acme_note}/function'\n defaults:\n _controller: 'acme_check_page'\n"; + let path = "web/modules/custom/acme_check/acme_check.module"; + let php = "::new() + ); +} + +/// A route whose controller class is not found gives its title callback +/// no verdict. +#[tokio::test] +async fn a_route_whose_controller_is_not_found_is_not_judged() { + let routing = "acme_check.missing:\n path: '/check/{acme_note}/missing'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\Missing::view'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let (_, found) = check_warnings( + routing, + " public function title(UserInterface $acme_note): string { return ''; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A hint whose ancestors are not all found is not judged. +#[tokio::test] +async fn a_hint_with_missing_ancestors_is_not_judged() { + let routing = "acme_check.broken:\n path: '/check/{acme_note}/broken'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::broken'\n"; + let (_, found) = check_warnings( + routing, + " public function broken(Broken $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A `NULL` default the path does not override is passed as it is. +#[tokio::test] +async fn a_null_default_outside_the_path_is_not_judged() { + let routing = "acme_check.null:\n path: '/check/null'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n acme_note: NULL\n"; + let (_, found) = check_warnings( + routing, + " public function view(UserInterface $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +const CHECK_VIEW_ROUTING: &str = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n"; + +/// A nullable hint keeps `null` in the upcast type. +#[tokio::test] +async fn an_upcast_parameter_with_a_nullable_hint_may_be_null() { + let text = check_hover( + CHECK_VIEW_ROUTING, + " public function view(?Note $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A parameter defaulting to `NULL` keeps `null` in the upcast type. +#[tokio::test] +async fn an_upcast_parameter_defaulting_to_null_may_be_null() { + let text = check_hover( + CHECK_VIEW_ROUTING, + " public function view(Note $acme_note = NULL): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A routing file's `NULL` default for a path parameter keeps `null` in +/// the upcast type. +#[tokio::test] +async fn an_upcast_parameter_a_route_defaults_to_null_may_be_null() { + let routing = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n acme_note: NULL\n"; + let text = check_hover( + routing, + " public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A `#[Route]`'s `NULL` default keeps `null` in the upcast type. +#[tokio::test] +async fn an_upcast_parameter_a_route_attribute_defaults_to_null_may_be_null() { + let text = check_hover( + "", + " #[Route(path: '/check/{acme_note}', name: 'acme_check.view', defaults: ['acme_note' => NULL])]\n public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A quoted `'~'`, `'null'`, `"null"` or `''` default is a string, not +/// `NULL`. +#[tokio::test] +async fn a_quoted_null_default_is_a_string() { + for value in ["'~'", "'null'", "\"null\"", "''"] { + let routing = format!( + "acme_check.view:\n path: '/check/{{acme_note}}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n acme_note: {value}\n" + ); + let text = check_hover( + &routing, + " public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(text.contains("$acme_note = Note\n"), "{value}: {text}"); + } +} + +const BOOK_CONTROLLER: &str = r#"