diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6a6003b93..7117567d8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Symfony service IDs and container parameters.** In a Symfony project, a service ID or parameter declared in YAML, XML, or a PHP configurator completes, jumps to its declaration, and lists its references wherever configuration or code names it, from `@app.mailer` and `%app.sender%` to `$container->get()` and `#[Autowire]`. An application's own `app.` name that nothing declares is reported. Contributed by @sidux. - **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. - **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 d2b4e338b..07761e386 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -133,11 +133,11 @@ ships, remove every row that names it. | DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium | | DR8 | [Permission and configuration names](todo/drupal.md#dr8-permission-and-configuration-names) | Low-Medium | Medium | | DR15 | [Benchmark for Drupal names](todo/drupal.md#dr15-benchmark-for-drupal-names) (depends on DR2) | Low-Medium | Low | -| DR3 | [Entity storage and entity type IDs](todo/drupal.md#dr3-entity-storage-and-entity-type-ids) | High | Medium-High | | DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High | -| DR4 | [Entity field access](todo/drupal.md#dr4-entity-field-access) (depends on DR3) | Medium-High | High | +| DR4 | [Entity field access](todo/drupal.md#dr4-entity-field-access) | Medium-High | High | | DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium | | DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium | +| DR18 | [Warn on unknown entity type IDs](todo/drupal.md#dr18-warn-on-unknown-entity-type-ids) | Medium | Low-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 28316b353..9a9f4dd4f 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -3,8 +3,9 @@ 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 are read. Nothing reads -Drupal's other declarations yet: entity types, fields, routes, or hooks. +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 requests in [#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the @@ -28,34 +29,9 @@ within the same impact tier. --- -## DR3. Entity storage and entity type IDs - -**Impact: High · Complexity: Medium-High** - -```php -$storage = \Drupal::entityTypeManager()->getStorage('node'); // NodeStorageInterface -$node = $storage->load($nid); // ?NodeInterface -``` - -`getStorage()` returns the generic `EntityStorageInterface`, so every -loaded entity is the generic `EntityInterface` and the methods a node -or user has are reported as unknown. The entity type ID maps to an -entity class and its storage handler through the entity type's -declaration: the `#[ContentEntityType]` / `#[ConfigEntityType]` -attribute, or the annotation older code uses. Read those statically, -type `getStorage()`, `load()`, `loadMultiple()`, and `create()` from -them, and complete and navigate entity type IDs. Entity queries -(`\Drupal::entityQuery('node')`, `$storage->getQuery()`) take the same -IDs and return IDs of that entity type. - -**Where to look:** PHPStan's Drupal extension (its entity data -repository and the storage return-type extensions). - ---- - ## DR4. Entity field access -**Impact: Medium-High · Complexity: High** (depends on DR3) +**Impact: Medium-High · Complexity: High** ```php $tags = $node->field_tags; // FieldItemListInterface @@ -349,6 +325,23 @@ lists, so an ID such a provider may register must not warn. --- +## DR18. Warn on unknown entity type IDs + +**Impact: Medium · Complexity: Low-Medium** + +```php +$storage = \Drupal::entityTypeManager()->getStorage('nod'); +``` + +An entity type ID no entity class declares is a typo or a module the +site lacks, and the entity type manager throws for it at run time. +Report one where `getStorage()`, `\Drupal::entityQuery()`, and the other +calls that take an entity type ID take it. A module can add entity types +in code from `hook_entity_type_build()`, which no class declares, so a +site where an implementation of that hook may add one stays silent. + +--- + ## 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 33a1baa32..1cb635f2a 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -30,6 +30,16 @@ Run `composer install` here first. Composer puts core and contrib into `core.services.yml`. Complete an ID inside `\Drupal::service('')`, `$container->get('')`, or after `@` in `acme.services.yml`, and find the references of a service from its declaration. +- Entity type IDs navigate between the entity class that declares them and + the code that asks the entity type manager for them, and type what that + code gets back. In `web/modules/custom/acme/src/NoteCounter.php`, hover + `$storage` and `$note` in `first()`, typed as the storage and as `Note`, + or go to the definition of `'acme_note'`, which lands on the `id` in + `Note`'s attribute. Complete an ID inside `getStorage('')`; core's entity + types are offered too. In `ids()` and `count()`, hover `$ids` and + `$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. ## What the Symfony support already reads diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 3d84d4855..16113fe38 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -26,7 +26,10 @@ use Drupal\Core\DependencyInjection\YamlFileLoader; use Drupal\Core\Entity\Attribute\ContentEntityType; use Drupal\Core\Entity\ContentEntityBase; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Entity\EntityTypeManager; +use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\Extension\InfoParser; use Drupal\Core\Hook\Attribute\Hook; @@ -125,6 +128,16 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string check("acme_note's label key is the title base field", ($noteType['entity_keys']['label'] ?? null) === 'title'); check('Note is a content entity', $note->isSubclassOf(ContentEntityBase::class)); check('Note declares its own base fields', $note->getMethod('baseFieldDefinitions')->getDeclaringClass()->getName() === Note::class); +// Attribute discovery tells the attribute which class it is on. +$noteAttribute = $note->getAttributes(ContentEntityType::class)[0]->newInstance(); +$noteAttribute->setClass(Note::class); +$noteDefinition = $noteAttribute->get(); +check("acme_note's storage, which NoteCounter::first() loads from, is core's SQL storage", $noteDefinition->getStorageClass() === SqlContentEntityStorage::class); +check('acme_note loads Note', $noteDefinition->getClass() === Note::class); +check("NoteCounter::ids() starts an entity query on acme_note", str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "\\Drupal::entityQuery('acme_note')")); +check('acme_note is a content entity type, whose entity query gives IDs under integer keys', $noteDefinition instanceof ContentEntityTypeInterface && $noteDefinition->entityClassImplements(ContentEntityInterface::class)); +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');")); // ─── Hooks and theme hooks ────────────────────────────────────────────────── diff --git a/examples/drupal/web/modules/custom/acme/src/NoteCounter.php b/examples/drupal/web/modules/custom/acme/src/NoteCounter.php index 173d406d0..f9efd5a07 100644 --- a/examples/drupal/web/modules/custom/acme/src/NoteCounter.php +++ b/examples/drupal/web/modules/custom/acme/src/NoteCounter.php @@ -4,6 +4,7 @@ namespace Drupal\acme; +use Drupal\acme\Entity\Note; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -20,18 +21,51 @@ public function __construct( public static function create(ContainerInterface $container): static { // Try: go to the definition of 'entity_type.manager', which lands in - // core's core.services.yml. Complete a service ID inside + // core's core.services.yml, or hover `$manager`, typed by the class + // core declares for it. Complete a service ID inside // `$container->get('')`. - return new static($container->get('entity_type.manager')); + $manager = $container->get('entity_type.manager'); + + return new static($manager); + } + + public function first(): ?Note + { + // Try: hover `$storage`, the storage of the acme_note entity type, + // and `$note`, typed as `Note`, the class that declares it. Go to the + // definition of 'acme_note', which lands on the `id` in Note's + // attribute, or complete an entity type ID inside `getStorage('')`. + $storage = $this->entityTypeManager->getStorage('acme_note'); + $note = $storage->load(1); + + return $note; } public function count(): int { - return (int) $this->entityTypeManager + // Try: hover `$count`. An entity query that counts gives the number + // of matches, and keeps counting through the methods it chains. + $count = $this->entityTypeManager ->getStorage('acme_note') ->getQuery() ->accessCheck(false) ->count() ->execute(); + + return $count; + } + + /** + * @return array + */ + public function ids(): array + { + // Try: hover `$ids`, the IDs of the notes, which a content entity + // query gives as strings under integer keys. Go to the definition of + // 'acme_note', or complete an entity type ID inside + // `\Drupal::entityQuery('')`. + $ids = \Drupal::entityQuery('acme_note')->accessCheck(false)->execute(); + + return $ids; } } diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index 6fc50edf2..ab5a86159 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -41,6 +41,7 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { StringKeyKind::GateAbility => CompletionItemKind::Method, StringKeyKind::Env => CompletionItemKind::Constant, StringKeyKind::ServiceId => CompletionItemKind::Reference, + StringKeyKind::DrupalEntityType => CompletionItemKind::Class, StringKeyKind::ContainerParameter => CompletionItemKind::Property, StringKeyKind::SymfonyConfigKey => CompletionItemKind::Field, StringKeyKind::SymfonyEvent | StringKeyKind::WordPressHook => CompletionItemKind::Event, @@ -181,7 +182,8 @@ impl Backend { | StringKeyKind::TwigTemplate | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus - | StringKeyKind::YiiRoute => self.symfony_declared_names(*kind).into(), + | StringKeyKind::YiiRoute + | StringKeyKind::DrupalEntityType => self.symfony_declared_names(*kind).into(), StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => { self.twig_callable_names(*kind).into() } diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index 6c740efda..057b981d9 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -235,12 +235,17 @@ impl Backend { }; }; if call.is_static { - // `\Drupal::service('|')` and `\Drupal::hasService('|')`. + // `\Drupal::service('|')`, `\Drupal::hasService('|')`, and + // `\Drupal::entityQuery('|')`. + if call.arg_index != 0 || !self.is_drupal_project() { + return None; + } let class = ctx.resolve_name_at(subject, cursor as u32); - return (call.arg_index == 0 - && self.is_drupal_project() - && crate::drupal::is_service_lookup(&class, &call.method_name)) - .then_some(StringKeyKind::ServiceId); + if crate::drupal::is_service_lookup(&class, &call.method_name) { + return Some(StringKeyKind::ServiceId); + } + return crate::drupal::is_entity_query(&class, &call.method_name) + .then_some(StringKeyKind::DrupalEntityType); } self.receiver_call_key( subject, diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index 8a4211fb3..fd5c907da 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -91,8 +91,10 @@ impl CheckedStringKind { // since another plugin or a later core may fire it. A block // name that nothing above defines starts a new block. A // template variable no render was found to pass may be passed - // by one that builds its context at runtime. + // by one that builds its context at runtime. A module can add + // an entity type in PHP, through `hook_entity_type_build()`. StringKeyKind::WordPressHook + | StringKeyKind::DrupalEntityType | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 7e4ff16c3..2679106db 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -18,6 +18,8 @@ use parking_lot::RwLock; pub(crate) mod types; use crate::Backend; +use crate::atom::Atom; +use crate::symbol_map::{StringKeyKind, SymbolKind}; /// The web roots of the workspace's Drupal projects, and the declaration /// files found under them. @@ -69,6 +71,16 @@ pub(crate) fn is_service_lookup(class: &str, method: &str) -> bool { && (method.eq_ignore_ascii_case("service") || method.eq_ignore_ascii_case("hasService")) } +/// Whether a static call of `method` on `class`, a resolved class name, +/// starts an entity query on an entity type ID: `\Drupal::entityQuery()` or +/// `\Drupal::entityQueryAggregate()`. Only the global `Drupal` class +/// counts. +pub(crate) fn is_entity_query(class: &str, method: &str) -> bool { + class.strip_prefix('\\').unwrap_or(class) == "Drupal" + && (method.eq_ignore_ascii_case("entityQuery") + || method.eq_ignore_ascii_case("entityQueryAggregate")) +} + /// Whether `path` is a file Drupal reads declarations from. pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool { path.file_name() @@ -155,6 +167,56 @@ impl Backend { }) } + /// The entity class and storage handler of each declaration of the + /// entity type `id`; a storage of `None` for a declaration that names + /// none and has no default. + pub(crate) fn drupal_entity_types(&self, id: &str) -> Vec<(Atom, Option)> { + let mut found: Vec<(Atom, Option)> = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + for site in &map.entity_types { + let at = map.spans.partition_point(|span| span.start < site.start); + let declares_id = map.spans[at..] + .iter() + .take_while(|span| span.start == site.start) + .any(|span| { + matches!( + &span.kind, + SymbolKind::StringKey { + kind: StringKeyKind::DrupalEntityType, + key, + .. + } if key == id + ) + }); + if declares_id + && !found + .iter() + .any(|(class, storage)| *class == site.class && *storage == site.storage) + { + found.push((site.class, site.storage)); + } + } + }); + found + } + + /// 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 { + if !self.is_drupal_project() { + return false; + } + let Some(path) = crate::util::uri_to_path(uri) else { + return false; + }; + self.drupal + .web_roots + .read() + .iter() + .any(|root| path.starts_with(root) && is_test_fixture(&path, root)) + && !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. diff --git a/src/drupal/types.rs b/src/drupal/types.rs index 617705851..9135c33b9 100644 --- a/src/drupal/types.rs +++ b/src/drupal/types.rs @@ -1,14 +1,33 @@ //! The types Drupal's own declarations give: a service `\Drupal::service()` -//! looks up. +//! looks up, the storage `getStorage()` gives for an entity type ID, the +//! entities that storage loads, and the IDs an entity query finds. //! //! Shared by the two places the type engine resolves a call: an assignment -//! (`$x = …`) and a chain (`…->method()`). +//! (`$x = …`) and a chain (`…->load(1)->`). The storage is tagged with the +//! entity class as a type argument (`NodeStorage`), the way +//! phpstan-drupal's `EntityStorageType` carries the entity type, so a load +//! on it, chained or through a variable, knows what it loads. An entity +//! query is tagged the same way (`QueryInterface`), and a counting one +//! carries a second argument (`QueryInterface`); its fluent +//! methods return `$this`, which the engine already resolves to the tagged +//! receiver, so only `count()` and `execute()` are read here. use std::sync::Arc; use crate::Backend; +use crate::atom::Atom; use crate::class_lookup::{Fqn, ResolvedClassLoader}; -use crate::types::ClassInfo; +use crate::php_type::{PhpType, TypeKind}; +use crate::types::{ClassInfo, ResolvedType}; + +use crate::symbol_map::DRUPAL_ENTITY_TYPE_MANAGER as ENTITY_TYPE_MANAGER; + +const ENTITY_STORAGE: &str = "Drupal\\Core\\Entity\\EntityStorageInterface"; +const ENTITY_QUERY: &str = "Drupal\\Core\\Entity\\Query\\QueryInterface"; +const CONFIG_ENTITY: &str = "Drupal\\Core\\Config\\Entity\\ConfigEntityInterface"; +const CONTENT_ENTITY: &str = "Drupal\\Core\\Entity\\ContentEntityInterface"; +/// The second type argument of a query tag once `count()` was called on it. +const COUNT_MARKER: &str = "count"; /// The classes `\Drupal::service(id)` returns, when `class` is the global /// `Drupal` class and the project declares `id`. @@ -32,3 +51,263 @@ pub(crate) fn service_classes( .collect(); (!classes.is_empty()).then_some(classes) } + +/// What `getStorage(id)` on `owner`, an entity type manager, returns: each +/// declaration's storage handler class with its tagged type. `None` for +/// another call or an ID the project does not declare, so the call keeps the +/// declared `EntityStorageInterface`. +pub(crate) fn storage_type( + backend: &Backend, + owner: &ClassInfo, + method_name: &str, + id: &str, + class_loader: &ResolvedClassLoader<'_>, +) -> Option, PhpType)>> { + if !method_name.eq_ignore_ascii_case("getStorage") + || !backend.is_drupal_project() + || !crate::class_lookup::is_subtype_of(owner, ENTITY_TYPE_MANAGER, class_loader) + { + return None; + } + let storages: Vec<(Arc, PhpType)> = backend + .drupal_entity_types(id) + .into_iter() + .filter_map(|(entity, storage)| { + let storage = storage?; + // A storage class the project lacks would hide the declared type. + let class = class_loader(Fqn::new(&storage))?; + Some(( + class, + PhpType::generic(storage, vec![PhpType::named(entity)]), + )) + }) + .collect(); + (!storages.is_empty()).then_some(storages) +} + +/// How many entities a storage method returns, if it returns any. +enum Loads { + /// `load()`, `loadUnchanged()`, `loadRevision()`, and the other single + /// loads: one, or `null`. + MaybeOne, + /// `create()`, `createWithSampleValues()`: one. + One, + /// `loadMultiple()`, `loadByProperties()`, and the other multiple + /// loads: an array keyed by ID, or by revision ID. + Many, +} + +fn loads(method_name: &str) -> Option { + let is = |name: &str| method_name.eq_ignore_ascii_case(name); + if is("load") + || is("loadUnchanged") + || is("loadRevision") + || is("loadRevisionUnchanged") + || is("loadOverrideFree") + { + Some(Loads::MaybeOne) + } else if is("create") || is("createWithSampleValues") { + Some(Loads::One) + } else if is("loadMultiple") + || is("loadByProperties") + || is("loadMultipleRevisions") + || is("loadMultipleOverrideFree") + { + Some(Loads::Many) + } else { + None + } +} + +/// What a load on a storage tagged by [`storage_type`] returns: the entity +/// class the tag names. `None` for another call or an untagged receiver. +pub(crate) fn load_type( + method_name: &str, + receiver: &[ResolvedType], + class_loader: &ResolvedClassLoader<'_>, +) -> Option { + let loads = loads(method_name)?; + let mut entities: Vec = Vec::new(); + // Every class the receiver can be has to be a tagged storage: a union + // with anything else keeps its own branches' types. + for resolved in receiver { + let Some(class) = resolved.class_info.as_deref() else { + continue; + }; + let TypeKind::Generic(generic) = resolved.type_string.kind() else { + return None; + }; + let [entity] = generic.args.as_slice() else { + return None; + }; + let TypeKind::Named(name) = entity.kind() else { + return None; + }; + if !crate::class_lookup::is_subtype_of(class, ENTITY_STORAGE, class_loader) { + return None; + } + if !entities.contains(name) { + entities.push(*name); + } + } + if entities.is_empty() { + return None; + } + let entity = PhpType::union(entities.into_iter().map(PhpType::named).collect()); + Some(match loads { + Loads::MaybeOne => PhpType::nullable(entity), + Loads::One => entity, + Loads::Many => PhpType::generic_array( + PhpType::union(vec![PhpType::int(), PhpType::string()]), + entity, + ), + }) +} + +/// The entity classes a tag's type argument names: one class, or a union of +/// them when the ID has more than one declaration. +fn tagged_entities(argument: &PhpType) -> Option> { + match argument.kind() { + TypeKind::Named(name) => Some(vec![*name]), + TypeKind::Union(members) => members + .iter() + .map(|member| match member.kind() { + TypeKind::Named(name) => Some(*name), + _ => None, + }) + .collect(), + _ => None, + } +} + +/// The query tag for `entities`, counting or not. +fn query_tag(entities: Vec, counting: bool) -> PhpType { + let mut arguments = vec![PhpType::union( + entities.into_iter().map(PhpType::named).collect(), + )]; + if counting { + arguments.push(PhpType::literal_string_value(COUNT_MARKER)); + } + PhpType::generic(ENTITY_QUERY, arguments) +} + +/// What `\Drupal::entityQuery(id)` returns: a query tagged with the entity +/// classes the project declares `id` for. `None` for another call, an +/// aggregate query, which keeps core's type, or an ID nothing declares. +pub(crate) fn entity_query_type( + backend: &Backend, + class: &str, + method_name: &str, + id: &str, +) -> Option { + if !method_name.eq_ignore_ascii_case("entityQuery") + || class.strip_prefix('\\').unwrap_or(class) != "Drupal" + || !backend.is_drupal_project() + { + return None; + } + let mut entities: Vec = Vec::new(); + for (entity, _) in backend.drupal_entity_types(id) { + if !entities.contains(&entity) { + entities.push(entity); + } + } + (!entities.is_empty()).then(|| query_tag(entities, false)) +} + +/// What a call on a tagged storage or a tagged query returns, the way +/// phpstan-drupal types entity queries: +/// +/// - `getQuery()` on a storage tagged by [`storage_type`]: a query tagged +/// with the same entities; +/// - `count()` on a tagged query: the query, now counting; +/// - `execute()` on a tagged query: the number of matches when it counts, +/// otherwise the IDs, keyed by revision ID, or by ID for an entity type +/// without revisions (`array`), for a content entity and by +/// ID (`array`) for a configuration entity. +/// +/// `None` for another call or an untagged receiver, and for the IDs of an +/// entity class that is not found, is neither a content nor a configuration +/// entity, or is not of the same kind as the others the query may be on: +/// those keep core's `int|array`. +pub(crate) fn query_call_type( + method_name: &str, + receiver: &[ResolvedType], + class_loader: &ResolvedClassLoader<'_>, +) -> Option { + let is = |name: &str| method_name.eq_ignore_ascii_case(name); + let (base, wants) = if is("getQuery") { + (ENTITY_STORAGE, Query::Start) + } else if is("count") { + (ENTITY_QUERY, Query::Count) + } else if is("execute") { + (ENTITY_QUERY, Query::Execute) + } else { + return None; + }; + let mut entities: Vec = Vec::new(); + let mut counting = false; + // Every class the receiver can be has to be tagged: a union with + // anything else keeps its own branches' types. + for resolved in receiver { + let Some(class) = resolved.class_info.as_deref() else { + continue; + }; + let TypeKind::Generic(generic) = resolved.type_string.kind() else { + return None; + }; + if !crate::class_lookup::is_subtype_of(class, base, class_loader) { + return None; + } + let (entity, rest) = generic.args.split_first()?; + for name in tagged_entities(entity)? { + if !entities.contains(&name) { + entities.push(name); + } + } + counting |= rest.iter().any(|argument| { + matches!(argument.kind(), TypeKind::Literal(literal) + if literal.string_content().as_deref() == Some(COUNT_MARKER)) + }); + } + if entities.is_empty() { + return None; + } + Some(match wants { + Query::Start => query_tag(entities, false), + Query::Count => query_tag(entities, true), + Query::Execute if counting => PhpType::int_range("0", "max"), + Query::Execute => { + // Whether each entity class is a configuration entity (`true`) + // or a content entity (`false`). + let kind_of = |entity: &Atom| { + let class = class_loader(Fqn::new(entity))?; + if crate::class_lookup::is_subtype_of(&class, CONFIG_ENTITY, class_loader) { + Some(true) + } else if crate::class_lookup::is_subtype_of(&class, CONTENT_ENTITY, class_loader) { + Some(false) + } else { + None + } + }; + let mut kinds = entities.iter().map(kind_of); + let is_config = kinds.next().flatten()?; + if !kinds.all(|kind| kind == Some(is_config)) { + return None; + } + let key = if is_config { + PhpType::string() + } else { + PhpType::int() + }; + PhpType::generic_array(key, PhpType::string()) + } + }) +} + +/// Which call on a tagged storage or query [`query_call_type`] answers. +enum Query { + Start, + Count, + Execute, +} diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index e1f255945..ad3e89e4d 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -290,9 +290,11 @@ impl Backend { | StringKeyKind::ContainerParameter | StringKeyKind::SymfonyRoute | StringKeyKind::SymfonyTranslation - | StringKeyKind::TranslationDomain => { + | StringKeyKind::TranslationDomain + | StringKeyKind::DrupalEntityType => { let label = match kind { StringKeyKind::ServiceId => "Service", + StringKeyKind::DrupalEntityType => "Entity type", StringKeyKind::ContainerParameter => "Parameter", StringKeyKind::SymfonyRoute => "Route", StringKeyKind::SymfonyTranslation => "Translation", diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 43e914ff1..189bc2e95 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1401,6 +1401,7 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { sym.add( sm.container_values.capacity() * size_of::(), ); + sym.add(sm.entity_types.capacity() * size_of::()); 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/reference_index.rs b/src/reference_index.rs index 941a9d180..24ff48483 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -743,6 +743,7 @@ impl Backend { let reads_container = self.reads_service_container(); let is_twig = self.is_twig_project(); let is_yii = self.is_yii_project(); + let is_drupal = self.is_drupal_project(); let enabled = |kind: StringKeyKind| match kind.framework() { KeyFramework::Laravel => is_laravel, KeyFramework::ServiceContainer => reads_container, @@ -750,6 +751,7 @@ impl Backend { KeyFramework::Twig => is_twig, KeyFramework::Yii => is_yii, KeyFramework::WordPress => false, + KeyFramework::Drupal => is_drupal, }; for site in &symbol_map.typed_receiver_sites { if !site.receiver.names_any(enabled) { diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index 03a0f679e..20e6724b5 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -23,6 +23,7 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC emit_twig_callable_attributes(class, ctx); emit_twig_global_declarations(class, ctx); emit_yii_route_argument_spans(class, ctx); + emit_drupal_entity_type_spans(class, ctx); if let Some(ref extends) = class.extends { for ident in extends.types.iter() { diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs new file mode 100644 index 000000000..16ee49651 --- /dev/null +++ b/src/symbol_map/extraction/drupal.rs @@ -0,0 +1,376 @@ +//! The entity types a Drupal entity class declares, by attribute or by the +//! annotation older code uses (see [`crate::drupal`]). + +use super::*; +use crate::symbol_map::{EntityTypeSite, StringKeyKind}; +use crate::virtual_members::laravel::helpers::resolved_string_literal; + +const CONTENT_STORAGE: &str = "Drupal\\Core\\Entity\\Sql\\SqlContentEntityStorage"; +const CONFIG_STORAGE: &str = "Drupal\\Core\\Config\\Entity\\ConfigEntityStorage"; + +/// An entity type attribute and the annotation older code writes instead, +/// with the storage a declaration that names none gets. +struct EntityTypeKind { + attribute: &'static str, + /// The annotation's opening, matched by short name, the way Drupal's + /// annotation discovery reads it. + annotation: &'static str, + default_storage: Option<&'static str>, +} + +const ENTITY_TYPE_KINDS: [EntityTypeKind; 3] = [ + EntityTypeKind { + attribute: "Drupal\\Core\\Entity\\Attribute\\ContentEntityType", + annotation: "@ContentEntityType(", + default_storage: Some(CONTENT_STORAGE), + }, + EntityTypeKind { + attribute: "Drupal\\Core\\Entity\\Attribute\\ConfigEntityType", + annotation: "@ConfigEntityType(", + default_storage: Some(CONFIG_STORAGE), + }, + EntityTypeKind { + attribute: "Drupal\\Core\\Entity\\Attribute\\EntityType", + annotation: "@EntityType(", + default_storage: None, + }, +]; + +/// Record the entity type `class` declares: a span over its ID, and the +/// class and storage the ID stands for. +pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( + class: &Class<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let mut found = Vec::new(); + for kind in &ENTITY_TYPE_KINDS { + for attribute in attributes_named(&class.attribute_lists, kind.attribute, ctx) { + let mut id = None; + let mut storage = kind.default_storage.map(str::to_string); + for (parameter, value) in attribute_arguments(attribute, &["id"]) { + match parameter { + "id" => id = resolved_string_literal(value, ctx.content), + "handlers" => { + if let Some(named) = handler_class(value, "storage", ctx) { + storage = Some(named); + } + } + _ => {} + } + } + if let Some((key, start, end)) = id.filter(|(key, _, _)| !key.is_empty()) { + found.push((key.to_string(), start as u32, end as u32, storage)); + } + } + } + if found.is_empty() + && let Some((doc, offset)) = get_docblock_text_with_offset(ctx.trivias, ctx.content, class) + && doc.contains("EntityType(") + { + found.extend(annotation_entity_type(doc, offset)); + } + if found.is_empty() { + return; + } + let Some(fqn) = ctx + .resolved_name_at(class.name.span.start.offset) + .map(|fqn| crate::atom::atom(fqn.trim_start_matches('\\'))) + else { + return; + }; + for (key, start, end, storage) in found { + ctx.spans.push(SymbolSpan { + start, + end, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalEntityType, + key, + is_write: true, + is_optional: false, + }, + }); + ctx.entity_types.push(EntityTypeSite { + start, + class: fqn, + storage: storage.map(|storage| crate::atom::atom(&storage)), + }); + } +} + +/// 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 { + let entries = crate::parser::array_literal_elements(handlers)?; + entries.iter().find_map(|entry| { + let ArrayElement::KeyValue(entry) = entry else { + return None; + }; + let (key, _, _) = resolved_string_literal(entry.key, ctx.content)?; + (key == name) + .then(|| message_class(entry.value, ctx)) + .flatten() + }) +} + +/// The entity type an `@ContentEntityType(…)`-family annotation in the +/// docblock `doc`, which starts at `offset` in the file, declares: its ID +/// with the ID's range in the file, and its storage. +fn annotation_entity_type(doc: &str, offset: u32) -> Option<(String, u32, u32, Option)> { + let (body, default_storage) = ENTITY_TYPE_KINDS.iter().find_map(|kind| { + let at = doc.find(kind.annotation)?; + Some((at + kind.annotation.len(), kind.default_storage)) + })?; + let mut id = None; + let mut storage = default_storage.map(str::to_string); + for entry in annotation_entries(doc, body) { + match (entry.path.as_slice(), entry.key) { + ([], "id") => id = Some((entry.value_start, entry.value_end)), + (["handlers"], "storage") => { + storage = Some( + doc[entry.value_start..entry.value_end] + .trim_start_matches('\\') + .to_string(), + ); + } + _ => {} + } + } + let (start, end) = id?; + let key = &doc[start..end]; + (!key.is_empty()).then(|| { + ( + key.to_string(), + offset + start as u32, + offset + end as u32, + storage, + ) + }) +} + +/// A `key = "value"` pair of an annotation, with the keys of the `{…}` +/// blocks around it. +struct AnnotationEntry<'d> { + path: Vec<&'d str>, + key: &'d str, + value_start: usize, + value_end: usize, +} + +/// Every `key = "value"` pair of the annotation whose arguments start at +/// `from` in `doc`, up to its closing parenthesis. Keys may be quoted. +fn annotation_entries(doc: &str, from: usize) -> Vec> { + let bytes = doc.as_bytes(); + let mut entries = Vec::new(); + let mut path: Vec<&str> = Vec::new(); + // The key read last, waiting for its `=`, and whether the `=` came. + let mut key: Option<&str> = None; + let mut assigned = false; + let mut parens = 0usize; + let mut i = from; + while i < bytes.len() { + match bytes[i] { + b'"' => { + let start = i + 1; + let Some(len) = doc[start..].find('"') else { + break; + }; + let end = start + len; + if assigned { + if let Some(name) = key.take() { + entries.push(AnnotationEntry { + path: path.clone(), + key: name, + value_start: start, + value_end: end, + }); + } + assigned = false; + } else { + key = Some(&doc[start..end]); + } + i = end + 1; + continue; + } + b'=' => assigned = key.is_some(), + b'{' => { + if assigned && let Some(name) = key.take() { + path.push(name); + } else { + path.push(""); + } + assigned = false; + } + b'}' => { + path.pop(); + key = None; + assigned = false; + } + // A nested annotation's own pairs (`@Translation(context = …)`) + // are not the entity type's. + b'(' => { + parens += 1; + path.push("("); + key = None; + assigned = false; + } + b')' => { + if parens == 0 { + break; + } + parens -= 1; + path.pop(); + key = None; + assigned = false; + } + b',' => { + key = None; + assigned = false; + } + byte if byte.is_ascii_alphabetic() || byte == b'_' => { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + if !assigned { + key = Some(&doc[start..i]); + } else { + // A bare value (`TRUE`, a constant): nothing to read. + key = None; + assigned = false; + } + continue; + } + _ => {} + } + i += 1; + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::names::OwnedResolvedNames; + + /// The entity type IDs, classes, and storages `php` declares. + fn declared(php: &str) -> Vec<(String, String, Option)> { + 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: false, + drupal: true, + twig: false, + }, + None, + ); + map.entity_types + .iter() + .map(|site| { + let id = map + .spans + .iter() + .find_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalEntityType, + key, + is_write: true, + .. + } if span.start == site.start => Some(key.clone()), + _ => None, + }) + .unwrap_or_default(); + ( + id, + site.class.to_string(), + site.storage.as_ref().map(ToString::to_string), + ) + }) + .collect() + } + + #[test] + fn an_attribute_declares_the_id_class_and_storage() { + let php = " NodeStorage::class, 'form' => ['default' => 'X']],\n)]\nclass Node {}\n"; + assert_eq!( + declared(php), + vec![( + "node".to_string(), + "Drupal\\node\\Entity\\Node".to_string(), + Some("Drupal\\node\\NodeStorage".to_string()) + )] + ); + } + + #[test] + fn a_config_attribute_without_handlers_gets_config_storage() { + let php = "( &mut ctx.spans, ); } + if ctx.drupal && crate::drupal::is_entity_query(semantic_subject, &member_name) { + try_emit_laravel_string_span( + crate::symbol_map::StringKeyKind::DrupalEntityType, + &static_call.argument_list, + ctx.content, + &mut ctx.spans, + ); + } } 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 79ce960c2..22af1f79e 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -102,6 +102,8 @@ struct ExtractionCtx<'a> { /// What the file's PHP configurator calls declare a service or a /// parameter as. container_values: Vec, + /// The entity types the file's entity classes declare. + entity_types: Vec, /// Current conditional nesting depth (if/else, switch, while, for, etc.). /// Incremented when entering a conditional block, decremented when leaving. cond_nesting_depth: u16, @@ -146,7 +148,8 @@ struct ExtractionCtx<'a> { /// type engine to confirm as naming a service or a parameter. service_container: bool, /// Whether the project is a Drupal site. Gates the lookups only - /// Drupal makes, such as `\Drupal::service()`. + /// Drupal makes, such as `\Drupal::service()`, the entity types an + /// entity class declares, and the entity type IDs its manager reads. drupal: bool, /// Whether the project uses Twig, outside Symfony too. Gates the /// `render()` calls recorded for the type engine to confirm as naming @@ -307,6 +310,7 @@ impl<'a> ExtractionCtx<'a> { } mod class_like; +mod drupal; mod expressions; mod keywords; pub(crate) mod laravel; @@ -322,6 +326,7 @@ mod yii; mod yii_routes; use class_like::*; +use drupal::*; use expressions::*; use keywords::*; use laravel::*; @@ -471,6 +476,7 @@ fn extract_symbol_map_inner( twig_callables: Vec::new(), twig_contexts: Vec::new(), container_values: Vec::new(), + entity_types: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, @@ -622,6 +628,7 @@ fn extract_symbol_map_inner( ctx.typed_receiver_sites.sort_by_key(|s| s.start); ctx.morph_column_sites.sort_by_key(|s| s.start); ctx.container_values.sort_by_key(|s| s.start); + ctx.entity_types.sort_by_key(|s| s.start); ctx.routes.shrink_to_fit(); let mut member_access_indices: crate::atom::AtomMap> = @@ -662,6 +669,7 @@ fn extract_symbol_map_inner( twig_callables: ctx.twig_callables, twig_contexts: ctx.twig_contexts, container_values: ctx.container_values, + entity_types: ctx.entity_types, 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 796910227..469a9430e 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -86,9 +86,12 @@ pub(crate) fn symfony_receiver_method( Some((ReceiverClass::ContainerBuilder, true)) } 1 if is("setAlias") => Some((ReceiverClass::ContainerBuilder, false)), - 0 if is("getDefinition") - || is("findDefinition") - || is("hasDefinition") + // Symfony's container builder and Drupal's entity type manager both + // have these two, each for its own kind of name. + 0 if is("getDefinition") || is("hasDefinition") => { + Some((ReceiverClass::DefinitionLookup, false)) + } + 0 if is("findDefinition") || is("removeDefinition") || is("getAlias") || is("hasAlias") @@ -128,6 +131,17 @@ pub(crate) fn symfony_receiver_method( Some((ReceiverClass::TemplatedEmail, false)) } 1 if is("dispatch") => Some((ReceiverClass::EventDispatcher, false)), + 0 if is("getStorage") + || is("getAccessControlHandler") + || is("getViewBuilder") + || is("getListBuilder") + || is("getFormObject") + || is("getRouteProviders") + || is("hasHandler") + || is("getHandler") => + { + Some((ReceiverClass::EntityTypeManager, false)) + } 0 if is("addListener") => Some((ReceiverClass::EventListeners, true)), 0 if is("removeListener") || is("hasListeners") @@ -197,6 +211,7 @@ pub(in crate::symbol_map::extraction) fn record_symfony_receiver_sites( KeyFramework::Symfony => ctx.symfony, KeyFramework::Twig => ctx.twig, KeyFramework::Yii => ctx.yii, + KeyFramework::Drupal => ctx.drupal, KeyFramework::Laravel | KeyFramework::WordPress => false, }; if !receiver.names_any(records) { @@ -2317,7 +2332,7 @@ pub(in crate::symbol_map::extraction) fn emit_messenger_handler_sites( /// The message class a `handles:` names: `Foo::class`, or a class name /// written out as a string. -fn message_class(handles: &Expression<'_>, ctx: &ExtractionCtx<'_>) -> Option { +pub(super) fn message_class(handles: &Expression<'_>, ctx: &ExtractionCtx<'_>) -> Option { if let Some(identifier) = crate::parser::class_fetch_identifier(handles) { let written = bytes_to_str(identifier.value()); let resolved = ctx @@ -2568,6 +2583,76 @@ mod tests { ); } + /// Each `(method, argument)` Drupal code reads a name from through + /// `symfony_receiver_method`, with the Drupal class and kind the + /// receiver it gets must offer, so that a same-named arm placed before + /// Drupal's takes nothing away from it. + const DRUPAL_RECEIVER_METHODS: &[(&str, usize, &str, crate::symbol_map::StringKeyKind)] = &[ + ("getStorage", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("getDefinition", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("hasDefinition", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ( + "getAccessControlHandler", + 0, + ENTITY_TYPE_MANAGER, + ENTITY_TYPE, + ), + ("getViewBuilder", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("getListBuilder", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("getFormObject", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("getRouteProviders", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("hasHandler", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("getHandler", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ]; + + const ENTITY_TYPE_MANAGER: &str = "Drupal\\Core\\Entity\\EntityTypeManagerInterface"; + const ENTITY_TYPE: crate::symbol_map::StringKeyKind = + crate::symbol_map::StringKeyKind::DrupalEntityType; + + #[test] + fn every_drupal_receiver_method_reaches_its_drupal_class() { + for &(method, index, class, kind) in DRUPAL_RECEIVER_METHODS { + let (receiver, _) = + crate::symbol_map::extraction::symfony_receiver_method(method, index) + .unwrap_or_else(|| panic!("{method}#{index} names no receiver")); + assert!( + receiver.targets().contains(&(class, kind)), + "{method}#{index}: {receiver:?} has no {class} target of {kind:?}" + ); + } + } + + #[test] + fn a_container_builders_definition_lookups_name_a_service() { + let php = "getDefinition('app.a');\n $container->hasDefinition('app.b');\n}\n"; + let map = map_of(php); + assert_eq!( + receivers(&map), + vec![ + ("app.a", ReceiverClass::DefinitionLookup, false), + ("app.b", ReceiverClass::DefinitionLookup, false), + ] + ); + // A receiver confirmed as a container builder names a service ID. + let (_, kind) = ReceiverClass::DefinitionLookup + .targets() + .iter() + .find(|(class, _)| { + *class == "Symfony\\Component\\DependencyInjection\\ContainerBuilder" + }) + .expect("the container builder is a target"); + let span = map.typed_receiver_sites[0].to_span(*kind); + assert!(matches!( + &span.kind, + crate::symbol_map::SymbolKind::StringKey { + kind: crate::symbol_map::StringKeyKind::ServiceId, + key, + is_write: false, + .. + } if key == "app.a" + )); + } + #[test] fn builder_calls_parents_and_placeholders_are_recorded() { let builder = "register('app.a', A::class);\n $container->setAlias('app.b', 'app.a');\n $container->findDefinition('app.a');\n}\n"; diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index a1765045a..805664f9b 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -304,6 +304,9 @@ pub(crate) struct SymbolMap { /// parameter declarations give, sorted by `start`. Empty for every file /// that declares none. pub container_values: Vec, + /// The entity types the file's entity classes declare, sorted by + /// `start`. Empty for every file that declares none. + pub entity_types: 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 b204324e2..c990ffa45 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -155,6 +155,11 @@ pub(crate) enum StringKeyKind { /// `wordpress::hooks`), which a literal name matches by its literal /// parts. WordPressHook, + /// A Drupal entity type ID: declared by the `id` of an entity class's + /// `#[ContentEntityType]`-family attribute or annotation, and read by + /// the entity type manager's `getStorage()` and the other methods that + /// take one. + DrupalEntityType, } impl StringKeyKind { @@ -201,6 +206,7 @@ impl StringKeyKind { | Self::TwigVariable => KeyFramework::Twig, Self::YiiView | Self::YiiRoute | Self::YiiRouteArgument => KeyFramework::Yii, Self::WordPressHook => KeyFramework::WordPress, + Self::DrupalEntityType => KeyFramework::Drupal, } } @@ -311,6 +317,8 @@ pub(crate) enum KeyFramework { Yii, /// WordPress's hook names, read wherever the hook functions are called. WordPress, + /// Drupal's own declarations, read in a Drupal project. + Drupal, } /// A workspace symbol whose presence suppresses an otherwise-valid framework @@ -591,6 +599,21 @@ pub(crate) struct ContainerValueSite { pub value: ContainerValue, } +/// What a Drupal entity type declaration gives its ID: the entity class, +/// and the storage handler that loads it. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct EntityTypeSite { + /// Byte offset of the ID's first character, matching the declaration's + /// [`SymbolKind::StringKey`] span. + pub start: u32, + /// The class the attribute or annotation is written on. + pub class: crate::atom::Atom, + /// The `storage` handler the declaration names, or the default storage + /// of its kind of entity type; `None` when it names none and has no + /// default. + pub storage: Option, +} + /// The value a container declaration gives, read by the kind of name it /// turns out to declare. #[derive(Debug, Clone, PartialEq)] @@ -703,6 +726,8 @@ const YII_WEB_VIEW_RENDERER: &str = "Yiisoft\\Yii\\View\\Renderer\\WebViewRender /// The renderer before `yiisoft/yii-view-renderer` 7 moved it. 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"; /// The namespace of the PHP configurators that build Symfony's service /// container. @@ -752,7 +777,8 @@ pub(crate) enum ReceiverClass { /// The container builder a kernel, a bundle, an extension, or a /// compiler pass defines services on: `register()`, `autowire()`, /// `setDefinition()`, and `setAlias()` declare a service ID, and the - /// rest of its definition API looks one up. + /// rest of its definition API looks one up, `getDefinition()` and + /// `hasDefinition()` through [`Self::DefinitionLookup`]. ContainerBuilder, /// What `generateUrl()` and `redirectToRoute()` take a route name in: /// the shortcuts a Symfony controller inherits. @@ -788,6 +814,14 @@ pub(crate) enum ReceiverClass { /// What renders a Yii view by its first argument: a view renderer's /// `render()` family, and a view's own `render()` inside a template. YiiViewRenderer, + /// Drupal's entity type manager, whose `getStorage()` and the other + /// handler getters take an entity type ID first, `getDefinition()` and + /// `hasDefinition()` through [`Self::DefinitionLookup`]. + EntityTypeManager, + /// The definition lookups, `getDefinition()` and `hasDefinition()`, that + /// 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, } impl ReceiverClass { @@ -866,6 +900,13 @@ impl ReceiverClass { (YII_LEGACY_VIEW_RENDERER, YiiView), (YII_VIEW, YiiView), ], + Self::EntityTypeManager => { + &[(DRUPAL_ENTITY_TYPE_MANAGER, StringKeyKind::DrupalEntityType)] + } + Self::DefinitionLookup => &[ + (SYMFONY_CONTAINER_BUILDER, ServiceId), + (DRUPAL_ENTITY_TYPE_MANAGER, StringKeyKind::DrupalEntityType), + ], } } diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index cef959974..380e78c00 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -125,7 +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, or a Messenger bus, outright or through a configurator call still to be +/// key, an event listener, a Messenger bus, or a Drupal entity type, +/// outright or through a configurator call still to be /// confirmed, or registers a Messenger handler, or imports routes, or /// declares a Twig loader. /// @@ -139,7 +140,8 @@ fn declares_symfony_keys(map: &SymbolMap, enabled: impl Fn(StringKeyKind) -> boo KeyFramework::ServiceContainer | KeyFramework::Symfony | KeyFramework::Twig - | KeyFramework::Yii => enabled(kind), + | KeyFramework::Yii + | KeyFramework::Drupal => enabled(kind), KeyFramework::Laravel | KeyFramework::WordPress => false, }; (!map.messenger_handlers.is_empty() && enabled(StringKeyKind::MessengerBus)) @@ -184,6 +186,7 @@ impl Backend { KeyFramework::Symfony => self.is_symfony_project(), KeyFramework::Twig => self.is_twig_project(), KeyFramework::Yii => self.is_yii_project(), + KeyFramework::Drupal => self.is_drupal_project(), // The hook functions' names are WordPress's own, and a project // that calls them is a WordPress one whatever its // `composer.json` says, or whether it has one at all. @@ -200,6 +203,7 @@ impl Backend { return; } let declares = declares_symfony_keys(map, |kind| self.string_key_kind_enabled(kind)) + && !self.is_closed_drupal_test_fixture(uri) && !self .workspace .vendor_uri_prefixes @@ -276,6 +280,7 @@ impl Backend { // Only Symfony declares names through calls the type engine has to // confirm; a Drupal project records none. let needs_typed = match kind.framework() { + KeyFramework::Drupal => false, KeyFramework::ServiceContainer => self.is_symfony_project(), KeyFramework::Laravel | KeyFramework::Symfony diff --git a/src/type_engine/call_resolution/return_types/call_arms.rs b/src/type_engine/call_resolution/return_types/call_arms.rs index b8519c227..80d0b7e29 100644 --- a/src/type_engine/call_resolution/return_types/call_arms.rs +++ b/src/type_engine/call_resolution/return_types/call_arms.rs @@ -74,6 +74,60 @@ impl Backend { return classes; } + // Drupal: a load or an entity query on an entity storage tagged with + // its entity class, the count and IDs of a tagged query, and + // `getStorage('node')` on an entity type manager (see + // `crate::drupal::types`). + let in_drupal = ctx + .file + .backend + .filter(|backend| backend.is_drupal_project()); + if in_drupal.is_some() + && let Some(ty) = + crate::drupal::types::load_type(method_name, &lhs_resolved, ctx.file.class_loader) + .or_else(|| { + crate::drupal::types::query_call_type( + method_name, + &lhs_resolved, + ctx.file.class_loader, + ) + }) + { + let classes = crate::type_engine::type_resolution::type_hint_to_classes_typed_returned( + &ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ); + if let Some(ref mut hint_out) = return_type_hint_out { + **hint_out = Some(ty); + } + return classes; + } + if method_name.eq_ignore_ascii_case("getStorage") + && let Some(backend) = in_drupal + && let Some(id) = first_literal_text_arg(text_args, "entity_type_id") + { + for owner in lhs_resolved.iter().filter_map(|r| r.class_info.as_deref()) { + if let Some(storages) = crate::drupal::types::storage_type( + backend, + owner, + method_name, + id, + ctx.file.class_loader, + ) { + // A chain carries one hint: with two declarations of the + // ID it is their union, which a following load cannot + // read a single entity from. + let (classes, types): (Vec<_>, Vec<_>) = storages.into_iter().unzip(); + if let Some(ref mut hint_out) = return_type_hint_out { + **hint_out = Some(crate::php_type::PhpType::union(types)); + } + return classes; + } + } + } + // Guard-aware auth user model: a `user()` call on a // `Guard`/`Request` subtype resolves to the model // configured for the guard named at the call site @@ -333,6 +387,27 @@ impl Backend { { return classes; } + // `\Drupal::entityQuery('node')` is a query tagged with the entity + // type's classes (see `crate::drupal::types`). + if method_name.eq_ignore_ascii_case("entityQuery") + && let Some(ref owner) = owner_class + && let Some(backend) = ctx.file.backend + && backend.is_drupal_project() + && let Some(id) = first_literal_text_arg(text_args, "entity_type") + && let Some(ty) = + crate::drupal::types::entity_query_type(backend, &owner.fqn(), method_name, id) + { + let classes = crate::type_engine::type_resolution::type_hint_to_classes_typed_returned( + &ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ); + if let Some(ref mut hint_out) = return_type_hint_out { + **hint_out = Some(ty); + } + return classes; + } if let Some(ref owner) = owner_class { // A static call through a Laravel facade is typed by the diff --git a/src/type_engine/variable/rhs_resolution/drupal_calls.rs b/src/type_engine/variable/rhs_resolution/drupal_calls.rs new file mode 100644 index 000000000..9d97b1066 --- /dev/null +++ b/src/type_engine/variable/rhs_resolution/drupal_calls.rs @@ -0,0 +1,133 @@ +//! Drupal calls in an assignment whose return type a Drupal declaration +//! decides (see [`crate::drupal::types`]). + +use mago_syntax::cst::*; + +use crate::types::{ClassInfo, ResolvedType}; + +use crate::type_engine::resolver::VarResolutionCtx; + +/// The string literal that binds a call's first parameter, named `name`, +/// positional or named. +fn literal_first<'c>( + argument_list: &'c ArgumentList<'_>, + name: &str, + content: &'c str, +) -> Option<&'c str> { + let argument = crate::call_args::first_param_arg(argument_list, name)?; + crate::virtual_members::laravel::helpers::resolved_string_literal(argument.value(), content) + .map(|(key, _, _)| key) +} + +/// What `\Drupal::service('id')` returns: the class the ID is built from. +pub(super) fn try_resolve_drupal_service_type( + owner_fqn: &str, + method_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &VarResolutionCtx<'_>, +) -> Option> { + if !method_name.eq_ignore_ascii_case("service") { + return None; + } + let backend = ctx.file.backend?; + let id = literal_first(argument_list, "id", ctx.file.content)?; + let classes = crate::drupal::types::service_classes( + backend, + owner_fqn, + method_name, + id, + ctx.file.class_loader, + )?; + Some(ResolvedType::from_classes(classes)) +} + +/// What `getStorage('node')` on an entity type manager returns. +pub(super) fn try_resolve_entity_storage_type( + owner: &ClassInfo, + method_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &VarResolutionCtx<'_>, +) -> Option> { + if !method_name.eq_ignore_ascii_case("getStorage") { + return None; + } + let backend = ctx + .file + .backend + .filter(|backend| backend.is_drupal_project())?; + let id = literal_first(argument_list, "entity_type_id", ctx.file.content)?; + let storages = + crate::drupal::types::storage_type(backend, owner, method_name, id, ctx.file.class_loader)?; + // Each storage keeps its own tag, so a load on it knows its entity. + Some( + storages + .into_iter() + .flat_map(|(class, ty)| ResolvedType::from_classes_with_hint(vec![class], ty)) + .collect(), + ) +} + +/// What a load on a tagged entity storage returns. +pub(super) fn try_resolve_entity_load_type( + method_name: &str, + receiver: &[ResolvedType], + ctx: &VarResolutionCtx<'_>, +) -> Option> { + ctx.file + .backend + .filter(|backend| backend.is_drupal_project())?; + let ty = crate::drupal::types::load_type(method_name, receiver, ctx.file.class_loader)?; + Some( + crate::type_engine::type_ops::resolution::resolved_types_for_hint( + ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ), + ) +} + +/// What `\Drupal::entityQuery('node')` returns: a query tagged with the +/// entity classes declared for the ID. +pub(super) fn try_resolve_entity_query_type( + owner_fqn: &str, + method_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &VarResolutionCtx<'_>, +) -> Option> { + if !method_name.eq_ignore_ascii_case("entityQuery") { + return None; + } + let backend = ctx.file.backend?; + let id = literal_first(argument_list, "entity_type", ctx.file.content)?; + let ty = crate::drupal::types::entity_query_type(backend, owner_fqn, method_name, id)?; + Some( + crate::type_engine::type_ops::resolution::resolved_types_for_hint( + ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ), + ) +} + +/// What `getQuery()` on a tagged storage, or `count()` and `execute()` on a +/// tagged query, returns. +pub(super) fn try_resolve_entity_query_call_type( + method_name: &str, + receiver: &[ResolvedType], + ctx: &VarResolutionCtx<'_>, +) -> Option> { + ctx.file + .backend + .filter(|backend| backend.is_drupal_project())?; + let ty = crate::drupal::types::query_call_type(method_name, receiver, ctx.file.class_loader)?; + Some( + crate::type_engine::type_ops::resolution::resolved_types_for_hint( + ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ), + ) +} diff --git a/src/type_engine/variable/rhs_resolution/method_calls.rs b/src/type_engine/variable/rhs_resolution/method_calls.rs index 443f48769..6c15460e1 100644 --- a/src/type_engine/variable/rhs_resolution/method_calls.rs +++ b/src/type_engine/variable/rhs_resolution/method_calls.rs @@ -199,6 +199,24 @@ pub(super) fn resolve_member_call_on_receiver<'b>( }; } + // A load on an entity storage `getStorage()` tagged with its entity + // class. The entity type rides on the receiver's type argument, not on + // any owner class. + if let Some(result) = + super::drupal_calls::try_resolve_entity_load_type(&method_name, &receiver_resolved, ctx) + { + return result; + } + // An entity query on a tagged storage, and the count and the IDs a + // tagged query gives. + if let Some(result) = super::drupal_calls::try_resolve_entity_query_call_type( + &method_name, + &receiver_resolved, + ctx, + ) { + return result; + } + // Laravel validated input: the rules that guard this request describe the // array it hands back, so `$data = $request->validated()` gets a shape // rather than plain `array`. Classifying the call does not depend on the @@ -241,6 +259,14 @@ pub(super) fn resolve_member_call_on_receiver<'b>( { return result; } + if let Some(result) = super::drupal_calls::try_resolve_entity_storage_type( + owner, + &method_name, + argument_list, + ctx, + ) { + return result; + } if let Some(result) = try_resolve_handle_trait_type(owner, &method_name, argument_list, ctx) { diff --git a/src/type_engine/variable/rhs_resolution/mod.rs b/src/type_engine/variable/rhs_resolution/mod.rs index a58ad9825..12ae675d3 100644 --- a/src/type_engine/variable/rhs_resolution/mod.rs +++ b/src/type_engine/variable/rhs_resolution/mod.rs @@ -72,6 +72,7 @@ mod assignment_value; mod branches; mod calls; mod constant_access; +mod drupal_calls; mod function_calls; mod instantiation; mod laravel_calls; diff --git a/src/type_engine/variable/rhs_resolution/static_calls.rs b/src/type_engine/variable/rhs_resolution/static_calls.rs index 613fe3a3e..26e291759 100644 --- a/src/type_engine/variable/rhs_resolution/static_calls.rs +++ b/src/type_engine/variable/rhs_resolution/static_calls.rs @@ -178,7 +178,15 @@ fn static_call_on_class( { return result; } - if let Some(result) = super::symfony_calls::try_resolve_drupal_service_type( + if let Some(result) = super::drupal_calls::try_resolve_drupal_service_type( + &owner.fqn(), + method_name, + argument_list, + ctx, + ) { + return result; + } + if let Some(result) = super::drupal_calls::try_resolve_entity_query_type( &owner.fqn(), method_name, argument_list, diff --git a/src/type_engine/variable/rhs_resolution/symfony_calls.rs b/src/type_engine/variable/rhs_resolution/symfony_calls.rs index c5a28423a..ace4e034f 100644 --- a/src/type_engine/variable/rhs_resolution/symfony_calls.rs +++ b/src/type_engine/variable/rhs_resolution/symfony_calls.rs @@ -67,37 +67,6 @@ pub(super) fn try_resolve_container_lookup_type( } } -/// What `\Drupal::service()` returns for the service ID its literal first -/// argument names: the class the ID is built from, as for a container's -/// `get()`. -/// -/// `None` for an ID the project does not declare, and for a class constant -/// (`Foo::class`), which core's own conditional return type already reads. -pub(super) fn try_resolve_drupal_service_type( - owner_fqn: &str, - method_name: &str, - argument_list: &ArgumentList<'_>, - ctx: &VarResolutionCtx<'_>, -) -> Option> { - if !method_name.eq_ignore_ascii_case("service") { - return None; - } - let backend = ctx.file.backend?; - let argument = crate::call_args::first_param_arg(argument_list, "id")?; - let (key, _, _) = crate::virtual_members::laravel::helpers::resolved_string_literal( - argument.value(), - ctx.file.content, - )?; - let classes = crate::drupal::types::service_classes( - backend, - owner_fqn, - method_name, - key, - ctx.file.class_loader, - )?; - Some(ResolvedType::from_classes(classes)) -} - /// What Messenger's `HandleTrait::handle()` returns for the message it is /// given: what the message's handler returns, when exactly one handler is /// registered for it. `handle()` throws for a message with none or with diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index caa6b9de2..d914dd22c 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -77,7 +77,8 @@ pub(crate) fn resolve_laravel_string_key( | StringKeyKind::SymfonyConfigKey | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus - | StringKeyKind::YiiRoute => backend.symfony_declaration_locations(*kind, key), + | StringKeyKind::YiiRoute + | StringKeyKind::DrupalEntityType => 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), @@ -312,7 +313,8 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::SymfonyConfigKey | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus - | StringKeyKind::YiiRoute => { + | StringKeyKind::YiiRoute + | StringKeyKind::DrupalEntityType => { 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_entities.rs b/tests/integration/drupal_entities.rs new file mode 100644 index 000000000..130827d73 --- /dev/null +++ b/tests/integration/drupal_entities.rs @@ -0,0 +1,484 @@ +//! Drupal's entity types: the IDs an entity class's attribute or annotation +//! declares, read by the entity type manager. + +use crate::common::{completion_response, response_labels}; +use crate::drupal_services::{ + definitions_at, position_in, references_at, uri_for, workspace, workspace_in, +}; + +const NOTE: &str = r#"manager->getStorage('acme_note'); + $this->manager->getDefinition('acme_note'); + } +} +"#; + +const CONSUMER_PATH: &str = "web/modules/custom/acme/src/Notes.php"; + +#[tokio::test] +async fn an_entity_type_id_goes_to_its_declaration_and_lists_its_readers() { + let (backend, dir) = workspace(&[ + (MANAGER_PATH, MANAGER), + (NOTE_PATH, NOTE), + (CONSUMER_PATH, CONSUMER), + ]) + .await; + let php = uri_for(&dir, CONSUMER_PATH); + for needle in ["getStorage('acme_note')", "getDefinition('acme_note')"] { + let inside = needle.find('\'').unwrap() + 2; + let found = definitions_at(&backend, &php, position_in(CONSUMER, needle, inside)).await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!( + (&found[0].uri, found[0].range.start.line), + (&uri_for(&dir, NOTE_PATH), 6) + ); + } + + let found = references_at( + &backend, + &uri_for(&dir, NOTE_PATH), + position_in(NOTE, "acme_note", 2), + ) + .await; + assert_eq!( + found, + vec![(php.clone(), 9), (php, 10), (uri_for(&dir, NOTE_PATH), 6)] + .into_iter() + .collect::>() + .into_iter() + .collect::>() + ); +} + +/// Core's entity classes sit in a folder the project's `.gitignore` leaves +/// out, and its test modules declare entity types no real site has. +#[tokio::test] +async fn core_entity_types_complete_and_test_modules_do_not() { + let consumer = CONSUMER.replace("getStorage('acme_note')", "getStorage('')"); + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ( + "web/core/modules/node/src/Entity/Node.php", + "manager->getStorage('acme_note'); + $storage; + $note = $storage->load(1); + $note; + $notes = $storage->loadMultiple(); + $notes; + $created = $this->manager->getStorage('acme_note')->create([]); + $created; + $unknown = $this->manager->getStorage('nope')->load(1); + $unknown; + $sample = $storage->createWithSampleValues(); + $sample; + $revisions = $storage->loadMultipleRevisions([1]); + $revisions; + } +} +"#; + +const LOADER_PATH: &str = "web/modules/custom/acme/src/Loader.php"; + +#[tokio::test] +async fn storage_and_loads_are_typed_by_the_entity_type() { + let mut files: Vec<(&str, &str)> = STORAGE_STUBS.to_vec(); + files.extend([ + (MANAGER_PATH, MANAGER), + (NOTE_PATH, NOTE), + (LOADER_PATH, LOADER), + ]); + let (backend, dir) = workspace(&files).await; + let php = uri_for(&dir, LOADER_PATH); + let hover = |needle: &'static str| { + let position = position_in(LOADER, needle, 2); + let backend = &backend; + let php = php.clone(); + async move { + crate::common::hover_text_at(backend, &php, position.line, position.character) + .await + .unwrap_or_default() + } + }; + + let storage = hover("$storage;").await; + assert!( + storage.contains("SqlContentEntityStorage"), + "{storage}" + ); + let note = hover("$note;").await; + assert!( + note.contains("?Note") || note.contains("Note|null"), + "{note}" + ); + let notes = hover("$notes;").await; + assert!(notes.contains("array"), "{notes}"); + let created = hover("$created;").await; + assert!(created.contains("= Note"), "{created}"); + let unknown = hover("$unknown;").await; + assert!(unknown.contains("EntityInterface"), "{unknown}"); + let sample = hover("$sample;").await; + assert!(sample.contains("= Note"), "{sample}"); + let revisions = hover("$revisions;").await; + assert!(revisions.contains("array"), "{revisions}"); +} + +/// A load or a service lookup called straight on the call before it, with +/// no variable in between, is typed the same way. +#[tokio::test] +async fn chained_loads_and_service_lookups_are_typed() { + let note = NOTE.replace( + "class Note {}", + "class Note {\n public function noteOnly(): void {}\n}", + ); + let chained = r#"getStorage('acme_note')->load(1)?->noteOnly(); + $manager->getStorage('acme_note')->create([])->noteOnly(); + \Drupal::service('acme.greeter')->greet(); + } +} +"#; + let path = "web/modules/custom/acme/src/Chained.php"; + let services = "services:\n acme.greeter:\n class: Drupal\\acme\\Greeter\n"; + let mut files: Vec<(&str, &str)> = STORAGE_STUBS.to_vec(); + files.extend([ + (MANAGER_PATH, MANAGER), + (NOTE_PATH, note.as_str()), + ("web/modules/custom/acme/acme.services.yml", services), + (path, chained), + ]); + let (backend, dir) = workspace(&files).await; + let php = uri_for(&dir, path); + for (needle, target) in [ + ("load(1)?->noteOnly", NOTE_PATH), + ("create([])->noteOnly", NOTE_PATH), + ("->greet()", "web/modules/custom/acme/src/Greeter.php"), + ] { + let inside = needle.rfind('>').unwrap() + 2; + let found = definitions_at(&backend, &php, position_in(chained, needle, inside)).await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!(found[0].uri, uri_for(&dir, target), "{needle}"); + } +} + +/// `getStorage(entity_type_id: 'x')` names the same entity type as the +/// positional form. +#[tokio::test] +async fn a_named_entity_type_id_types_the_storage() { + let named = LOADER.replace( + "$storage = $this->manager->getStorage('acme_note');", + "$storage = $this->manager->getStorage(entity_type_id: 'acme_note');", + ); + let mut files: Vec<(&str, &str)> = STORAGE_STUBS.to_vec(); + files.extend([ + (MANAGER_PATH, MANAGER), + (NOTE_PATH, NOTE), + (LOADER_PATH, named.as_str()), + ]); + let (backend, dir) = workspace(&files).await; + let position = position_in(&named, "$storage;", 2); + let hover = crate::common::hover_text_at( + &backend, + &uri_for(&dir, LOADER_PATH), + position.line, + position.character, + ) + .await + .unwrap_or_default(); + assert!(hover.contains("SqlContentEntityStorage"), "{hover}"); +} + +/// Just enough of core's entity queries: `\Drupal`'s query starters, the +/// query interfaces, and a storage that starts one. +const QUERY_STUBS: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal.php", + "condition('title', 'Ada'); + $ids = $query->accessCheck(TRUE)->execute(); + $ids; + $count = \Drupal::entityQuery('acme_note')->accessCheck(TRUE)->count()->execute(); + $count; + $counting = $manager->getStorage('acme_note')->getQuery()->count(); + $counted = $counting->condition('title', 'Ada')->execute(); + $counted; + $styles = \Drupal::entityQuery('acme_style')->accessCheck(TRUE)->execute(); + $styles; + $rows = \Drupal::entityQueryAggregate('acme_note')->execute(); + $rows; + $plain = \Drupal::entityQuery('acme_plain')->accessCheck(TRUE)->execute(); + $plain; + } +} +"#; + +const QUERIES_PATH: &str = "web/modules/custom/acme/src/Queries.php"; + +/// The files of a site with a content and a configuration entity type and +/// `consumer` at [`QUERIES_PATH`]. +fn query_files(consumer: &str) -> Vec<(&str, &str)> { + let mut files: Vec<(&str, &str)> = QUERY_STUBS.to_vec(); + files.extend([ + (MANAGER_PATH, MANAGER), + (NOTE_PATH, CONTENT_NOTE), + (STYLE_PATH, STYLE), + (PLAIN_PATH, PLAIN), + (QUERIES_PATH, consumer), + ]); + files +} + +/// An entity query's `execute()` gives the IDs of the entity type the query +/// was started on, keyed by revision ID for a content entity and by ID for a +/// configuration entity, or their number once the query counts. The query +/// keeps what it was started on through a variable and through a chain of +/// the methods that return it. An entity class of neither kind keeps +/// core's `int|array`. +#[tokio::test] +async fn entity_queries_are_typed_by_the_entity_type() { + let (backend, dir) = workspace(&query_files(QUERIES)).await; + let php = uri_for(&dir, QUERIES_PATH); + let hover = |needle: &'static str| { + let position = position_in(QUERIES, needle, 2); + let backend = &backend; + let php = php.clone(); + async move { + crate::common::hover_text_at(backend, &php, position.line, position.character) + .await + .unwrap_or_default() + } + }; + + let ids = hover("$ids;").await; + assert!(ids.contains("array"), "{ids}"); + let count = hover("$count;").await; + assert!(count.contains("int<0, max>"), "{count}"); + let counted = hover("$counted;").await; + assert!(counted.contains("int<0, max>"), "{counted}"); + let styles = hover("$styles;").await; + assert!(styles.contains("array"), "{styles}"); + // An aggregate query gives rows, as core declares, not IDs. + let rows = hover("$rows;").await; + assert!(rows.contains("$rows = array\n"), "{rows}"); + let plain = hover("$plain;").await; + assert!(plain.contains("$plain = int|array\n"), "{plain}"); +} + +/// The entity type ID `\Drupal::entityQuery()` and +/// `\Drupal::entityQueryAggregate()` start a query on goes to its +/// declaration and completes. +#[tokio::test] +async fn entity_query_ids_go_to_their_declaration_and_complete() { + let (backend, dir) = workspace(&query_files(QUERIES)).await; + let php = uri_for(&dir, QUERIES_PATH); + let id_line = CONTENT_NOTE + .lines() + .position(|line| line.contains("id: 'acme_note'")) + .unwrap() as u32; + for needle in [ + "entityQuery('acme_note')", + "entityQueryAggregate('acme_note')", + ] { + let inside = needle.find('\'').unwrap() + 2; + let found = definitions_at(&backend, &php, position_in(QUERIES, needle, inside)).await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!( + (&found[0].uri, found[0].range.start.line), + (&uri_for(&dir, NOTE_PATH), id_line), + "{needle}" + ); + } + + let typing = QUERIES.replace( + "\\Drupal::entityQuery('acme_style')", + "\\Drupal::entityQuery('acme_')", + ); + let (backend, dir) = workspace(&query_files(typing.as_str())).await; + let position = position_in(&typing, "entityQuery('acme_')", 18); + let labels = response_labels( + completion_response( + &backend, + &uri_for(&dir, QUERIES_PATH), + position.line, + position.character, + None, + ) + .await, + ); + assert!(labels.contains(&"acme_note".to_string()), "{labels:?}"); + assert!(labels.contains(&"acme_style".to_string()), "{labels:?}"); +} diff --git a/tests/integration/drupal_mixed_workspace.rs b/tests/integration/drupal_mixed_workspace.rs index 4f6fb88ac..f2e93fbbb 100644 --- a/tests/integration/drupal_mixed_workspace.rs +++ b/tests/integration/drupal_mixed_workspace.rs @@ -63,6 +63,52 @@ final class StaticConsumer { const DRUPAL_SERVICES_PATH: &str = "drupal/web/modules/custom/acme/acme.services.yml"; const DRUPAL_CONSUMER_PATH: &str = "drupal/web/modules/custom/acme/src/StaticConsumer.php"; +/// An entity type the Drupal site declares, its manager, and a reader; +/// and a Symfony container builder looking a service definition up with +/// the same method name the manager has. +const ENTITY_FILES: &[(&str, &str)] = &[ + ( + "drupal/web/core/lib/Drupal/Core/Entity/EntityTypeManagerInterface.php", + "manager->getStorage('acme_note'); + $this->manager->getDefinition('acme_note'); + } +} +"#; + +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"; + // ─── Symfony, in `symfony/` ───────────────────────────────────────────────── const SYMFONY_COMPOSER: &str = r#"{ @@ -138,6 +184,21 @@ final class MailController } "#; +const SYMFONY_KERNEL: &str = r#"getDefinition('app.mailer'); + } +} +"#; + +const SYMFONY_KERNEL_PATH: &str = "symfony/src/Kernel.php"; const SYMFONY_YAML_PATH: &str = "symfony/config/services.yaml"; const SYMFONY_PHP_PATH: &str = "symfony/config/services.php"; const SYMFONY_CONSUMER_PATH: &str = "symfony/src/Controller/MailController.php"; @@ -221,6 +282,11 @@ fn mixed_files() -> Vec<(String, &'static str)> { .iter() .map(|(path, content)| (format!("symfony/{path}"), *content)), ); + files.extend( + ENTITY_FILES + .iter() + .map(|(path, content)| (path.to_string(), *content)), + ); files } @@ -438,6 +504,44 @@ async fn drupal_services_work_in_the_drupal_site() { ); } +#[tokio::test] +async fn drupal_entity_types_work_in_the_drupal_site() { + let (backend, dir) = workspace(&mixed_files()).await; + let notes = uri_for(&dir, DRUPAL_NOTES_PATH); + for needle in ["getStorage('acme_note')", "getDefinition('acme_note')"] { + let inside = needle.find('\'').unwrap() + 2; + let found = definitions(&backend, ¬es, position_in(DRUPAL_NOTES, needle, inside)).await; + assert_eq!( + found, + vec![( + uri_for(&dir, DRUPAL_NOTE_PATH), + line_of(DRUPAL_NOTE, "id: 'acme_note'") + )], + "{needle}" + ); + } +} + +/// `getDefinition()` on Symfony's container builder still names a service, +/// though Drupal's entity type manager has a method of the same name. +#[tokio::test] +async fn a_symfony_container_builder_still_names_services_beside_a_drupal_site() { + let (backend, dir) = workspace(&mixed_files()).await; + let found = definitions( + &backend, + &uri_for(&dir, SYMFONY_KERNEL_PATH), + position_in(SYMFONY_KERNEL, "getDefinition('app.mailer')", 17), + ) + .await; + assert_eq!( + found, + vec![( + uri_for(&dir, SYMFONY_YAML_PATH), + line_of(SYMFONY_SERVICES_YAML, "app.mailer:") + )] + ); +} + /// 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_services.rs b/tests/integration/drupal_services.rs index 55e1e508c..21fcab15d 100644 --- a/tests/integration/drupal_services.rs +++ b/tests/integration/drupal_services.rs @@ -16,7 +16,7 @@ const COMPOSER: &str = r#"{ }"#; /// Just enough of the container interfaces, where Composer would put core. -const STUBS: &[(&str, &str)] = &[ +pub(crate) const STUBS: &[(&str, &str)] = &[ ( "web/core/lib/Psr/Container/ContainerInterface.php", " (Backend, tempfile::TempDir) { +pub(crate) async fn workspace(files: &[(&str, &str)]) -> (Backend, tempfile::TempDir) { workspace_in(tempfile::tempdir().unwrap(), files).await } /// [`workspace`] in `dir`, which may hold files already. -async fn workspace_in( +pub(crate) async fn workspace_in( dir: tempfile::TempDir, files: &[(&str, &str)], ) -> (Backend, tempfile::TempDir) { @@ -83,12 +83,12 @@ async fn workspace_in( (backend, dir) } -fn uri_for(dir: &tempfile::TempDir, rel: &str) -> Uri { +pub(crate) fn uri_for(dir: &tempfile::TempDir, rel: &str) -> Uri { Uri::from_file_path(dir.path().join(rel)).unwrap() } /// The position `inside` bytes into the first occurrence of `needle`. -fn position_in(content: &str, needle: &str, inside: usize) -> Position { +pub(crate) fn position_in(content: &str, needle: &str, inside: usize) -> Position { offset_position( content, content.find(needle).expect("needle should exist") + inside, @@ -175,7 +175,11 @@ async fn service_ids_complete_in_php_and_in_the_services_file() { /// Every reference to the name at `position`, declarations included, as /// files and lines. -async fn references_at(backend: &Backend, uri: &Uri, position: Position) -> Vec<(Uri, u32)> { +pub(crate) async fn references_at( + backend: &Backend, + uri: &Uri, + position: Position, +) -> Vec<(Uri, u32)> { let mut found: Vec<(Uri, u32)> = backend .references(ReferenceParams { text_document_position_params: TextDocumentPositionParams { @@ -294,7 +298,11 @@ final class StaticConsumer { const STATIC_CONSUMER_PATH: &str = "web/modules/custom/acme/src/StaticConsumer.php"; /// The definitions of the name at `position`. -async fn definitions_at(backend: &Backend, uri: &Uri, position: Position) -> Vec { +pub(crate) async fn definitions_at( + backend: &Backend, + uri: &Uri, + position: Position, +) -> Vec { let response = backend .goto_definition(DefinitionParams { text_document_position_params: TextDocumentPositionParams { diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 01c2ef528..02f9c160b 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -200,6 +200,7 @@ mod document_highlight; mod document_links; mod document_symbols; mod document_symbols_blade; +mod drupal_entities; mod drupal_mixed_workspace; mod drupal_services; mod duplicate_class_declarations;