diff --git a/benches/drupal_completion.rs b/benches/drupal_completion.rs index d582f3133..3126a7ff5 100644 --- a/benches/drupal_completion.rs +++ b/benches/drupal_completion.rs @@ -7,7 +7,8 @@ use tower_lsp_server::LanguageServer; use tower_lsp_server::gen_lsp_types::*; /// Custom modules in the generated site, each declaring `SERVICES` -/// services, `ROUTES` routes, `ENTITY_TYPES` entity types, `PERMISSIONS` +/// services, `ROUTES` routes, `ENTITY_TYPES` entity types with `FIELDS` +/// base fields each, `PERMISSIONS` /// permissions, `CONFIGS` configuration objects, with their default /// configuration and schema, `ELEMENTS` render elements, `BLOCKS` block /// plugins, `THEME_HOOKS` theme hooks, and `LIBRARIES` libraries, @@ -17,6 +18,7 @@ const MODULES: usize = 30; const SERVICES: usize = 10; const ROUTES: usize = 10; const ENTITY_TYPES: usize = 3; +const FIELDS: usize = 10; const PERMISSIONS: usize = 10; const CONFIGS: usize = 10; const ELEMENTS: usize = 3; @@ -32,7 +34,8 @@ const COMPOSER: &str = r#"{ /// Just enough of core, where Composer puts it, for the type engine to /// tell a container, the entity type manager, the module handler, an -/// account, a URL, and the block manager from any other receiver. +/// account, a URL, the block manager, and a content entity from any other +/// receiver. const STUBS: &[(&str, &str)] = &[ ( "web/core/lib/Drupal/Component/Plugin/PluginManagerInterface.php", @@ -74,6 +77,22 @@ const STUBS: &[(&str, &str)] = &[ "web/core/lib/Drupal/Core/Entity/Attribute/ContentEntityType.php", "blocks->createInstance('mod1_'); $themed = ['#theme' => 'mod1_']; $build = ['#attached' => ['library' => ['mod1/']]]; + $this->entity->get('mod1_'); } } "#; @@ -205,10 +227,17 @@ fn generate_site() -> Vec<(String, String)> { files.push((format!("{dir}/mod{m}.libraries.yml"), libraries)); for e in 0..ENTITY_TYPES { + let mut fields = String::new(); + for f in 0..FIELDS { + let _ = writeln!( + fields, + " $fields['mod{m}_field{f}'] = BaseFieldDefinition::create('string');" + ); + } files.push(( format!("{dir}/src/Entity/Entity{e}.php"), format!( - " ['mod1/'", "mod1/library7", ), + ( + "field_name", + &consumer, + CONSUMER_PHP, + "get('mod1_'", + "mod1_field7", + ), ( "hook_implementation", &module, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85a351e15..63c6c6ae6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -905,10 +905,12 @@ Provider priority order (highest first): 1. **Framework provider** (e.g. Laravel): richest type info 2. **PHPDoc provider**: `@method`, `@property`, `@property-read`, `@property-write`, `@mixin` -Two providers are currently registered in `default_providers()`: +Among the providers registered in `default_providers()`: - **`LaravelModelProvider`** (`virtual_members/laravel/model_provider.rs`): synthesizes virtual members for classes extending `Illuminate\Database\Eloquent\Model`. Produces relationship properties (methods returning `HasMany`, `HasOne`, `BelongsTo`, etc. generate a virtual property typed from the relationship's generic parameters), scope methods (both the `scopeActive` naming convention and the `#[Scope]` attribute from Laravel 11+ are supported; either style becomes `active()` as both static and instance), Builder-as-static forwarding (`User::where()->get()` resolves end-to-end), accessors (legacy `getXAttribute()` and modern `Attribute` casts), and cast properties (`$casts` array or `casts()` method entries are mapped to PHP types like `datetime` to `\Carbon\Carbon`, `boolean` to `bool`, framework class casts such as `AsEnumCollection::of(Status::class)` to `Collection`, custom cast classes to their `get()` return type). Highest priority among virtual member providers. Scope methods are also injected onto `Builder` instances via a post-generic-substitution hook in `type_hint_to_classes_depth` (see "Scope Methods on Builder Instances" below). - **`PHPDocProvider`** (`virtual_members/phpdoc.rs`, with `@mixin` collection in `phpdoc/mixins.rs`): surfaces `@method`, `@property`, `@property-read`, `@property-write`, and `@mixin` tags from the class-level docblock. Explicit `@method` / `@property` tags are parsed once during AST extraction into `ClassInfo.doc_members` (an `Arc`, `None` when the docblock declares neither), so `provide` only reads structured data and applies the per-consumer generic substitutions. The provider collects those tags from the class itself, its used traits, its parent chain, and its implemented interfaces. For `@mixin` tags, it loads the referenced classes and merges their public members. Within the provider, explicit tags take precedence over mixin members. Recurses into mixin-of-mixin chains up to `MAX_MIXIN_DEPTH`. +- **`FieldableEntityProvider`** (`drupal/fields.rs`), in a Drupal project only: gives the classes and interfaces under `FieldableEntityInterface` the fields of the entity types they may hold as typed properties, read from the entity classes' `baseFieldDefinitions()` (`drupal/base_fields.rs`) and from the field storage files of extensions and of the site's exported configuration (`drupal/field_storage.rs`), and gives the interfaces the untyped `__get()` and `__set()` every content entity has. Providers never see the `Backend`, so the field index reaches it through the resolved-class cache, whose slot reads the index through the `Backend` it is bound to when the workspace becomes a Drupal project. A reference field's target is the entity type its `target_type` setting names, the entity type's own `bundle_entity_type` for `getBundleEntityType()`, or else the default its item class's `defaultStorageSettings()` gives. An entity class's fields show on interfaces that do not depend on the entity class, so a change to them, to a field storage file, or to the parent, interfaces, or traits of a class the index was read through, evicts `FieldableEntityInterface` and everything under it, found through the inheritance index, once per publish batch. +- **`FieldItemProvider`** (`drupal/field_items.rs`), in a Drupal project only: gives field item classes the properties their `propertyDefinitions()` declare, following `parent::propertyDefinitions()`, from the same field index. An item list reads a property on its first item, and a field is typed `List` with a reference field's target class as a second argument, so the list's properties, its `first()` and `get()`, which give the item carrying the target, and the target's typing of `entity` and `referencedEntities()` depend on the type arguments: `resolve_class_for_type_hint` adds them, as it does Doctrine's repository members, and specialises the list's template with the item alone. A change to an item class's `propertyDefinitions()` evicts that class and every class that extends it, found through the inheritance index, since a resolved class records no dependency on an ancestor further up than its parent. ### Laravel Class Patches diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a688d3f58..2321a8ced 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -147,6 +147,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Unknown Drupal services.** In a Drupal project, a service ID that `\Drupal::service()` or a container's `get()` looks up is reported when no services file declares it and no service provider, compiler pass, or other PHP code could define it. A lookup the code checks with `has()` first, one that runs only when `moduleExists()` finds a module the site lacks, or one in a file that may catch the exception a missing service throws, is left alone, and so is test code. - **Drupal annotations without imports.** In a Drupal project, an annotation on a plugin class that no `use` imports, such as `@Block` or `@Translation`, jumps to its class, shows it on hover, and is found by Find References, wherever Drupal's plugin discovery would find it. - **Controller methods of a Drupal service.** In a routing file, the method of a `_controller` or `_title_callback` written as `service:method` jumps to the method of the service's class, completes from its public methods, and is listed among the method's references. +- **Drupal entity fields.** In a Drupal project, a content entity's fields are typed properties of the entity and of its interfaces, down to the entity a reference field points to. Field names written in code complete, show their type on hover, and jump to where the field is declared. Contributed by @giuseppe-esposito-87. #### WordPress diff --git a/docs/todo.md b/docs/todo.md index 7d86b1cbd..6bf81dee4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -134,7 +134,6 @@ ships, remove every row that names it. | 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 | | DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium | -| 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 | | DR18 | [Warn on unknown entity type IDs](todo/drupal.md#dr18-warn-on-unknown-entity-type-ids) | Medium | Low-Medium | | DR19 | [Warn on unknown hook names](todo/drupal.md#dr19-warn-on-unknown-hook-names) | Low-Medium | Medium | @@ -145,6 +144,9 @@ ships, remove every row that names it. | DR24 | [Warn on unknown theme hook names](todo/drupal.md#dr24-warn-on-unknown-theme-hook-names) | Low-Medium | Medium-High | | DR28 | [Plugin types a manager builds at run time](todo/drupal.md#dr28-plugin-types-a-manager-builds-at-run-time) | Medium | Medium | | DR27 | [Plugin IDs in YAML](todo/drupal.md#dr27-plugin-ids-in-yaml) | Low-Medium | Medium | +| DR29 | [Fields added by hooks](todo/drupal.md#dr29-fields-added-by-hooks) | Medium | Medium-High | +| DR30 | [Warn on unknown field names](todo/drupal.md#dr30-warn-on-unknown-field-names) (depends on DR29) | Medium | Low-Medium | +| DR31 | [Field names in entity queries and `loadByProperties()`](todo/drupal.md#dr31-field-names-in-entity-queries-and-loadbyproperties) | Low-Medium | Medium | ## Yii diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 8d7c316be..7a04253d4 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -12,8 +12,8 @@ the plugins of each, the permissions the `*.permissions.yml` files declare, the configuration objects and keys the default configuration and the configuration schema declare, the theme hooks `hook_theme()` implementations declare, the render elements element -classes declare, and the libraries the `*.libraries.yml` files declare -are read. Nothing reads fields yet. +classes declare, the libraries the `*.libraries.yml` files declare, and +the fields entity classes and field storage files declare are read. The requests in [#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the @@ -32,9 +32,9 @@ installs core into `web/core` through Composer (`memory_drupal_module`), next to the other framework scenarios. `benches/drupal_completion.rs` measures completion of service IDs, route names, entity type IDs, permission names, configuration names, render element names, plugin IDs, -hook names, theme hook names, library names, and hook implementations over a -generated site with many custom modules. Every item that adds a name -Drupal declares adds its case to it. +hook names, theme hook names, library names, field names, and hook +implementations over a generated site with many custom modules. Every +item that adds a name Drupal declares adds its case to it. Items are ordered by **impact** (descending), then **complexity** (ascending) within the same impact tier. @@ -46,30 +46,6 @@ within the same impact tier. --- -## DR4. Entity field access - -**Impact: Medium-High · Complexity: High** - -```php -$tags = $node->field_tags; // FieldItemListInterface -$title = $node->get('title')->value; -``` - -Fields are reached through magic properties and `get()`. A field name -comes from the entity class's `baseFieldDefinitions()` or from -configuration (`field.storage...yml` in the exported -config or a module's `config/install`). Type a field access as the field -item list its field type gives (an entity reference field gives -`EntityReferenceFieldItemListInterface`), and complete and navigate -field names. - -PHPStan's Drupal extension types any magic property on a fieldable -entity as `FieldItemListInterface` without checking the name. That is -the floor; reading the configuration is what lets an editor complete -names and report a misspelt one. - ---- - ## DR11. Form and render callbacks **Impact: Low-Medium · Complexity: Low-Medium** @@ -137,7 +113,7 @@ discouraged calls. Port them into `tests/phpstan_drupal/` the same way: the `assertType()` fixtures run by `tests/assert_type_runner.rs`, the rule tests as `.fixture` files, a slice of `drupal/core` copied by `scripts/extract-framework-stubs.php`, and upstream's test modules and -their `*.services.yml` as the project. Until DR2 to DR4 ship most +their `*.services.yml` as the project. Until the items that cover them ship most assertions are a `// SKIP` pointing at the item that covers them; file any that no item does. Rules about Drupal's own deprecations are listed in the README as not ported. @@ -268,8 +244,7 @@ Drupal builds them: each callback returns an array keyed by names such as `"create $type_id content"`, and the bundles and formats the names are built from are configuration entities (`node.type.*`, `filter.format.*`). Their names come from the default configuration, -and on most sites from the exported configuration, which -[DR4](#dr4-entity-field-access) reads. A name built from something the +and on most sites from the exported configuration. A name built from something the site's files do not hold could be kept as a pattern, as route builders' names are. @@ -365,3 +340,61 @@ argument, which each service in `views.services.yml` and sources and processes) neither complete nor navigate. Read the argument each such service passes, and type the service by the plugin type it builds. + +--- + +## DR29. Fields added by hooks + +**Impact: Medium · Complexity: Medium-High** + +```php +$state = $node->moderation_state->value; +$alias = $node->path->alias; +``` + +A field name comes from an entity class's `baseFieldDefinitions()` or +from a field storage file. Modules also add fields in code: +`hook_entity_base_field_info()` and `hook_entity_bundle_field_info()` +return field definitions, and `hook_entity_field_storage_info()` returns +storage definitions, as `content_moderation` does for `moderation_state` +and `path` does for `path`. Those names are not read, so they stay +`mixed` and do not complete. Read the definitions an implementation +builds, as the base field reader does for a class, including those it +adds from a helper, and keep the ones that depend on a condition (a +bundle, a module's setting) as fields of every bundle. + +--- + +## DR30. Warn on unknown field names + +**Impact: Medium · Complexity: Low-Medium** + +```php +$node->hasField('field_tgas'); +$node->get('field_tgas'); +``` + +A field name no entity type declares is a typo or a field the site +lacks, and `get()` throws for it at run time. Report one where +`get()`, `set()`, `hasField()`, and `getFieldDefinition()` take it. This +depends on [DR29](#dr29-fields-added-by-hooks): without the fields hooks +add, `moderation_state` would be reported on every moderated site. +A lookup the code checks with `hasField()` first is left alone. + +--- + +## DR31. Field names in entity queries and `loadByProperties()` + +**Impact: Low-Medium · Complexity: Medium** + +```php +$ids = $storage->getQuery()->condition('field_tags.target_id', $tid)->execute(); +$nodes = $storage->loadByProperties(['field_tags' => $tid]); +``` + +A field name completes and navigates in `get()` and the other calls +that take one on an entity, but not where it is a condition of an entity +query or a key of `loadByProperties()`. Read the entity type from the +storage or query, offer its field names there, and for a condition read +the field property after the dot (`field_tags.target_id`) from the +field's item class. diff --git a/examples/drupal/README.md b/examples/drupal/README.md index a58191439..3ba584f3d 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -43,6 +43,32 @@ 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. +- An entity's base fields are typed properties of its class and of its + interfaces. In `web/modules/custom/acme/src/NoteSummary.php`, complete + after `$note->`: `title` comes from `Note`, `id` and `uuid` from core's + `ContentEntityBase` under the names the entity keys give, and `uid` from + core's owner trait. Hover `$title`, a list of `StringItem` items, and + `$author`, an entity reference list that names `User`, or go to the + definition of `title`, which lands on its `$fields['title']` line in + `Note`. +- Configurable fields come from field storage files: the site's exported + configuration in `config/sync`, beside the web root, and the default + configuration of modules and profiles. In `NoteSummary.php`, `field_tags` + completes after `$note->` too; hover `$tags`, an entity reference list + that names `Term`, or go to the definition of `field_tags`, which lands in + `config/sync/field.storage.acme_note.field_tags.yml`. +- A field reads the properties of its first item, the ones its item class + declares in `propertyDefinitions()`, inherited ones included. In + `NoteSummary.php`, complete after `$note->field_tags->` to see + `target_id` and `entity`; hover `$firstTag`, a `?Term`, and + `$authorAccount`, a `?User`, the class of the entity type each field + points to. Every stored value, such as `$titleText`, stays `mixed`. +- A field's name in a fieldable entity's `get()`, `set()`, `hasField()`, + and `getFieldDefinition()` is one of its fields. In `NoteSummary.php`, + complete inside `hasField('')` in `isTagged()`, go to the definition of + `'field_tags'`, which lands in its field storage file, hover it for its + field type, or find its references, the `$note->field_tags` reads + included; `$tags`, from `get('field_tags')`, is typed as the field is. - 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 diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 39fdf41ae..0fedd31d5 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -17,12 +17,14 @@ use Drupal\acme\Controller\NoteController; use Drupal\acme\Element\GreetingCard; use Drupal\acme\Entity\Note; +use Drupal\acme\Entity\NoteInterface; use Drupal\acme\Form\SettingsForm; use Drupal\acme\Greeter; use Drupal\acme\Hook\AcmeHooks; use Drupal\acme\Plugin\Block\FarewellBlock; use Drupal\acme\Plugin\Block\GreetingBlock; use Drupal\acme\NoteCounter; +use Drupal\acme\NoteSummary; use Drupal\acme\RequestClock; use Drupal\Component\Datetime\Time; use Drupal\Component\Datetime\TimeInterface; @@ -32,13 +34,18 @@ use Drupal\Core\Block\Attribute\Block; use Drupal\Core\Block\BlockBase; use Drupal\Core\Block\BlockManager; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\Attribute\FieldType; use Drupal\Core\Field\Attribute\FieldWidget; +use Drupal\Core\Field\EntityReferenceFieldItemList; +use Drupal\Core\Field\FieldItemList; +use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; use Drupal\Core\Field\Plugin\Field\FieldType\StringItem; use Drupal\Core\Field\Plugin\Field\FieldWidget\StringTextfieldWidget; use Drupal\Core\Validation\Attribute\Constraint; use Drupal\Core\Validation\Plugin\Validation\Constraint\UniqueFieldConstraint; use Drupal\Core\Config\ConfigBase; +use Drupal\Core\Config\FileStorage; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; @@ -66,6 +73,10 @@ use Drupal\Core\Url; use Drupal\language\ConfigurableLanguageManager; use Drupal\language\LanguageServiceProvider; +use Drupal\taxonomy\Entity\Term; +use Drupal\user\Entity\User; +use Drupal\user\EntityOwnerInterface; +use Drupal\user\EntityOwnerTrait; $passed = 0; $failed = 0; @@ -98,10 +109,10 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string $module = "$web/modules/custom/acme"; $theme = "$web/themes/custom/acme_theme"; -// The demo's classes live in the module, and the language module's in -// core, which only a booted site adds to the autoloader. +// The demo's classes live in the module, and the language, taxonomy, and +// user modules' in core, which only a booted site adds to the autoloader. spl_autoload_register(static function (string $class) use ($module, $web): void { - foreach (['acme' => $module, 'language' => "$web/core/modules/language"] as $name => $dir) { + foreach (['acme' => $module, 'language' => "$web/core/modules/language", 'taxonomy' => "$web/core/modules/taxonomy", 'user' => "$web/core/modules/user"] as $name => $dir) { $prefix = "Drupal\\$name\\"; if (str_starts_with($class, $prefix)) { $file = "$dir/src/" . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; @@ -240,6 +251,68 @@ protected function getDefaultLanguageValues() 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');")); +// Base fields: the ones Note declares, the ones its parent and the owner +// trait declare under the names the entity keys give, and their field +// types' item and item list classes (core's EntityFieldManager and +// FieldTypePluginManager). +$noteSource = (string) file_get_contents("$module/src/Entity/Note.php"); +$contentEntitySource = (string) file_get_contents("$web/core/lib/Drupal/Core/Entity/ContentEntityBase.php"); +$ownerTrait = new ReflectionClass(EntityOwnerTrait::class); +$ownerSource = (string) file_get_contents((string) $ownerTrait->getFileName()); +$summarySource = (string) file_get_contents("$module/src/NoteSummary.php"); +check('Note creates its title field from the string field type', str_contains($noteSource, "\$fields['title'] = BaseFieldDefinition::create('string')")); +check("Note takes ContentEntityBase's fields and the owner trait's", str_contains($noteSource, '$fields = parent::baseFieldDefinitions($entity_type);') && str_contains($noteSource, '$fields += static::ownerBaseFieldDefinitions($entity_type);')); +check('Note uses the owner trait and implements its interface, which ownerBaseFieldDefinitions() requires', in_array(EntityOwnerTrait::class, $note->getTraitNames(), true) && $note->implementsInterface(EntityOwnerInterface::class)); +check("ContentEntityBase names the id and uuid fields after the entity keys", str_contains($contentEntitySource, "\$fields[\$entity_type->getKey('id')] = BaseFieldDefinition::create('integer')") && str_contains($contentEntitySource, "\$fields[\$entity_type->getKey('uuid')] = BaseFieldDefinition::create('uuid')")); +check("acme_note's id and uuid keys name the id and uuid fields", ($noteType['entity_keys']['id'] ?? null) === 'id' && ($noteType['entity_keys']['uuid'] ?? null) === 'uuid'); +check("acme_note's owner key names the uid field the owner trait creates", ($noteType['entity_keys']['owner'] ?? null) === 'uid' && str_contains($ownerSource, "\$entity_type->getKey('owner') => BaseFieldDefinition::create('entity_reference')")); +check("the owner trait's field points to user entities", str_contains($ownerSource, "->setSetting('target_type', 'user')") && ((new ReflectionClass(User::class))->getAttributes(ContentEntityType::class)[0]->getArguments()['id'] ?? null) === 'user'); +check('acme_note is neither translatable nor revisionable, so the entity field manager adds no field of its own', !isset($noteType['translatable']) && !isset($noteType['entity_keys']['revision'])); +$stringType = attributeArguments(new ReflectionClass(StringItem::class), FieldType::class); +$referenceType = attributeArguments(new ReflectionClass(EntityReferenceItem::class), FieldType::class); +check('StringItem is the string field type, kept in the default FieldItemList', ($stringType['id'] ?? null) === 'string' && !isset($stringType['list_class']) && class_exists(FieldItemList::class)); +check('EntityReferenceItem is the entity_reference field type, kept in an EntityReferenceFieldItemList', ($referenceType['id'] ?? null) === 'entity_reference' && ($referenceType['list_class'] ?? null) === EntityReferenceFieldItemList::class); +check('NoteSummary reads the title and uid fields of a NoteInterface, which Note implements', str_contains($summarySource, '$note->title;') && str_contains($summarySource, '$note->uid;') && $note->implementsInterface(NoteInterface::class)); +check('NoteInterface is a content entity interface, which has fields', is_subclass_of(NoteInterface::class, ContentEntityInterface::class)); + +// Configurable fields: the site's exported field storage, read with core's +// configuration file storage, declares field_tags on acme_note, an entity +// reference to taxonomy terms (core's FieldStorageConfig). +$sync = new FileStorage(__DIR__ . '/config/sync'); +$tagsStorage = $sync->read('field.storage.acme_note.field_tags'); +check('the exported configuration declares the field_tags storage', is_array($tagsStorage) && in_array('field.storage.acme_note.field_tags', $sync->listAll('field.storage.'), true)); +check('field_tags is a field of acme_note', ($tagsStorage['entity_type'] ?? null) === ($noteType['id'] ?? null) && ($tagsStorage['field_name'] ?? null) === 'field_tags' && ($tagsStorage['id'] ?? null) === 'acme_note.field_tags'); +check('field_tags is an entity_reference field, kept in an EntityReferenceFieldItemList', ($tagsStorage['type'] ?? null) === ($referenceType['id'] ?? null)); +check('field_tags points to taxonomy terms, whose class is Term', ($tagsStorage['settings']['target_type'] ?? null) === 'taxonomy_term' && (attributeArguments(new ReflectionClass(Term::class), ContentEntityType::class)['id'] ?? null) === 'taxonomy_term'); +check('the exported field attaches field_tags to the acme_note bundle', ($sync->read('field.field.acme_note.acme_note.field_tags')['field_name'] ?? null) === 'field_tags'); +check('NoteSummary reads the field_tags field of a NoteInterface', str_contains($summarySource, '$note->field_tags;')); + +// Field properties: an item list reads a property on its first item, and +// the item's propertyDefinitions() declares the property, taking its +// parent's (core's FieldItemList and the field type item classes). +$fieldItemListSource = (string) file_get_contents((string) (new ReflectionClass(FieldItemList::class))->getFileName()); +$referenceItemSource = (string) file_get_contents((string) (new ReflectionClass(EntityReferenceItem::class))->getFileName()); +$stringDefinitions = new ReflectionMethod(StringItem::class, 'propertyDefinitions'); +$stringDefinitionsSource = (string) file_get_contents((string) $stringDefinitions->getDeclaringClass()->getFileName()); +check('FieldItemList reads a property on its first item', str_contains($fieldItemListSource, 'if ($item = $this->first()) {') && str_contains($fieldItemListSource, 'return $item->__get($property_name);')); +check("EntityReferenceItem declares the target_id and entity properties, entity as a reference to an entity", str_contains($referenceItemSource, "\$properties['target_id'] = \$target_id_definition;") && str_contains($referenceItemSource, "\$properties['entity'] = DataReferenceDefinition::create('entity')")); +check("StringItem takes its value property from the item class it extends", $stringDefinitions->getDeclaringClass()->getName() !== StringItem::class && is_subclass_of(StringItem::class, $stringDefinitions->getDeclaringClass()->getName()) && str_contains($stringDefinitionsSource, "\$properties['value'] = DataDefinition::create('string')")); +check('NoteSummary reads the entity of field_tags and uid, and the value of the title field', str_contains($summarySource, '$note->field_tags->entity;') && str_contains($summarySource, '$note->uid->entity;') && str_contains($summarySource, '$titleText = $title->value;')); +check('NoteSummary allows for an empty reference field, whose entity is null', str_contains($summarySource, '$firstTag?->label()') && str_contains($summarySource, '$authorAccount?->label()')); +check('NoteSummary returns a string', (new ReflectionMethod(NoteSummary::class, 'summarize'))->getReturnType()?->getName() === 'string'); + +// Field names in code: a fieldable entity's get(), set(), hasField(), and +// getFieldDefinition() take a field's name first, and get() gives the +// field's item list (core's FieldableEntityInterface). +foreach (['get', 'set', 'hasField', 'getFieldDefinition'] as $method) { + $parameter = (new ReflectionMethod(FieldableEntityInterface::class, $method))->getParameters()[0]->getName(); + check("FieldableEntityInterface::$method() takes a field's name first", in_array($parameter, ['field_name', 'name'], true)); +} +check('FieldableEntityInterface::get() returns the field item list', preg_match('/@return \\\\Drupal\\\\Core\\\\Field\\\\FieldItemListInterface$/m', (string) (new ReflectionMethod(FieldableEntityInterface::class, 'get'))->getDocComment()) === 1); +check('NoteInterface is fieldable', is_subclass_of(NoteInterface::class, FieldableEntityInterface::class)); +check('NoteSummary::isTagged() asks for and reads field_tags by name', str_contains($summarySource, "\$note->hasField('field_tags')") && str_contains($summarySource, "\$tags = \$note->get('field_tags');")); +check('NoteSummary::isTagged() returns a bool', (new ReflectionMethod(NoteSummary::class, 'isTagged'))->getReturnType()?->getName() === 'bool'); + // 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()). diff --git a/examples/drupal/config/sync/field.field.acme_note.acme_note.field_tags.yml b/examples/drupal/config/sync/field.field.acme_note.acme_note.field_tags.yml new file mode 100644 index 000000000..0418760b1 --- /dev/null +++ b/examples/drupal/config/sync/field.field.acme_note.acme_note.field_tags.yml @@ -0,0 +1,28 @@ +uuid: 2b7d8e9f-0a1b-4c2d-8e3f-4a5b6c7d8e9f +langcode: en +status: true +dependencies: + config: + - field.storage.acme_note.field_tags + - taxonomy.vocabulary.tags + module: + - acme +id: acme_note.acme_note.field_tags +field_name: field_tags +entity_type: acme_note +bundle: acme_note +label: Tags +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + tags: tags + sort: + field: _none + auto_create: true +field_type: entity_reference diff --git a/examples/drupal/config/sync/field.storage.acme_note.field_tags.yml b/examples/drupal/config/sync/field.storage.acme_note.field_tags.yml new file mode 100644 index 000000000..464df1ad6 --- /dev/null +++ b/examples/drupal/config/sync/field.storage.acme_note.field_tags.yml @@ -0,0 +1,20 @@ +uuid: 6f1c2a3e-9b4d-4c8e-a1f2-3d4e5f6a7b8c +langcode: en +status: true +dependencies: + module: + - acme + - taxonomy +id: acme_note.field_tags +field_name: field_tags +entity_type: acme_note +type: entity_reference +settings: + target_type: taxonomy_term +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/examples/drupal/web/modules/custom/acme/src/Entity/Note.php b/examples/drupal/web/modules/custom/acme/src/Entity/Note.php index 8e12ea367..204fe740f 100644 --- a/examples/drupal/web/modules/custom/acme/src/Entity/Note.php +++ b/examples/drupal/web/modules/custom/acme/src/Entity/Note.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\user\EntityOwnerTrait; /** * A short note, declared by attribute the way Drupal 11 core declares @@ -21,19 +22,27 @@ 'id' => 'id', 'uuid' => 'uuid', 'label' => 'title', + 'owner' => 'uid', ], base_table: 'acme_note', // Try: complete a permission inside `admin_permission: ''`. admin_permission: 'administer acme', )] -final class Note extends ContentEntityBase +final class Note extends ContentEntityBase implements NoteInterface { + use EntityOwnerTrait; + /** * @return array */ public static function baseFieldDefinitions(EntityTypeInterface $entity_type): array { + // Try: in `src/NoteSummary.php`, `$note->` offers the fields this + // method declares, its parent's and the owner trait's: `id` and + // `uuid` come from ContentEntityBase under the names the attribute + // gives the keys, and `uid` from core's EntityOwnerTrait. $fields = parent::baseFieldDefinitions($entity_type); + $fields += static::ownerBaseFieldDefinitions($entity_type); // Try: complete after `BaseFieldDefinition::create('string')->`. Each // setter returns the definition, so the chain keeps completing. Go to diff --git a/examples/drupal/web/modules/custom/acme/src/Entity/NoteInterface.php b/examples/drupal/web/modules/custom/acme/src/Entity/NoteInterface.php new file mode 100644 index 000000000..1dc22159c --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/Entity/NoteInterface.php @@ -0,0 +1,14 @@ +`. The base fields of acme_note are + // offered: `title` from Note, `id` and `uuid` from ContentEntityBase, + // and `uid` from core's owner trait. Hover `$title`, the list of + // StringItem items the `string` field type keeps, and `$author`, an + // entity reference list that also names `User`, the class of the + // entity type it points to. Go to the definition of `title`, which + // lands on its `$fields['title']` line in Note, or of `uid`, which + // lands in core's EntityOwnerTrait. + $title = $note->title; + $author = $note->uid; + // Try: `field_tags` is a configurable field, which the site's + // exported configuration declares in + // `config/sync/field.storage.acme_note.field_tags.yml`. It completes + // after `$note->` with the base fields, `$tags` is an entity + // reference list that names `Term`, the class of the taxonomy_term + // entity type its `target_type` gives, and go-to-definition on + // `field_tags` lands in that file. + $tags = $note->field_tags; + // Try: a field reads the properties of its first item, the ones its + // item class declares in `propertyDefinitions()`. Complete after + // `$note->field_tags->`: `target_id` and `entity` come from + // EntityReferenceItem. Hover `$firstTag`, a `?Term`, null when the + // field is empty, and `$authorAccount`, a `?User`; `->label()` after + // either navigates. `$titleText` is `mixed`, as every stored value + // is: the database gives it as it is. + $firstTag = $note->field_tags->entity; + $authorAccount = $note->uid->entity; + $titleText = $title->value; + + return sprintf('%s (%d, %d tags, first %s, by %s)', (string) $titleText, count($author), count($tags), (string) $firstTag?->label(), (string) $authorAccount?->label()); + } + + public function isTagged(NoteInterface $note): bool + { + // Try: the name `hasField()` takes is one of acme_note's fields: + // complete inside `hasField('')` to see the base and configurable + // fields, go to the definition of `'field_tags'`, which lands in its + // field storage file, hover it for its field type, or find its + // references, which include the `$note->field_tags` reads. + if (!$note->hasField('field_tags')) { + return FALSE; + } + // Try: `get('field_tags')` reads the same field `$note->field_tags` + // does, so `$tags` is an entity reference list that names `Term`; + // complete, navigate, and hover the name as in `hasField()`. + $tags = $note->get('field_tags'); + return !$tags->isEmpty(); + } +} diff --git a/src/backend/construction.rs b/src/backend/construction.rs index 995d1bbf7..4a8ed5185 100644 --- a/src/backend/construction.rs +++ b/src/backend/construction.rs @@ -83,6 +83,13 @@ impl BackendState { Arc::clone(&is_doctrine), Arc::clone(&doctrine_mapping_files), )); + let drupal = crate::drupal::DrupalState::default(); + resolved_class_cache + .write() + .set_drupal(crate::drupal::fields::DrupalCacheState::new( + Arc::clone(&workspace.is_drupal), + drupal.field_slot(), + )); Self { name: "PHPantom".to_string(), version: env!("PHPANTOM_GIT_VERSION").to_string(), @@ -127,7 +134,7 @@ impl BackendState { yii: crate::yii::YiiState::default(), stylesheets: crate::stylesheets::StylesheetState::default(), wordpress_hooks: crate::wordpress::hooks::HookFiles::default(), - drupal: crate::drupal::DrupalState::default(), + drupal, pest_configs: Arc::new(RwLock::new(None)), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), diag: crate::diagnostics::state::DiagnosticState::new(), diff --git a/src/backend/settings.rs b/src/backend/settings.rs index bf062d8ed..aa072fad9 100644 --- a/src/backend/settings.rs +++ b/src/backend/settings.rs @@ -140,6 +140,8 @@ impl Backend { /// Only ever turned on: a workspace whose subprojects include one /// Drupal site treats those extensions as PHP throughout. pub(crate) fn mark_drupal_project(&self) { + // Before the flag, so no provider finds the fields unreadable. + self.drupal.bind_fields(&self.state); if !self.workspace.is_drupal.swap(true, Ordering::AcqRel) { *self.workspace.index_filters.write() = None; } diff --git a/src/blade/typed_receiver.rs b/src/blade/typed_receiver.rs index 81b5532e4..8bec0b9d3 100644 --- a/src/blade/typed_receiver.rs +++ b/src/blade/typed_receiver.rs @@ -195,6 +195,25 @@ impl Backend { } continue; } + if kind == StringKeyKind::DrupalFieldName { + // The name is the field every entity type the + // receiver may hold has, known or not: the + // fields are looked up when asked, so a field + // storage file added later is not missed. + let entity_types = self + .drupal_receiver_entity_types(&ty, loaders.resolved_class_loader()); + if !entity_types.is_empty() { + let mut span = site.to_span(kind); + if let crate::symbol_map::SymbolKind::StringKey { key, .. } = + &mut span.kind + { + *key = + crate::drupal::fields::field_key(&entity_types, &site.key); + } + confirmed.push(span); + } + continue; + } if kind == StringKeyKind::DrupalPlugin && site.receiver.bare_plugin_key() { // The ID is one of the manager's the receiver // is, or the one the call always looks it up in. diff --git a/src/classmap_scanner/discovery.rs b/src/classmap_scanner/discovery.rs index 9bce10696..fe3285f2f 100644 --- a/src/classmap_scanner/discovery.rs +++ b/src/classmap_scanner/discovery.rs @@ -715,6 +715,11 @@ pub fn scan_drupal_directories( declaration_files.declarations.push(path.to_path_buf()); } } else if crate::drupal::config::is_default_config_file(path, web_root) { + // A field storage file is read for the field it declares, + // where the workspace walk does not reach core and contrib. + if crate::drupal::field_storage::is_field_storage_file(path) { + declaration_files.declarations.push(path.to_path_buf()); + } declaration_files.default_config.push(path.to_path_buf()); } else if crate::drupal::config::is_schema_file(path, web_root) { declaration_files.config_schemas.push(path.to_path_buf()); @@ -748,7 +753,8 @@ pub fn scan_drupal_directories( #[derive(Default)] pub struct DrupalDeclarationFiles { /// The YAML files Drupal reads declarations from (see - /// `drupal::is_declaration_file`) and the PHP files that document or + /// `drupal::is_declaration_file`), the field storage files among the + /// default configuration files, and the PHP files that document or /// implement hooks (see `drupal::is_hook_php_file`). pub declarations: Vec, /// The service providers whose `alter()` may give a service another diff --git a/src/classmap_scanner/discovery_tests.rs b/src/classmap_scanner/discovery_tests.rs index 26d3c7483..56f1ae990 100644 --- a/src/classmap_scanner/discovery_tests.rs +++ b/src/classmap_scanner/discovery_tests.rs @@ -824,6 +824,34 @@ fn scan_drupal_directories_collects_declaration_files_outside_tests() { ); } +/// Core's and contributed extensions' field storage files are read for +/// their fields; the other default configuration files are only listed. +#[test] +fn scan_drupal_directories_reads_field_storage_files_of_extensions() { + let dir = tempfile::tempdir().unwrap(); + let web_root = dir.path(); + for path in [ + "core/profiles/standard/config/install/field.storage.user.user_picture.yml", + "core/profiles/standard/config/install/user.settings.yml", + "core/recipes/article_tags/config/field.storage.node.field_tags.yml", + "core/modules/node/tests/modules/node_test/config/install/field.storage.node.field_test.yml", + ] { + let full = web_root.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, "id: example\n").unwrap(); + } + + let (_, files) = scan_drupal_directories(web_root, &IndexFilters::empty(), None); + assert_eq!( + files.declarations, + vec![ + web_root + .join("core/profiles/standard/config/install/field.storage.user.user_picture.yml") + ] + ); + assert_eq!(files.default_config.len(), 2); +} + #[test] fn scan_drupal_directories_records_templates_outside_tests() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index 4ec88ad10..a2e6e5067 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -50,7 +50,9 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { | StringKeyKind::YiiTranslation => CompletionItemKind::Text, StringKeyKind::DrupalPermission => CompletionItemKind::Constant, StringKeyKind::DrupalConfigName => CompletionItemKind::File, - StringKeyKind::DrupalConfigKey => CompletionItemKind::Field, + StringKeyKind::DrupalConfigKey | StringKeyKind::DrupalFieldName => { + CompletionItemKind::Field + } StringKeyKind::TranslationDomain => CompletionItemKind::Module, StringKeyKind::MorphAlias => CompletionItemKind::EnumMember, StringKeyKind::Middleware => CompletionItemKind::Class, @@ -242,7 +244,8 @@ impl Backend { // definition's argument from the method it is passed to, and a // plugin ID from the manager its receiver is, and a Yii view's // parameter from the template its render names - // (`crate::completion::yii_views`). A hook name is + // (`crate::completion::yii_views`), and a field name from the + // entity types its receiver may hold. A hook name is // completed with its patterns as snippets, which only the // WordPress completion builds. StringKeyKind::SymfonyTranslation @@ -257,6 +260,7 @@ impl Backend { | StringKeyKind::DrupalConfigKey | StringKeyKind::DrupalThemeHookVariable | StringKeyKind::DrupalPlugin + | StringKeyKind::DrupalFieldName | StringKeyKind::Section | StringKeyKind::Stack | StringKeyKind::TwigBlock diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index 4b62239c3..a94e59343 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -105,6 +105,22 @@ impl Backend { ); return self.drupal_config_key_response(&names, content, prefix_start, position); } + // A field name: one of the fields of the entity types the + // receiver's type may hold. + if kind == StringKeyKind::DrupalFieldName { + let names = self.drupal_receiver_field_names( + receiver_type.as_ref()?, + self.file_loaders(ctx).resolved_class_loader(), + ); + return self.symfony_names_response( + kind, + names, + content, + prefix_start, + cursor, + position, + ); + } // A plugin ID: the manager's, which the receiver's type is, or the // one the call always looks it up in. if kind == StringKeyKind::DrupalPlugin { @@ -487,12 +503,23 @@ impl Backend { ctx, ); } + // A fieldable entity's `get()` and `set()` are a container lookup's + // and a configurator's too, and its `hasField()` is no one else's. + let field_names = call.arg_index == 0 + && self.is_drupal_project() + && crate::drupal::fields::field_name_call(&call.method_name).is_some(); + let receivers: Vec = + symfony_receiver_method(&call.method_name, call.arg_index) + .map(|(receiver, _)| receiver) + .into_iter() + .chain(field_names.then_some(crate::symbol_map::ReceiverClass::FieldableEntity)) + .collect(); // The receiver's type is kept for what it carries besides its - // class: a config object's name. - let (receiver, _) = symfony_receiver_method(&call.method_name, call.arg_index)?; - let (kind, ty) = self.receiver_key_and_type( + // class: a config object's name, or the entity types a fieldable + // entity may be. + let (kind, ty) = self.receivers_key_and_type( subject, - (receiver, &call.method_name, call.arg_index), + (&receivers, &call.method_name, call.arg_index), content, cursor, ctx, @@ -538,10 +565,29 @@ impl Backend { content: &str, cursor: usize, ctx: &FileContext, + ) -> Option<(StringKeyKind, crate::php_type::PhpType)> { + self.receivers_key_and_type(subject, (&[receiver], method, index), content, cursor, ctx) + } + + /// [`Self::receiver_key_and_type`] for a call several receivers may + /// take, the first that the subject is confirmed to be, resolving the + /// subject once for all of them. + fn receivers_key_and_type( + &self, + subject: &str, + (receivers, method, index): (&[crate::symbol_map::ReceiverClass], &str, usize), + content: &str, + cursor: usize, + ctx: &FileContext, ) -> Option<(StringKeyKind, crate::php_type::PhpType)> { // Resolving the receiver is the costly part, and is wasted on a // call whose every kind of key the project does not read. - if !receiver.names_any(|kind| self.string_key_kind_enabled(kind)) { + let receivers: Vec<_> = receivers + .iter() + .copied() + .filter(|receiver| receiver.names_any(|kind| self.string_key_kind_enabled(kind))) + .collect(); + if receivers.is_empty() { return None; } let loaders = self.file_loaders(ctx); @@ -551,14 +597,13 @@ impl Backend { .into_iter() .find_map(|resolved| { let class = resolved.class_info?; - self.typed_receiver_key_kind( - &crate::php_type::PhpType::named(class.fqn()), - method, - index, - receiver, - class_loader, - ) - .map(|kind| (kind, resolved.type_string)) + let ty = crate::php_type::PhpType::named(class.fqn()); + receivers + .iter() + .find_map(|receiver| { + self.typed_receiver_key_kind(&ty, method, index, *receiver, class_loader) + }) + .map(|kind| (kind, resolved.type_string)) }) } diff --git a/src/definition/member/mod.rs b/src/definition/member/mod.rs index 7e28b4aa7..e76190636 100644 --- a/src/definition/member/mod.rs +++ b/src/definition/member/mod.rs @@ -214,7 +214,7 @@ impl Backend { content: &str, position: Position, mctx: &MemberDefinitionCtx<'_>, - ) -> Option { + ) -> Option> { let cursor_offset = position_to_offset(content, position); let ctx = self.file_context_at(uri, cursor_offset); let loaders = self.file_loaders(&ctx); @@ -251,7 +251,7 @@ impl Backend { if let Some(location) = candidates.iter().find_map(|record| { self.yii_column_location(record, mctx.member_name, class_loader) }) { - return Some(location); + return Some(vec![location]); } } crate::symbol_map::DocblockMemberRef::YiiProperty { form_model: true } => { @@ -282,10 +282,19 @@ impl Backend { // the real inheritance/mixin chain. let raw_class = Self::reload_raw_class(target_class, &ctx.classes, class_loader); let lookup_class = raw_class.as_ref().unwrap_or(target_class); + // A Drupal entity field goes to every place its entity types + // declare it. + if lookup.access_hint == MemberAccessHint::PropertyAccess { + let fields = + self.drupal_field_locations(lookup_class, lookup.member_name, class_loader); + if !fields.is_empty() { + return Some(fields); + } + } if let Some(location) = self.member_definition_on_candidate(&lookup, target_class, lookup_class) { - return Some(location); + return Some(vec![location]); } } @@ -293,6 +302,7 @@ impl Backend { // so we at least get partial results when possible. let raw_fallback = Self::reload_raw_class(first, &ctx.classes, class_loader); self.member_definition_fallback(&lookup, first, raw_fallback.as_ref().unwrap_or(first)) + .map(|location| vec![location]) } /// Look the member up on one candidate class of the subject. diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs index 02d6ef51f..ef11740d7 100644 --- a/src/definition/resolve.rs +++ b/src/definition/resolve.rs @@ -383,7 +383,6 @@ impl Backend { }; self.resolve_member_definition_with(uri, content, position, &mctx) - .map(|loc| vec![loc]) } SymbolKind::SelfStaticParent(ssp_kind) => { @@ -468,10 +467,10 @@ impl Backend { }, docblock_ref: DocblockMemberRef::No, }; - if let Some(loc) = + if let Some(locations) = self.resolve_member_definition_with(uri, content, position, &mctx) { - return Some(vec![loc]); + return Some(locations); } } diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index d35a55de4..31deade59 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -121,7 +121,8 @@ impl CheckedStringKind { // 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. A module can add - // an entity type in PHP, through `hook_entity_type_build()`. + // an entity type in PHP, through `hook_entity_type_build()`, + // and fields, through `hook_entity_base_field_info()`. // Drupal sends a route parameter no path declares as the query // string, and so does a Yii URL generator a route argument. A serializer group nothing puts a member in selects // nothing, which is no error either. A module can declare @@ -146,6 +147,7 @@ impl CheckedStringKind { | StringKeyKind::DrupalPermission | StringKeyKind::DrupalConfigName | StringKeyKind::DrupalConfigKey + | StringKeyKind::DrupalFieldName | StringKeyKind::DrupalThemeHook | StringKeyKind::DrupalThemeHookVariable | StringKeyKind::TranslationDomain diff --git a/src/drupal/base_fields.rs b/src/drupal/base_fields.rs new file mode 100644 index 000000000..225cf25ca --- /dev/null +++ b/src/drupal/base_fields.rs @@ -0,0 +1,1117 @@ +//! The base fields of Drupal's content entity types, read the way Drupal's +//! entity field manager builds them. +//! +//! An entity class's `baseFieldDefinitions()` creates each field with +//! `BaseFieldDefinition::create('type')` under its name, and takes the +//! fields of the methods it calls on `parent::`, `static::`, or `self::`: +//! `ContentEntityBase`'s key fields, and the owner, published, and revision +//! log fields core's traits give. A name written as +//! `$entity_type->getKey('owner')` is the field the entity type names for +//! that key, and a key the entity type lacks drops the field, as `hasKey()` +//! does in core. The field manager then adds three boolean fields of its +//! own (`EntityFieldManager::buildBaseFieldDefinitions()`). +//! +//! The entity types and base field methods each file declares are copied +//! out of its symbol map as it is published, since core's traits and base +//! classes let go of their parse once indexed, with the configurable field +//! each field storage file declares (see [`super::field_storage`]), and +//! read into a [`FieldIndex`] on first use. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use tower_lsp_server::gen_lsp_types::{Location, Range}; + +use super::fields::{EntityTypeFields, FIELDABLE_ENTITY, Field, FieldDeclaration, FieldIndex}; +use crate::Backend; +use crate::atom::Atom; +use crate::class_lookup::{Fqn, ResolvedClassLoader}; +use crate::php_type::PhpType; +use crate::symbol_map::{ + BaseFieldMethodSite, BaseFieldName, DefaultTarget, EntityTypeSite, FieldItemSite, + FieldStorageSite, FieldTarget, MethodScope, SymbolMap, +}; +use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH}; + +/// The item list class of a field type that names none. +const FIELD_ITEM_LIST: &str = "Drupal\\Core\\Field\\FieldItemList"; +/// What a field is when its field type is not known. +const FIELD_ITEM_LIST_INTERFACE: &str = "Drupal\\Core\\Field\\FieldItemListInterface"; +/// The field type of the fields the entity field manager adds. +const BOOLEAN: &str = "boolean"; + +/// What one file gives the field index, copied out of its symbol map. +#[derive(Debug, PartialEq)] +pub(crate) struct FieldSources { + uri: Arc, + /// The entity types the file declares, by ID. + entity_types: Vec<(String, EntityTypeSite)>, + methods: Vec, + /// What the file's field item classes declare about their items. + field_items: Vec, + /// The configurable field the file declares, when it is a field + /// storage file. + storage: Option, +} + +impl FieldSources { + /// What `map`, the symbol map of the file at `uri`, gives, if anything: + /// its entity types and base field methods when `declarations` counts + /// them, and its configurable field when `storage` counts it. + fn of( + uri: &str, + map: &SymbolMap, + declarations: impl FnOnce() -> bool, + storage: impl FnOnce() -> bool, + ) -> Option { + let fields = map.drupal_fields.as_deref(); + let declares = (!map.entity_types.is_empty() + || fields.is_some_and(|fields| { + !fields.base_field_methods.is_empty() || !fields.field_items.is_empty() + })) + && declarations(); + let storage = fields + .and_then(|fields| fields.storage.as_ref()) + .filter(|_| storage()) + .cloned(); + if !declares && storage.is_none() { + return None; + } + Some(Self { + uri: Arc::from(uri), + entity_types: if declares { + super::entity_type_declarations(map) + .map(|(id, site)| (id.to_string(), site.clone())) + .collect() + } else { + Vec::new() + }, + methods: match fields { + Some(fields) if declares => fields.base_field_methods.clone(), + _ => Vec::new(), + }, + field_items: match fields { + Some(fields) if declares => fields.field_items.clone(), + _ => Vec::new(), + }, + storage, + }) + } + + /// Whether the two give the same fields, wherever they are written. + fn declares_as(&self, other: &Self) -> bool { + self.entity_types.len() == other.entity_types.len() + && self + .entity_types + .iter() + .zip(&other.entity_types) + .all(|((a_id, a), (b_id, b))| { + a_id == b_id + && EntityTypeSite { + start: b.start, + ..a.clone() + } == *b + }) + && self.methods.len() == other.methods.len() + && self + .methods + .iter() + .zip(&other.methods) + .all(|(a, b)| a.declares_as(b)) + && self.field_items == other.field_items + && match (&self.storage, &other.storage) { + (Some(a), Some(b)) => a.declares_as(b), + (a, b) => a.is_none() && b.is_none(), + } + } +} + +/// The source files of the field index, by URI. +pub(crate) type FieldSourceMap = BTreeMap>; + +/// The entity keys and revision metadata keys of one entity type, with the +/// defaults Drupal's entity type classes give them. +struct EntityKeys<'a> { + keys: HashMap<&'a str, &'a str>, + revision_metadata: HashMap<&'a str, &'a str>, + /// The entity type's `bundle_entity_type`. + bundle_entity_type: Option<&'a str>, +} + +impl<'a> EntityKeys<'a> { + fn of(site: &'a EntityTypeSite) -> Self { + let mut keys: HashMap<&str, &str> = site + .keys + .iter() + .map(|(key, field)| (key.as_str(), field.as_str())) + .collect(); + // `EntityType::__construct()`. + for (key, field) in [ + ("default_langcode", "default_langcode"), + ( + "revision_translation_affected", + "revision_translation_affected", + ), + ] { + keys.entry(key).or_insert(field); + } + let mut revision_metadata: HashMap<&str, &str> = site + .revision_metadata_keys + .iter() + .map(|(key, field)| (key.as_str(), field.as_str())) + .collect(); + // `ContentEntityType::__construct()`. + if site.content { + revision_metadata + .entry("revision_default") + .or_insert("revision_default"); + } + Self { + keys, + revision_metadata, + bundle_entity_type: site.bundle_entity_type.as_deref(), + } + } + + /// The field the entity type names for `key`, if it has the key: as + /// `hasKey()` reads it, an empty name is no key. + fn key(&self, key: &str) -> Option<&'a str> { + self.keys + .get(key) + .copied() + .filter(|field| !field.is_empty()) + } + + fn revision_metadata_key(&self, key: &str) -> Option<&'a str> { + self.revision_metadata + .get(key) + .copied() + .filter(|field| !field.is_empty()) + } + + fn name_of(&self, name: &'a BaseFieldName) -> Option<&'a str> { + match name { + BaseFieldName::Literal(name) => Some(name), + BaseFieldName::Key(key) => self.key(key), + BaseFieldName::RevisionMetadataKey(key) => self.revision_metadata_key(key), + } + } +} + +/// One statement that creates a field, or one the field manager adds. +struct Creation<'a> { + field_type: &'a str, + target_type: Option<&'a str>, + declaration: Option, +} + +/// A field name with every statement that creates it: two when the method +/// creates it in two branches. +type Created<'a> = Vec<(String, Vec>)>; + +/// Reads base field methods, following the calls between them. +struct BaseFieldReader<'a> { + /// Each base field method by its lowercased declaring class or trait + /// and lowercased name, with the file that declares it. + methods: HashMap<(String, String), (&'a Arc, &'a BaseFieldMethodSite)>, + class_loader: &'a ResolvedClassLoader<'a>, +} + +impl<'a> BaseFieldReader<'a> { + /// The fields `method`, looked up from `start` up, creates for the + /// entity type whose class is `entity_class`. + fn fields( + &self, + entity_class: Atom, + start: Atom, + method: &str, + keys: &EntityKeys<'a>, + path: &mut Vec<(Atom, String)>, + ) -> Created<'a> { + let Some((owner, using, uri, site)) = self.find(start, method) else { + return Vec::new(); + }; + let step = (owner, method.to_ascii_lowercase()); + if path.contains(&step) || path.len() >= MAX_INHERITANCE_DEPTH as usize { + return Vec::new(); + } + path.push(step); + let mut fields: Created<'a> = Vec::new(); + // `$fields + static::helper()` keeps the fields already there. + for include in &site.includes { + // A trait's `self::` and `parent::` are those of the class that + // uses it. + let from = match include.scope { + MethodScope::Static => Some(entity_class), + MethodScope::Self_ => Some(using), + MethodScope::Parent => self.load(using).and_then(|class| class.parent_class), + }; + let Some(from) = from else { + continue; + }; + for (name, creations) in self.fields(entity_class, from, &include.method, keys, path) { + if !fields.iter().any(|(known, _)| *known == name) { + fields.push((name, creations)); + } + } + } + fields.retain(|(name, _)| { + !site + .removed + .iter() + .any(|removed| keys.name_of(removed) == Some(name.as_str())) + }); + // The method's own statements replace what it took from others. + let mut own: Created<'a> = Vec::new(); + for (statement, field) in site.fields.iter().enumerate() { + let Some(name) = keys.name_of(&field.name) else { + continue; + }; + if field + .bundle_condition + .is_some_and(|bundled| bundled != keys.bundle_entity_type.is_some()) + { + continue; + } + let creation = Creation { + field_type: &field.field_type, + target_type: match &field.target { + Some(FieldTarget::EntityType(target)) => Some(target), + Some(FieldTarget::BundleEntityType) => keys.bundle_entity_type, + None => None, + }, + declaration: Some(FieldDeclaration::BaseField { + uri: Arc::clone(uri), + owner: site.owner, + method: site.method, + statement, + }), + }; + match own.iter_mut().find(|(known, _)| known == name) { + Some((_, creations)) => creations.push(creation), + None => own.push((name.to_string(), vec![creation])), + } + } + for (name, creations) in own { + set_field(&mut fields, name, creations); + } + path.pop(); + fields + } + + /// The class or trait that declares the base field method `method` for + /// `start`, and the class that has it. + fn find( + &self, + start: Atom, + method: &str, + ) -> Option<(Atom, Atom, &'a Arc, &'a BaseFieldMethodSite)> { + let method = method.to_ascii_lowercase(); + find_static_method(start, self.class_loader, |class| { + self.methods + .get(&(class.to_ascii_lowercase(), method.clone())) + .copied() + }) + .map(|(owner, using, (uri, site))| (owner, using, uri, site)) + } + + fn load(&self, class: Atom) -> Option> { + (self.class_loader)(Fqn::new(&class)) + } +} + +/// The class or trait that declares a static method for `start`, as PHP +/// finds it: the class's own, then its traits', then its parent's, with +/// the class among them that has the method, which a trait's `self::` and +/// `parent::` are bound to. `declared` gives what a class or trait +/// declares itself. +pub(super) fn find_static_method( + start: Atom, + class_loader: &ResolvedClassLoader<'_>, + declared: impl Fn(Atom) -> Option, +) -> Option<(Atom, Atom, T)> { + let declared_in = |class: Atom| -> Option<(Atom, T)> { + let mut pending = vec![(class, 0)]; + while let Some((class, depth)) = pending.pop() { + if let Some(found) = declared(class) { + return Some((class, found)); + } + if depth < MAX_INHERITANCE_DEPTH + && let Some(info) = class_loader(Fqn::new(&class)) + { + pending.extend(info.used_traits.iter().rev().map(|used| (*used, depth + 1))); + } + } + None + }; + let mut current = Some(start); + let mut depth = 0; + while let Some(class) = current + && depth < MAX_INHERITANCE_DEPTH + { + depth += 1; + if let Some((owner, found)) = declared_in(class) { + return Some((owner, class, found)); + } + current = class_loader(Fqn::new(&class)).and_then(|info| info.parent_class); + } + None +} + +/// Make `creations` the field `name` of `fields`, in place of any there. +fn set_field<'a>(fields: &mut Created<'a>, name: String, creations: Vec>) { + match fields.iter_mut().find(|(known, _)| *known == name) { + Some((_, known)) => *known = creations, + None => fields.push((name, creations)), + } +} + +impl Backend { + /// Keep the field index's sources in step with the symbol map of the + /// file at `uri` (`None` once it is gone). A change to what the fields + /// are drops the index and evicts the classes that have them; one to + /// where they are written, as any edit above them makes, keeps both. + pub(crate) fn note_drupal_field_sources(&self, uri: &str, map: Option<&SymbolMap>) { + let sources = map.filter(|_| self.is_drupal_project()).and_then(|map| { + FieldSources::of( + uri, + map, + || { + // A test module's entity types are only seen by test + // code, while a class's fields are the same whoever + // asks, and a dependency's are never part of the + // project's own. + !crate::util::uri_to_path(uri) + .is_some_and(|path| self.is_drupal_test_path(&path)) + && !self + .workspace + .vendor_uri_prefixes + .lock() + .iter() + .any(|prefix| uri.starts_with(prefix.as_str())) + }, + || self.is_drupal_field_storage_source(uri), + ) + }); + let old = self.drupal.field_sources.read().get(uri).cloned(); + if old.as_deref() == sources.as_ref() { + return; + } + let same_fields = match (old.as_deref(), sources.as_ref()) { + (Some(old), Some(new)) => old.declares_as(new), + _ => false, + }; + // The item classes whose declarations changed. + let mut item_classes: Vec = Vec::new(); + let old_items = old.as_deref().map_or(&[][..], |old| &old.field_items); + let new_items = sources.as_ref().map_or(&[][..], |new| &new.field_items); + if old_items != new_items { + for site in old_items.iter().chain(new_items) { + if !item_classes.contains(&site.owner) { + item_classes.push(site.owner); + } + } + } + { + let mut stored = self.drupal.field_sources.write(); + match sources { + Some(sources) => { + stored.insert(uri.to_string(), Arc::new(sources)); + } + None => { + stored.remove(uri); + } + } + } + if !same_fields { + self.invalidate_drupal_fields(); + } + // They go with the index, or one resolved before it does would keep + // the properties it read from the old one. + if !item_classes.is_empty() { + let mut pending = self.drupal.changed_item_classes.lock(); + for class in item_classes { + if !pending.contains(&class) { + pending.push(class); + } + } + } + } + + /// Evict `classes` and every class that extends or implements one, with + /// the generic specialisations that name them: a class resolved from + /// its parent's members records no dependency on an ancestor further + /// up, so an ancestor's eviction does not reach a class whose parent + /// is not cached. The names of every class the eviction reached, as + /// dependencies are recorded. + fn evict_with_descendants(&self, classes: Vec) -> Vec { + let descendants = self.descendants_closure(classes.iter().cloned()); + let mut changed: Vec = Vec::with_capacity(descendants.len()); + let mut cache = self.resolved_class_cache.write(); + // Most descendants are neither cached nor named by a cached class. + for fqn in &descendants { + changed.push(crate::resolution_deps::dep_key(fqn)); + if classes.contains(fqn) || cache.holds_or_depends_on(fqn) { + for evicted in crate::virtual_members::evict_fqn(&mut cache, fqn) { + changed.push(crate::resolution_deps::dep_key(&evicted)); + } + } + } + changed + } + + /// Note that what the fields are read from changed. They go when the + /// publish that brought the change ends (see + /// [`drupal_field_flush`](Self::drupal_field_flush)), so the many + /// changes one batch of indexing brings drop them once. + pub(crate) fn invalidate_drupal_fields(&self) { + self.drupal + .fields_changed + .store(true, std::sync::atomic::Ordering::Release); + } + + /// A guard that drops the fields as it goes when a change was noted + /// meanwhile. Taken before the locks a publish holds, so it runs after + /// they are released. + pub(crate) fn drupal_field_flush(&self) -> DrupalFieldFlush<'_> { + DrupalFieldFlush(self) + } + + /// Drop the field index and the resolved classes that have fields: the + /// fields of an entity class show on interfaces that do not depend on + /// it, so every class under `FieldableEntityInterface` goes. + fn flush_drupal_field_changes(&self) { + if !self + .drupal + .fields_changed + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + return; + } + // A class takes fields only from an index handed out to it, so + // while none was, as through most of indexing, there is nothing to + // walk for. + let mut changed = if self.drupal.fields.invalidate() { + self.evict_with_descendants(vec![FIELDABLE_ENTITY.to_string()]) + } else { + let mut cache = self.resolved_class_cache.write(); + crate::virtual_members::evict_fqn(&mut cache, FIELDABLE_ENTITY) + .iter() + .map(|fqn| crate::resolution_deps::dep_key(fqn)) + .chain(std::iter::once(crate::resolution_deps::dep_key( + FIELDABLE_ENTITY, + ))) + .collect() + }; + // The item classes whose properties changed, and the classes that + // extend them. + let item_classes: Vec = + std::mem::take(&mut *self.drupal.changed_item_classes.lock()) + .iter() + .map(Atom::to_string) + .collect(); + if !item_classes.is_empty() { + changed.extend(self.evict_with_descendants(item_classes)); + } + // A cached receiver resolution depends on the classes that have the + // fields, while member completion records nothing to tell. + changed.sort_unstable(); + changed.dedup(); + self.retain_resolved_member_files(&changed); + self.member_completion_cache.lock().clear(); + } + + /// The fields of the project's content entity types, read on first use. + /// `None` outside a Drupal project, and on the thread reading them. + pub(crate) fn drupal_field_index(&self) -> Option> { + if !self.is_drupal_project() { + return None; + } + self.drupal + .fields + .get_or_read(|| self.read_drupal_field_index()) + } + + fn read_drupal_field_index(&self) -> FieldIndex { + // Copied out, since loading a class may publish a file. + let sources: Vec> = + self.drupal.field_sources.read().values().cloned().collect(); + let resolved = self.resolved_class_loader_with(&[]); + // Every class the read looks at, so that a change to what one of + // them extends, implements, or uses reads the fields again. + let loader = |name: Fqn<'_>| { + self.drupal.fields.note_read_class(name.as_str()); + resolved(name) + }; + let mut methods = HashMap::new(); + let mut declared: Vec<(&str, &EntityTypeSite)> = Vec::new(); + let mut storages: Vec<(&Arc, &FieldStorageSite)> = Vec::new(); + for source in &sources { + if let Some(storage) = &source.storage { + storages.push((&source.uri, storage)); + } + for site in &source.methods { + methods + .entry(( + site.owner.to_ascii_lowercase(), + site.method.to_ascii_lowercase(), + )) + .or_insert((&source.uri, site)); + } + declared.extend( + source + .entity_types + .iter() + .map(|(id, site)| (id.as_str(), site)), + ); + } + let reader = BaseFieldReader { + methods, + class_loader: &loader, + }; + let mut field_types: HashMap<&str, Vec<(Atom, Option)>> = HashMap::new(); + let mut index = FieldIndex::default(); + for source in &sources { + for site in &source.field_items { + index + .field_items + .entry(site.owner.to_ascii_lowercase()) + .or_insert_with(|| site.clone()); + } + } + for &(id, site) in declared.iter().filter(|(_, site)| site.content) { + // Which receivers hold the entity type depends on all of it. + load_class_graph(site.class, &loader); + let keys = EntityKeys::of(site); + let mut created = reader.fields( + site.class, + site.class, + "baseFieldDefinitions", + &keys, + &mut Vec::new(), + ); + add_manager_fields(&mut created, site, &keys); + // A field declared by several files is one field. + for &(uri, storage) in storages + .iter() + .filter(|(_, storage)| storage.entity_type == id) + { + let creation = Creation { + field_type: &storage.field_type, + target_type: storage.target_type.as_deref(), + declaration: Some(FieldDeclaration::Storage { + uri: Arc::clone(uri), + }), + }; + match created + .iter_mut() + .find(|(known, _)| *known == storage.field_name) + { + Some((_, creations)) => creations.push(creation), + None => created.push((storage.field_name.clone(), vec![creation])), + } + } + let fields = created + .into_iter() + .map(|(name, creations)| { + let ty = PhpType::union( + creations + .iter() + .map(|creation| { + let classes = + field_types.entry(creation.field_type).or_insert_with(|| { + self.drupal_field_type_classes(creation.field_type) + }); + field_type(classes, creation.target_type, &declared, |item| { + default_target(&index.field_items, item, &loader) + }) + }) + .collect(), + ); + let mut field_types = Vec::new(); + for creation in &creations { + let field_type = crate::atom::atom(creation.field_type); + if !field_types.contains(&field_type) { + field_types.push(field_type); + } + } + let mut declarations = Vec::new(); + for creation in creations { + if let Some(declaration) = creation.declaration + && !declarations.contains(&declaration) + { + declarations.push(declaration); + } + } + Field { + name, + ty, + field_types, + declarations, + } + }) + .collect(); + index.entity_types.push(EntityTypeFields { + id: id.to_string(), + class: site.class, + fields, + }); + } + index.classes = self.drupal.fields.read_classes(); + index + } + + /// Note that the classes named `changed` were declared, dropped, or + /// given another parent, other interfaces, or other traits: the fields + /// go when the index was read through one of them, or is being read. + pub(crate) fn note_drupal_class_graph_changes(&self, changed: &[&str]) { + if changed.is_empty() || !self.is_drupal_project() { + return; + } + let stale = match self.drupal.fields.published() { + Some(index) => changed.iter().any(|class| { + index + .classes + .contains(&class.trim_start_matches('\\').to_ascii_lowercase()) + }), + // A read in progress restarts only for a class it has looked at, + // or every publish of indexing would restart it. + None => self.drupal.fields.read_looked_at(changed), + }; + if stale { + self.invalidate_drupal_fields(); + } + } + + /// The IDs of the entity types a receiver of type `ty` may hold, each + /// once: the ones a field name asked of it belongs to. + pub(crate) fn drupal_receiver_entity_types( + &self, + ty: &PhpType, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec { + let Some(index) = self.drupal_field_index() else { + return Vec::new(); + }; + let mut ids: Vec = Vec::new(); + for class in ty.top_level_class_names() { + let Some(class) = class_loader(Fqn::new(&class)) else { + continue; + }; + let held = super::fields::receiver_entity_types( + &class, + index + .entity_types + .iter() + .map(|entity_type| (entity_type.id.as_str(), entity_type.class)), + class_loader, + ); + for id in held { + if !ids.iter().any(|known| known == id) { + ids.push(id.to_string()); + } + } + } + ids + } + + /// The names of the fields a receiver of type `ty` has. + pub(crate) fn drupal_receiver_field_names( + &self, + ty: &PhpType, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec { + let Some(index) = self.drupal_field_index() else { + return Vec::new(); + }; + let mut names: Vec = Vec::new(); + for class in ty.top_level_class_names() { + let Some(class) = class_loader(Fqn::new(&class)) else { + continue; + }; + for field in index.fields_of(&class, class_loader) { + if !names.contains(&field.name) { + names.push(field.name); + } + } + } + names + } + + /// The field a field name's span `key` names, as the field index has it + /// now, with the entity type's ID. + pub(crate) fn drupal_field_named(&self, key: &str) -> Option { + let name = super::fields::split_field_key(key).1; + let index = self.drupal_field_index()?; + let mut shared: Option = None; + // The field every entity type of the key has, as the property is. + for entity_type in super::fields::field_key_entity_types(key) { + let mut found: Option = None; + // An ID declared twice has the fields of either declaration. + for declared in index.entity_types.iter().filter(|e| e.id == entity_type) { + let Some(field) = declared.fields.iter().find(|field| field.name == name) else { + continue; + }; + match &mut found { + Some(known) => super::fields::merge_field(known, field), + None => found = Some(field.clone()), + } + } + let found = found?; + match &mut shared { + Some(known) => super::fields::merge_field(known, &found), + None => shared = Some(found), + } + } + shared + } + + /// Whether the field name spans `a` and `b` name the same field: the + /// same span, or a field both name, of an entity type both may hold. + pub(crate) fn drupal_field_keys_match(&self, a: &str, b: &str) -> bool { + if a == b { + return true; + } + if super::fields::split_field_key(a).1 != super::fields::split_field_key(b).1 { + return false; + } + super::fields::field_key_entity_types(a) + .any(|entity_type| super::fields::field_key_entity_types(b).any(|e| e == entity_type)) + && self.drupal_field_named(a).is_some() + && self.drupal_field_named(b).is_some() + } + + /// Where the field a field name's span `key` names is declared. + pub(crate) fn drupal_field_name_locations(&self, key: &str) -> Vec { + self.drupal_field_named(key) + .map(|field| self.field_locations(&field)) + .unwrap_or_default() + } + + /// The hover text of the field a field name's span `key` names: its + /// field type, its type, its entity type, and the files that declare + /// it. + pub(crate) fn drupal_field_name_detail(&self, key: &str) -> String { + let entity_types: Vec = super::fields::field_key_entity_types(key) + .map(|entity_type| format!("`{entity_type}`")) + .collect(); + let Some(field) = self.drupal_field_named(key) else { + return if entity_types.len() == 1 { + format!("Not a known field of {}", entity_types[0]) + } else { + format!("Not a field all of {} have", entity_types.join(", ")) + }; + }; + let mut parts = Vec::new(); + if !field.field_types.is_empty() { + let field_types: Vec = field + .field_types + .iter() + .map(|field_type| format!("`{field_type}`")) + .collect(); + parts.push(format!("Field type {}", field_types.join(", "))); + } + parts.push(format!("Type `{}`", field.ty.shorten())); + parts.push(if entity_types.len() == 1 { + format!("Entity type {}", entity_types[0]) + } else { + format!("Entity types {}", entity_types.join(", ")) + }); + let mut files: Vec = Vec::new(); + for location in self.field_locations(&field) { + let path = self + .workspace_relative_path(location.uri.as_str()) + .unwrap_or_else(|| location.uri.path().to_string()); + if !files.contains(&path) { + files.push(path); + } + } + if !files.is_empty() { + let files: Vec = files.iter().map(|file| format!("`{file}`")).collect(); + parts.push(format!("Declared in {}", files.join(", "))); + } + parts.join("\n\n") + } + + /// Where the field `name` of `receiver` is declared, every declaration + /// of the entity types it may hold, when the property `name` of + /// `receiver` is that field. + pub(crate) fn drupal_field_locations( + &self, + receiver: &ClassInfo, + name: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec { + if !self.is_drupal_project() { + return Vec::new(); + } + let resolved = crate::virtual_members::resolve_class_fully_cached( + receiver, + class_loader, + &self.resolved_class_cache, + ); + if !resolved.properties.iter().any(|property| { + property.name == name + && property.source == Some(crate::types::PropertySource::DrupalField) + }) { + return Vec::new(); + } + let Some(index) = self.drupal_field_index() else { + return Vec::new(); + }; + let Some(field) = index + .fields_of(receiver, class_loader) + .into_iter() + .find(|field| field.name == name) + else { + return Vec::new(); + }; + self.field_locations(&field) + } + + /// Whether the property access `span` of the file at `uri` reads a + /// field of an entity or a property of a field item, which the + /// entity's `get()` calls, field storage files, and property + /// definitions also name. + pub(crate) fn is_drupal_field_access( + &self, + uri: &str, + content: &str, + span: &crate::symbol_map::SymbolSpan, + ) -> bool { + let crate::symbol_map::SymbolKind::MemberAccess { + subject_text, + member_name, + is_static: false, + is_method_call: false, + .. + } = &span.kind + else { + return false; + }; + if !self.is_drupal_project() { + return false; + } + let Some(source) = self.symbol_map_source(uri, content) else { + return false; + }; + let subject = subject_text.as_str(source); + let ctx = self.file_context_at(uri, span.start); + let loaders = self.file_loaders(&ctx); + let class_loader = loaders.resolved_class_loader(); + crate::type_engine::resolver::resolve_target_classes( + subject, + crate::AccessKind::Arrow, + &loaders.resolution_ctx_at(content, span.start), + ) + .into_iter() + .filter_map(|resolved| resolved.class_info) + .any(|class| { + let is_field = |class: &ClassInfo| { + class.properties.iter().any(|property| { + property.name == member_name.as_str() && property.writes_through_magic_set() + }) + }; + // A field's item list comes resolved for its type arguments. + is_field(&class) + || is_field(&crate::virtual_members::resolve_class_fully_cached( + &class, + class_loader, + &self.resolved_class_cache, + )) + }) + } + + /// Where each declaration of `field` is written now. + fn field_locations(&self, field: &Field) -> Vec { + let mut locations: Vec = Vec::new(); + for declaration in &field.declarations { + if let Some(location) = self.field_declaration_location(declaration) + && !locations.contains(&location) + { + locations.push(location); + } + } + locations + } + + /// Where `declaration` is written now. + fn field_declaration_location(&self, declaration: &FieldDeclaration) -> Option { + let (uri, start, end) = { + let sources = self.drupal.field_sources.read(); + match declaration { + FieldDeclaration::BaseField { + uri, + owner, + method, + statement, + } => { + let statement = sources + .get(&**uri)? + .methods + .iter() + .find(|site| site.owner == *owner && site.method == *method)? + .fields + .get(*statement)?; + (Arc::clone(uri), statement.start, statement.end) + } + FieldDeclaration::Storage { uri } => { + let storage = sources.get(&**uri)?.storage.as_ref()?; + (Arc::clone(uri), storage.start, storage.end) + } + } + }; + let content = self.get_file_content_arc(&uri)?; + let lines = crate::text_position::LineIndex::new(&content); + Some(Location { + uri: crate::util::parse_url(&uri)?, + range: Range { + start: lines.position(start as usize), + end: lines.position(end as usize), + }, + }) + } +} + +/// Load `class` and everything it extends, implements, and uses. +fn load_class_graph(class: Atom, class_loader: &ResolvedClassLoader<'_>) { + let mut pending = vec![class]; + let mut seen: Vec = Vec::new(); + while let Some(name) = pending.pop() { + if seen.contains(&name) { + continue; + } + seen.push(name); + if let Some(info) = class_loader(Fqn::new(&name)) { + pending.extend(info.parent_class); + pending.extend(info.interfaces.iter().copied()); + pending.extend(info.used_traits.iter().copied()); + } + } +} + +/// See [`Backend::drupal_field_flush`]. +pub(crate) struct DrupalFieldFlush<'a>(&'a Backend); + +impl Drop for DrupalFieldFlush<'_> { + fn drop(&mut self) { + self.0.flush_drupal_field_changes(); + } +} + +/// Add the boolean fields `EntityFieldManager::buildBaseFieldDefinitions()` +/// adds to those of a translatable or revisionable entity type, under the +/// same conditions. +fn add_manager_fields<'a>(created: &mut Created<'a>, site: &EntityTypeSite, keys: &EntityKeys<'_>) { + let boolean = || { + vec![Creation { + field_type: BOOLEAN, + target_type: None, + declaration: None, + }] + }; + let revisionable = keys.key("revision").is_some(); + if site.translatable + && let Some(name) = keys.key("default_langcode") + && !created.iter().any(|(known, _)| known == name) + { + created.push((name.to_string(), boolean())); + } + if revisionable && let Some(name) = keys.revision_metadata_key("revision_default") { + set_field(created, name.to_string(), boolean()); + } + if revisionable + && site.translatable + && let Some(name) = keys.key("revision_translation_affected") + { + set_field(created, name.to_string(), boolean()); + } +} + +/// The item list a field of a field type with the item and list `classes` +/// holds, tagged with the class of the entity type `target_type` names, or +/// when it names none, the one the item class's +/// `defaultStorageSettings()` gives (`default_target`). +fn field_type( + classes: &[(Atom, Option)], + target_type: Option<&str>, + entity_types: &[(&str, &EntityTypeSite)], + default_target: impl Fn(Atom) -> Option, +) -> PhpType { + if classes.is_empty() { + return PhpType::named(crate::atom::atom(FIELD_ITEM_LIST_INTERFACE)); + } + let targets = |target_type: &str| { + let mut targets: Vec = Vec::new(); + for (id, site) in entity_types { + let target = PhpType::named(site.class); + if *id == target_type && !targets.contains(&target) { + targets.push(target); + } + } + targets + }; + PhpType::union( + classes + .iter() + .map(|(item, list)| { + let mut args = vec![PhpType::named(*item)]; + let targets = match target_type { + Some(target_type) => targets(target_type), + None => default_target(*item) + .map(|target_type| targets(&target_type)) + .unwrap_or_default(), + }; + if !targets.is_empty() { + args.push(PhpType::union(targets)); + } + PhpType::generic_atom( + list.unwrap_or_else(|| crate::atom::atom(FIELD_ITEM_LIST)), + args, + ) + }) + .collect(), + ) +} + +/// The entity type the `defaultStorageSettings()` PHP calls for the field +/// item class `item` gives as its `target_type`, following +/// `parent::defaultStorageSettings()`: the furthest ancestor's setting wins, +/// as `+` keeps its left operand's keys. +fn default_target( + field_items: &HashMap, + item: Atom, + class_loader: &ResolvedClassLoader<'_>, +) -> Option { + let mut start = Some(item); + let mut depth = 0; + // What the furthest class that adds its parent's settings writes after + // them, for when no ancestor writes one. + let mut otherwise: Option> = None; + while let Some(from) = start + && depth < MAX_INHERITANCE_DEPTH + { + depth += 1; + let Some((_, using, declared)) = find_static_method(from, class_loader, |class| { + field_items + .get(&class.to_ascii_lowercase()) + .and_then(|site| site.default_target.as_ref()) + }) else { + break; + }; + match declared { + DefaultTarget::Declared(target) => return target.clone(), + DefaultTarget::FromParent { otherwise: then } => { + if then.is_some() { + otherwise.clone_from(then); + } + start = class_loader(Fqn::new(&using)).and_then(|class| class.parent_class); + } + } + } + otherwise.flatten() +} + +#[cfg(test)] +#[path = "base_fields_tests.rs"] +mod tests; diff --git a/src/drupal/base_fields_tests.rs b/src/drupal/base_fields_tests.rs new file mode 100644 index 000000000..cff40de74 --- /dev/null +++ b/src/drupal/base_fields_tests.rs @@ -0,0 +1,285 @@ +use super::FieldSources; + +const NOTE: &str = r#" 'id'])] +class Note extends ContentEntityBase { + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields = parent::baseFieldDefinitions($entity_type); + $fields['body'] = BaseFieldDefinition::create('string'); + return $fields; + } +} +"#; + +/// What a custom module's file holding `content` gives the field index. +fn sources(content: &str) -> FieldSources { + let dir = tempfile::tempdir().unwrap(); + let backend = crate::Backend::new_test_with_workspace(dir.path().to_path_buf(), vec![]); + backend.mark_drupal_project(); + let uri = crate::util::path_to_uri(&dir.path().join("web/modules/custom/acme/src/Note.php")); + backend.update_ast(&uri, content); + let map = backend.symbol_map_for(&uri).expect("the file's map"); + // Compared across backends, so the URI must not differ. + FieldSources { + uri: std::sync::Arc::from("file:///note.php"), + ..FieldSources::of(&uri, &map, || true, || true).expect("the file declares fields") + } +} + +/// Text moved around a declaration changes where its fields are written, +/// not what they are, so the classes that have them stay resolved. +#[test] +fn moved_declarations_declare_the_same_fields() { + let before = sources(NOTE); + let moved = sources(&NOTE.replace("class Note", "\n\nclass Note")); + assert_ne!(before, moved); + assert!(before.declares_as(&moved)); +} + +#[test] +fn a_renamed_field_or_a_changed_key_declares_other_fields() { + let before = sources(NOTE); + for changed in [ + NOTE.replace("$fields['body']", "$fields['summary']"), + NOTE.replace("create('string')", "create('integer')"), + NOTE.replace("'id' => 'id'", "'id' => 'nid'"), + NOTE.replace( + "$fields['body']", + "unset($fields['id']);\n $fields['body']", + ), + ] { + assert!(!before.declares_as(&sources(&changed)), "{changed}"); + } +} + +/// What a field storage file holding `content` gives the field index. +fn storage_sources(content: &str) -> FieldSources { + let map = crate::symbol_map::SymbolMap { + drupal_fields: crate::symbol_map::DrupalFieldSites { + storage: crate::drupal::field_storage::read_field_storage(content), + ..Default::default() + } + .boxed(), + ..Default::default() + }; + FieldSources::of( + "file:///field.storage.node.field_tags.yml", + &map, + || true, + || true, + ) + .expect("the file declares a field") +} + +const STORAGE: &str = "id: node.field_tags\nfield_name: field_tags\nentity_type: node\ntype: entity_reference\nsettings:\n target_type: taxonomy_term\n"; + +/// Editing a field storage file anywhere but the field it declares, as +/// an exported file's `uuid` or `dependencies` change, keeps the classes +/// that have the field resolved. +#[test] +fn a_storage_edited_around_its_field_declares_the_same_field() { + let before = storage_sources(STORAGE); + let moved = storage_sources(&format!("uuid: 0d4c5b0e\nlangcode: en\n{STORAGE}")); + assert_ne!(before, moved); + assert!(before.declares_as(&moved)); + for changed in [ + STORAGE.replace("type: entity_reference", "type: string"), + STORAGE.replace("taxonomy_term", "user"), + STORAGE.replace("entity_type: node", "entity_type: user"), + STORAGE.replace("field_name: field_tags", "field_name: field_topics"), + ] { + assert!(!before.declares_as(&storage_sources(&changed)), "{changed}"); + } +} + +/// The field storage files one publish brings, as indexing publishes a +/// folder of them, drop the fields once. +#[test] +fn a_batch_of_field_changes_drops_the_fields_once() { + let dir = tempfile::tempdir().unwrap(); + let backend = crate::Backend::new_test_with_workspace(dir.path().to_path_buf(), vec![]); + backend.mark_drupal_project(); + let maps: Vec<(String, std::sync::Arc)> = ["tags", "topics"] + .iter() + .map(|name| { + let content = STORAGE.replace("field_tags", &format!("field_{name}")); + let path = dir + .path() + .join(format!("config/sync/field.storage.node.field_{name}.yml")); + let map = crate::symbol_map::SymbolMap { + drupal_fields: crate::symbol_map::DrupalFieldSites { + storage: crate::drupal::field_storage::read_field_storage(&content), + ..Default::default() + } + .boxed(), + ..Default::default() + }; + (crate::util::path_to_uri(&path), std::sync::Arc::new(map)) + }) + .collect(); + let before = backend.drupal.fields.generation(); + backend.publish_symbol_maps(maps); + assert_eq!(backend.drupal.fields.generation(), before + 1); +} + +/// A storage that does not count gives nothing. +#[test] +fn a_storage_that_does_not_count_gives_nothing() { + let map = crate::symbol_map::SymbolMap { + drupal_fields: crate::symbol_map::DrupalFieldSites { + storage: crate::drupal::field_storage::read_field_storage(STORAGE), + ..Default::default() + } + .boxed(), + ..Default::default() + }; + assert!(FieldSources::of("file:///f.yml", &map, || true, || false).is_none()); +} + +/// A target written after the parent's settings is the one the parent's +/// leave in place: the furthest ancestor's wins, and none is no target. +#[test] +fn a_target_after_the_parent_s_settings_is_used_when_the_parent_has_none() { + use crate::symbol_map::{DefaultTarget, FieldItemSite}; + let class = |name: &str, parent: Option<&str>| { + std::sync::Arc::new(crate::types::ClassInfo { + name: crate::atom::atom(name), + parent_class: parent.map(crate::atom::atom), + ..Default::default() + }) + }; + let classes = [ + class("PlainItem", None), + class("FileItem", Some("PlainItem")), + class("MediaItem", Some("FileItem")), + class("OtherItem", Some("PlainItem")), + ]; + let loader = |name: crate::class_lookup::Fqn<'_>| { + classes + .iter() + .find(|class| class.name == name.as_str()) + .cloned() + }; + let site = |owner: &str, target: Option<&str>| { + ( + owner.to_ascii_lowercase(), + FieldItemSite { + owner: crate::atom::atom(owner), + property_definitions: None, + default_target: Some(DefaultTarget::FromParent { + otherwise: target.map(|target| Some(target.to_string())), + }), + }, + ) + }; + let items: std::collections::HashMap = [ + site("FileItem", Some("file")), + site("MediaItem", Some("media")), + site("OtherItem", None), + ] + .into_iter() + .collect(); + let target = |item: &str| super::default_target(&items, crate::atom::atom(item), &loader); + assert_eq!(target("MediaItem").as_deref(), Some("file")); + assert_eq!(target("FileItem").as_deref(), Some("file")); + assert_eq!(target("OtherItem"), None); +} + +const FIELD_ITEM_INTERFACE: &str = " Vec { + let loader = backend.resolved_class_loader_with(&[]); + let class = loader(crate::class_lookup::Fqn::new( + "Drupal\\acme\\Plugin\\Field\\FieldType\\ExcerptItem", + )) + .expect("the item class"); + crate::virtual_members::resolve_class_fully_cached( + &class, + &loader, + &backend.resolved_class_cache, + ) + .properties + .iter() + .map(|property| property.name.to_string()) + .collect() + }; + assert!(properties().contains(&"value".to_string())); + + // What another backend reads from the edited file, noted without the + // publish that would drop the fields at once. + let edited = EXCERPT_ITEM.replace("$properties['value']", "$properties['summary']"); + let other = crate::Backend::new_test(); + other.mark_drupal_project(); + other.update_ast(&uri, &edited); + let map = other.symbol_map_for(&uri).expect("the file's map"); + backend.note_drupal_field_sources(&uri, Some(&map)); + // A request resolves the item class before the publish ends. + properties(); + backend.flush_drupal_field_changes(); + + let after = properties(); + assert!(after.contains(&"summary".to_string()), "{after:?}"); +} + +/// While the fields are being read, as during indexing, a publish that +/// declares classes the read has not looked at leaves the read alone, and +/// one that changes a class it has looked at reads them again. +#[test] +fn a_read_in_progress_restarts_only_for_the_classes_it_looked_at() { + let backend = crate::Backend::new_test(); + backend.mark_drupal_project(); + for (changed, expected_reads) in [ + ("Drupal\\acme\\Plugin\\Unrelated", 1), + ("Drupal\\acme\\Entity\\Note", 2), + ] { + let reads = std::cell::Cell::new(0); + backend.drupal.fields.get_or_read(|| { + reads.set(reads.get() + 1); + backend + .drupal + .fields + .note_read_class("Drupal\\acme\\Entity\\Note"); + if reads.get() == 1 { + backend.note_drupal_class_graph_changes(&[changed]); + backend.flush_drupal_field_changes(); + } + crate::drupal::fields::FieldIndex::default() + }); + assert_eq!(reads.get(), expected_reads, "{changed}"); + backend.drupal.fields.invalidate(); + } +} diff --git a/src/drupal/config.rs b/src/drupal/config.rs index db7924556..127dc5fc1 100644 --- a/src/drupal/config.rs +++ b/src/drupal/config.rs @@ -36,7 +36,7 @@ pub(crate) fn is_default_config_file(path: &Path, web_root: &Path) -> bool { /// Whether `path` is a `*.yml` right inside a `config/install` or /// `config/optional` folder. -fn has_default_config_shape(path: &Path) -> bool { +pub(super) fn has_default_config_shape(path: &Path) -> bool { path.extension().is_some_and(|extension| extension == "yml") && path .parent() diff --git a/src/drupal/field_items.rs b/src/drupal/field_items.rs new file mode 100644 index 000000000..f35ac13d8 --- /dev/null +++ b/src/drupal/field_items.rs @@ -0,0 +1,313 @@ +//! The properties of Drupal's field items, and of the item lists that hold +//! them. +//! +//! A field item class declares its properties in `propertyDefinitions()`, +//! taking its parent's with `parent::propertyDefinitions()`, and reads and +//! writes them through the magic `__get()` and `__set()` of +//! `FieldItemBase`. [`FieldItemProvider`] gives the item class those +//! properties. An item list reads a property on its first item +//! (`FieldItemList::__get()`), so a field typed +//! `FieldItemList` has `StringItem`'s properties, and a +//! reference field's target class, its list's second type argument, types +//! the `entity` property and `referencedEntities()` +//! ([`apply_to_field_type`]). Both read the type arguments, so they apply +//! where a type hint names the list or item, not to the bare class. + +use std::sync::Arc; + +use crate::atom::{Atom, atom}; +use crate::class_lookup::{Fqn, ResolvedClassLoader, is_subtype_of}; +use crate::php_type::{PhpType, TypeKind}; +use crate::types::{ + ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, PropertyInfo, PropertySource, Visibility, +}; +use crate::virtual_members::{ResolvedClassCache, VirtualMemberProvider, VirtualMembers}; + +use super::base_fields::find_static_method; +use super::fields::{FieldIndex, field_index}; + +/// The interface every field item implements. +const FIELD_ITEM: &str = "Drupal\\Core\\Field\\FieldItemInterface"; +/// The interface every field item list implements. +const FIELD_ITEM_LIST: &str = "Drupal\\Core\\Field\\FieldItemListInterface"; +const ENTITY: &str = "Drupal\\Core\\Entity\\EntityInterface"; +const LANGUAGE: &str = "Drupal\\Core\\Language\\LanguageInterface"; + +/// The properties a field item class's `propertyDefinitions()` declares. +pub(crate) struct FieldItemProvider; + +impl VirtualMemberProvider for FieldItemProvider { + fn applies_to(&self, class: &ClassInfo, class_loader: &ResolvedClassLoader<'_>) -> bool { + class.kind == ClassLikeKind::Class + && super::fields::extends_or_implements(class) + && is_subtype_of(class, FIELD_ITEM, class_loader) + } + + fn provide( + &self, + class: &ClassInfo, + class_loader: &ResolvedClassLoader<'_>, + cache: Option<&ResolvedClassCache>, + ) -> VirtualMembers { + let properties = cache + .and_then(field_index) + .map(|index| { + item_properties(&index, class.fqn(), class_loader) + .into_iter() + .map(|(name, data_type)| item_property(&name, data_type.as_deref())) + .collect() + }) + .unwrap_or_default(); + VirtualMembers { + methods: Vec::new(), + properties, + constants: Vec::new(), + } + } +} + +/// The properties of the field item class `class`, each with the data type +/// its definition creates: those of the `propertyDefinitions()` PHP calls +/// for it, with those it takes from its parent's. +fn item_properties( + index: &FieldIndex, + class: Atom, + class_loader: &ResolvedClassLoader<'_>, +) -> Vec<(String, Option)> { + let mut start = Some(class); + // The declaring classes, nearest first. + let mut chain = Vec::new(); + while let Some(from) = start + && chain.len() < MAX_INHERITANCE_DEPTH as usize + { + let Some((_, using, site)) = find_static_method(from, class_loader, |class| { + index + .field_items + .get(&class.to_ascii_lowercase()) + .and_then(|site| site.property_definitions.as_ref()) + }) else { + break; + }; + chain.push(site); + start = site + .takes_parent + .then(|| class_loader(Fqn::new(&using)).and_then(|class| class.parent_class)) + .flatten(); + } + let mut properties: Vec<(String, Option)> = Vec::new(); + for site in chain.into_iter().rev() { + properties.retain(|(name, _)| !site.removed.contains(name)); + for property in &site.properties { + match properties + .iter_mut() + .find(|(name, _)| *name == property.name) + { + Some(known) => known.1.clone_from(&property.data_type), + None => properties.push((property.name.clone(), property.data_type.clone())), + } + } + } + properties +} + +/// The property `name` of a field item, whose definition creates +/// `data_type`: a referenced entity or language, else the stored value, +/// which the database gives as it is. +fn item_property(name: &str, data_type: Option<&str>) -> PropertyInfo { + let ty = match data_type { + Some("entity") => entity_type(None), + Some("language") => PhpType::nullable(PhpType::named(atom(LANGUAGE))), + _ => PhpType::mixed(), + }; + PropertyInfo { + source: Some(PropertySource::DrupalItemProperty), + ..PropertyInfo::virtual_property_owned(atom(name), Some(ty)) + } +} + +/// What the `entity` property of a reference field's item is: its target +/// class when known, else any entity, or `null` when the field is empty. +fn entity_type(target: Option<&PhpType>) -> PhpType { + match target { + Some(target) => match target.kind() { + TypeKind::Union(members) => { + let mut members = members.to_vec(); + members.push(PhpType::null()); + PhpType::union(members) + } + _ => PhpType::nullable(target.clone()), + }, + None => PhpType::nullable(PhpType::named(atom(ENTITY))), + } +} + +/// A type hint's arguments on a field item list or a field item. +#[derive(Debug)] +pub(crate) enum FieldTypeHint { + /// `FieldItemList`, or `EntityReferenceFieldItemList` + /// for a reference field. + List { + item: PhpType, + target: Option, + }, + /// `EntityReferenceItem`: an item of a reference field, which an + /// item list hands out. `own` are the arguments, when the item class + /// has templates of its own for them. + Item { target: PhpType, own: Vec }, +} + +impl FieldTypeHint { + /// The arguments the class's own templates take: the item, carrying the + /// target, for a list, whose template is the item's; for an item, those + /// it has templates for. + pub(crate) fn specialisation(&self) -> Vec { + match self { + Self::List { item, target } => vec![list_item(item, target.as_ref())], + Self::Item { own, .. } => own.clone(), + } + } +} + +/// The items of a list of `item`, carrying the reference field's `target` +/// unless `item` names arguments of its own. +fn list_item(item: &PhpType, target: Option<&PhpType>) -> PhpType { + match (item.base_name(), target, item.kind()) { + (_, _, TypeKind::Generic(_)) => item.clone(), + (Some(name), Some(target), _) => PhpType::generic_atom(atom(name), vec![target.clone()]), + _ => item.clone(), + } +} + +/// What the type hint naming `class` with `generic_args` says about a field +/// item list or item, in a Drupal project. +pub(crate) fn field_type_hint( + class: &ClassInfo, + generic_args: &[PhpType], + class_loader: &ResolvedClassLoader<'_>, +) -> Option { + if generic_args.is_empty() + || !crate::virtual_members::active_resolved_class_cache() + .is_some_and(|cache| cache.read().drupal().is_enabled()) + { + return None; + } + if is_subtype_of(class, FIELD_ITEM_LIST, class_loader) { + let item = generic_args[0].clone(); + item.base_name()?; + return Some(FieldTypeHint::List { + item, + target: generic_args.get(1).cloned(), + }); + } + if class.kind == ClassLikeKind::Class && is_subtype_of(class, FIELD_ITEM, class_loader) { + return Some(FieldTypeHint::Item { + target: generic_args[0].clone(), + own: if class.template_params.is_empty() { + Vec::new() + } else { + generic_args.to_vec() + }, + }); + } + None +} + +/// Give `result`, the class a type hint names, what `hint` says: a list the +/// properties of its item, and a reference field's list and item its +/// target. +pub(crate) fn apply_to_field_type( + result: &mut ClassInfo, + hint: &FieldTypeHint, + class_loader: &ResolvedClassLoader<'_>, +) { + match hint { + FieldTypeHint::List { item, target } => { + let Some(item_class) = item + .base_name() + .and_then(|name| class_loader(Fqn::new(name))) + else { + return; + }; + let resolved = match target { + Some(target) => crate::virtual_members::resolve_class_for_type_hint( + &item_class, + class_loader, + &[target.to_string()], + std::slice::from_ref(target), + ), + None => crate::virtual_members::resolve_class_fully_maybe_cached( + &item_class, + class_loader, + crate::virtual_members::active_resolved_class_cache(), + ), + }; + // A read from outside reaches `__get()` past a property the list + // keeps to itself, such as its protected `$langcode`. + let added: Vec> = resolved + .properties + .iter() + .filter(|property| { + property.source == Some(PropertySource::DrupalItemProperty) + && !result.properties.iter().any(|own| { + own.name == property.name && own.visibility == Visibility::Public + }) + }) + .cloned() + .collect(); + if !added.is_empty() { + let properties = result.properties.make_mut(); + properties.retain(|own| !added.iter().any(|item| item.name == own.name)); + properties.extend(added); + } + // A reference list's own interface names no item, which a + // list class without templates of its own would otherwise + // take its items' type from. + let item = PhpType::nullable(list_item(item, target.as_ref())); + set_return_type(result, "first", &item); + set_return_type(result, "get", &item); + if let Some(target) = target { + set_return_type( + result, + "referencedEntities", + &PhpType::array_of(target.clone()), + ); + } + } + FieldTypeHint::Item { target, .. } => { + let untyped = entity_type(None); + let is_entity = |property: &PropertyInfo| { + property.source == Some(PropertySource::DrupalItemProperty) + && property.type_hint.as_ref() == Some(&untyped) + }; + if !result.properties.iter().any(|property| is_entity(property)) { + return; + } + let typed = entity_type(Some(target)); + for property in result.properties.make_mut().iter_mut() { + if is_entity(property) { + let mut retyped = (**property).clone(); + retyped.type_hint = Some(typed.clone()); + *property = Arc::new(retyped); + } + } + } + } +} + +/// Make `ty` the return type of `result`'s method `name`, if it has one. +fn set_return_type(result: &mut ClassInfo, name: &str, ty: &PhpType) { + let Some(position) = result + .methods + .iter() + .position(|method| method.name.eq_ignore_ascii_case(name)) + else { + return; + }; + if result.methods[position].return_type.as_ref() == Some(ty) { + return; + } + let method = &mut result.methods.make_mut()[position]; + let mut typed = (**method).clone(); + typed.return_type = Some(ty.clone()); + *method = Arc::new(typed); +} diff --git a/src/drupal/field_storage.rs b/src/drupal/field_storage.rs new file mode 100644 index 000000000..ce3c87f8a --- /dev/null +++ b/src/drupal/field_storage.rs @@ -0,0 +1,256 @@ +//! Drupal's configurable fields, each declared by a field storage file, +//! `field.storage...yml`. +//! +//! Extensions ship them as default configuration (`config/install` and +//! `config/optional`), core's profiles included, and a site keeps the ones +//! it has in its exported configuration (`config/sync`, config split +//! folders), anywhere in the project. Recipes and tests ship them too, for +//! sites that apply a recipe or run a test, so those are no source. A field +//! is offered on every bundle of its entity type: `field.field.*` files, +//! which attach it to bundles, are not read. +//! +//! The workspace walk already reads every YAML file the project commits, +//! and the Drupal walk hands it core's and contributed extensions' field +//! storage files, so each one's symbol map carries what it declares (see +//! [`read_field_storage`]) into the field index (see [`super::base_fields`]). + +use std::path::Path; + +use super::config_keys::yaml_tree; +use crate::Backend; +use crate::symbol_map::FieldStorageSite; + +/// Whether the file name `name` is a field storage file's: +/// `field.storage...yml`. +pub(crate) fn is_field_storage_file_name(name: &str) -> bool { + name.strip_prefix("field.storage.") + .and_then(|rest| rest.strip_suffix(".yml")) + .and_then(|rest| rest.split_once('.')) + .is_some_and(|(entity_type, field)| { + !entity_type.is_empty() && !field.is_empty() && !field.contains('.') + }) +} + +/// Whether the file at the end of `uri` is a field storage file. +pub(crate) fn is_field_storage_uri(uri: &str) -> bool { + is_field_storage_file_name(uri.rsplit('/').next().unwrap_or(uri)) +} + +/// Whether `path` is a field storage file. +pub(crate) fn is_field_storage_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(is_field_storage_file_name) +} + +/// The field a field storage file's `content` declares: its `entity_type`, +/// `field_name`, `type`, and `settings.target_type`. +pub(crate) fn read_field_storage(content: &str) -> Option { + let tree = yaml_tree(content); + let top = |key: &str| tree.iter().find(|node| node.key == key); + let value = |key: &str| { + top(key) + .map(|node| node.value.as_str()) + .filter(|value| !value.is_empty()) + }; + let name = top("field_name").filter(|node| !node.value.is_empty())?; + // The value starts after the key's colon, past any quote. + let line = &content[name.start..]; + let line = &line[..line.find('\n').unwrap_or(line.len())]; + let start = name.start + + line + .find(':') + .and_then(|colon| line[colon..].find(name.value.as_str()).map(|at| colon + at)) + .unwrap_or(0); + Some(FieldStorageSite { + entity_type: value("entity_type")?.to_string(), + field_name: name.value.clone(), + field_type: value("type")?.to_string(), + target_type: top("settings") + .and_then(|settings| { + settings + .children + .iter() + .find(|node| node.key == "target_type") + }) + .map(|node| node.value.clone()) + .filter(|value| !value.is_empty()), + start: start as u32, + end: (start + name.value.len()) as u32, + }) +} + +/// Whether a field storage file at `relative`, relative to the web root +/// when `in_web_root` and to the workspace otherwise, declares a field of +/// the site: not a recipe's, not a test's unless `is_open`, and inside the +/// folders Composer installs only as an extension's default configuration. +fn counts(relative: &Path, in_web_root: bool, is_open: bool) -> bool { + let mut parts = relative.components().map(|part| part.as_os_str()); + if parts.clone().any(|part| part == "recipes") { + return false; + } + if !is_open && parts.any(|part| part == "tests") { + return false; + } + if !in_web_root { + return true; + } + if super::UNLOADED_DIRS + .iter() + .any(|dir| relative.starts_with(dir)) + { + return false; + } + !super::READ_ONLY_DIRS + .iter() + .any(|dir| relative.starts_with(dir)) + || super::config::has_default_config_shape(relative) +} + +impl Backend { + /// Whether the field storage file at `uri` declares a field of the + /// site: one in the workspace or a web root, outside its dependencies, + /// that [`counts`]. + pub(crate) fn is_drupal_field_storage_source(&self, uri: &str) -> bool { + if self + .workspace + .vendor_uri_prefixes + .lock() + .iter() + .any(|prefix| uri.starts_with(prefix.as_str())) + { + return false; + } + let Some(path) = crate::util::uri_to_path(uri) else { + return false; + }; + let is_open = || self.open_files.read().contains_key(uri); + if let Some(root) = self + .drupal + .web_roots + .read() + .iter() + .find(|root| path.starts_with(root)) + { + let relative = path.strip_prefix(root).unwrap_or(&path); + return counts(relative, true, is_open()); + } + let workspace = self.workspace.workspace_root.read().clone(); + workspace.is_some_and(|workspace| { + path.strip_prefix(&workspace) + .is_ok_and(|relative| counts(relative, false, is_open())) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_storage_files_are_found_by_name() { + assert!(is_field_storage_file_name( + "field.storage.node.field_tags.yml" + )); + for name in [ + "field.field.node.article.field_tags.yml", + "field.storage.node.yml", + "field.storage.node.field_tags.extra.yml", + "field.storage..field_tags.yml", + "field.storage.node.field_tags.yaml", + ] { + assert!(!is_field_storage_file_name(name), "{name}"); + } + assert!(is_field_storage_uri( + "file:///site/config/sync/field.storage.node.field_tags.yml" + )); + } + + #[test] + fn a_storage_gives_its_entity_type_name_type_and_target() { + let content = "uuid: 0d4c5b0e\nlangcode: en\ndependencies:\n module:\n - node\n - taxonomy\nid: node.field_tags\nfield_name: field_tags\nentity_type: node\ntype: entity_reference\nsettings:\n target_type: taxonomy_term\nmodule: core\n"; + let site = read_field_storage(content).expect("a field"); + assert_eq!(site.entity_type, "node"); + assert_eq!(site.field_name, "field_tags"); + assert_eq!(site.field_type, "entity_reference"); + assert_eq!(site.target_type.as_deref(), Some("taxonomy_term")); + assert_eq!( + &content[site.start as usize..site.end as usize], + "field_tags" + ); + assert!(content[..site.start as usize].ends_with("field_name: ")); + + let quoted = "field_name: 'field_body'\nentity_type: node\ntype: text_with_summary\nsettings: { }\n"; + let site = read_field_storage(quoted).expect("a field"); + assert_eq!(site.target_type, None); + assert_eq!( + "ed[site.start as usize..site.end as usize], + "field_body" + ); + assert!(read_field_storage("field_name: field_tags\ntype: string\n").is_none()); + } + + /// An export committed from Windows has CRLF line endings. + #[test] + fn a_storage_with_crlf_line_endings_reads_the_same() { + let content = "id: node.field_tags\r\nfield_name: field_tags\r\nentity_type: node\r\ntype: entity_reference\r\nsettings:\r\n target_type: taxonomy_term\r\n"; + let site = read_field_storage(content).expect("a field"); + assert_eq!(site.field_type, "entity_reference"); + assert_eq!(site.target_type.as_deref(), Some("taxonomy_term")); + assert_eq!( + &content[site.start as usize..site.end as usize], + "field_tags" + ); + } + + #[test] + fn storages_count_outside_recipes_tests_and_installed_code() { + let web = |path: &str, open: bool| counts(Path::new(path), true, open); + let project = |path: &str, open: bool| counts(Path::new(path), false, open); + assert!(project( + "config/sync/field.storage.node.field_tags.yml", + false + )); + assert!(project( + "config/split/dev/field.storage.node.field_tags.yml", + false + )); + assert!(web( + "modules/custom/acme/config/install/field.storage.acme_note.field_tags.yml", + false + )); + assert!(web( + "core/profiles/standard/config/install/field.storage.user.user_picture.yml", + false + )); + assert!(web( + "modules/contrib/example/config/optional/field.storage.node.field_example.yml", + false + )); + assert!(!web( + "core/recipes/article_tags/config/field.storage.node.field_tags.yml", + false + )); + assert!(!project( + "recipes/example/config/field.storage.node.field_tags.yml", + false + )); + assert!(!web( + "core/modules/node/tests/modules/node_test/config/install/field.storage.node.field_test.yml", + false + )); + assert!(web( + "core/modules/node/tests/modules/node_test/config/install/field.storage.node.field_test.yml", + true + )); + assert!(!project( + "tests/fixtures/field.storage.node.field_test.yml", + false + )); + assert!(!web("core/assets/field.storage.node.field_tags.yml", false)); + assert!(!web( + "modules/contrib/example/field.storage.node.field_example.yml", + false + )); + } +} diff --git a/src/drupal/fields.rs b/src/drupal/fields.rs new file mode 100644 index 000000000..0068a394d --- /dev/null +++ b/src/drupal/fields.rs @@ -0,0 +1,561 @@ +//! The fields of Drupal's content entities, read as properties. +//! +//! Every content entity reads its fields through `ContentEntityBase::__get()`, +//! which also hands back any other value stored on the entity, so any +//! property name reads on the entity class, and writes every value through +//! `ContentEntityBase::__set()`. The interfaces a receiver is usually typed +//! with (`NodeInterface`, `ContentEntityInterface`) declare neither, so +//! [`FieldableEntityProvider`] gives them the same untyped ones the class +//! has. +//! +//! The fields an entity type is known to have are typed properties on top of +//! that: a field is its field type's item list, `FieldItemList`. +//! [`receiver_entity_types`] maps a receiver to the entity types it may +//! hold, and the receiver has the fields all of them have. The fields are +//! read into a [`FieldIndex`] on first use (see [`super::base_fields`] and, +//! for configurable fields, [`super::field_storage`]), +//! which the provider reads from the resolved-class cache: providers never +//! see the `Backend`. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use parking_lot::{Mutex, RwLock}; + +use crate::atom::{Atom, atom}; +use crate::class_lookup::{Fqn, ResolvedClassLoader, is_subtype_of}; +use crate::php_type::PhpType; +use crate::symbol_map::FieldItemSite; +use crate::types::{ + ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, MethodInfo, ParameterInfo, PropertyInfo, + PropertySource, SharedVec, +}; +use crate::virtual_members::{ResolvedClassCache, VirtualMemberProvider, VirtualMembers}; + +/// The interface every entity with fields implements. +pub(crate) const FIELDABLE_ENTITY: &str = "Drupal\\Core\\Entity\\FieldableEntityInterface"; + +/// The methods of a fieldable entity that take a field's name, each with +/// the name of the parameter that takes it, first. +const FIELD_NAME_CALLS: &[(&str, &str)] = &[ + ("get", "field_name"), + ("set", "field_name"), + ("hasField", "field_name"), + ("getFieldDefinition", "name"), +]; + +/// The name of the parameter a call of `method` on a fieldable entity takes +/// a field's name in, when it takes one. +pub(crate) fn field_name_call(method: &str) -> Option<&'static str> { + FIELD_NAME_CALLS + .iter() + .find(|(name, _)| method.eq_ignore_ascii_case(name)) + .map(|(_, parameter)| *parameter) +} + +/// The key of a [`crate::symbol_map::StringKeyKind::DrupalFieldName`] +/// span: the field `name` of the entity types `entity_types` a receiver +/// may hold, sorted and joined with commas, written as the ID of a field +/// storage is (`node.title`, `node,user.uid`). +pub(crate) fn field_key(entity_types: &[String], name: &str) -> String { + let mut sorted: Vec<&str> = entity_types.iter().map(String::as_str).collect(); + sorted.sort_unstable(); + format!("{}.{name}", sorted.join(",")) +} + +/// The entity types, joined with commas, and the field name of a field +/// name's span key. +pub(crate) fn split_field_key(key: &str) -> (&str, &str) { + key.split_once('.').unwrap_or(("", key)) +} + +/// The entity types of a field name's span key. +pub(crate) fn field_key_entity_types(key: &str) -> impl Iterator { + split_field_key(key) + .0 + .split(',') + .filter(|entity_type| !entity_type.is_empty()) +} + +/// What the resolved-class cache carries for Drupal, since virtual member +/// providers receive the cache but never the `Backend`. +pub(crate) struct DrupalCacheState { + /// Whether the workspace holds a Drupal project, shared with + /// [`Backend::is_drupal_project`](crate::Backend::is_drupal_project). + /// Off in a cache made without a `Backend`, so code that resolves + /// without a project pays nothing for Drupal. + enabled: Arc, + /// The fields of the project's entity types, shared with the `Backend` + /// that reads them. + fields: Arc, +} + +impl Default for DrupalCacheState { + fn default() -> Self { + Self::new(Arc::new(AtomicBool::new(false)), Arc::default()) + } +} + +impl DrupalCacheState { + pub(crate) fn new(enabled: Arc, fields: Arc) -> Self { + Self { enabled, fields } + } + + pub(crate) fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Acquire) + } +} + +/// Where the [`FieldIndex`] lives once read, shared between the `Backend` +/// that reads it and the resolved-class cache the provider reads it from. +/// +/// It is published only once complete, and read by one thread at a time: +/// a half-read index would look like entity types without fields. +#[derive(Default)] +pub(crate) struct FieldSlot { + index: RwLock>>, + /// Serializes reads, so a thread that arrives during one waits for it. + build: Mutex<()>, + /// Moves with every [`invalidate`](Self::invalidate), so a read that + /// spans one is read again. + generation: AtomicU64, + /// Whether an index was handed out since the last invalidation, so a + /// resolved class may hold fields read from it. + handed_out: AtomicBool, + /// The state of the `Backend` that reads the index, for the providers, + /// which never see the `Backend`. Weak, since the state holds the + /// cache that holds this slot. + backend: std::sync::OnceLock>, + /// The classes, lowercased, the read in progress has looked at so far: + /// a publish that changes none of them leaves the read alone, as + /// nearly every publish during indexing does. + read_classes: Mutex>, +} + +thread_local! { + /// Whether this thread is reading a field index, during which the + /// class loads it makes must not start another. + static READING_FIELDS: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Marks this thread as reading a field index for as long as it lives, +/// panics included. +struct ReadingFields; + +impl ReadingFields { + fn enter() -> Self { + READING_FIELDS.set(true); + Self + } +} + +impl Drop for ReadingFields { + fn drop(&mut self) { + READING_FIELDS.set(false); + } +} + +impl FieldSlot { + /// The index, or `None` when nothing has read it since the last change. + pub(crate) fn peek(&self) -> Option> { + let slot = self.index.read(); + let index = slot.clone()?; + // Marked under the lock, so an invalidation either sees the mark + // or finds the index gone. + self.handed_out.store(true, Ordering::Release); + Some(index) + } + + /// The index, read by `read` unless it is there already. `None` on + /// the thread that is reading it. A read an invalidation overtakes is + /// read again, so what is handed out is always what was published. + pub(crate) fn get_or_read( + &self, + mut read: impl FnMut() -> FieldIndex, + ) -> Option> { + if let Some(index) = self.peek() { + return Some(index); + } + if READING_FIELDS.get() { + return None; + } + let _build = self.build.lock(); + loop { + if let Some(index) = self.peek() { + return Some(index); + } + let generation = self.generation.load(Ordering::Acquire); + self.read_classes.lock().clear(); + let index = { + let _reading = ReadingFields::enter(); + Arc::new(read()) + }; + let mut slot = self.index.write(); + if self.generation.load(Ordering::Acquire) == generation { + self.handed_out.store(true, Ordering::Release); + *slot = Some(Arc::clone(&index)); + self.read_classes.lock().clear(); + return Some(index); + } + } + } + + /// The index, read by the `Backend` [`bind`](Self::bind) names unless + /// it is there already. `None` before the workspace is a Drupal + /// project, and on the thread that is reading it. + pub(crate) fn get(&self) -> Option> { + if let Some(index) = self.peek() { + return Some(index); + } + let state = self.backend.get()?.upgrade()?; + crate::Backend { + state, + request_progress: None, + } + .drupal_field_index() + } + + /// How many times the index was invalidated. + #[cfg(test)] + pub(crate) fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + /// The index as published, without handing it out. + pub(crate) fn published(&self) -> Option> { + self.index.read().clone() + } + + /// Note that the read in progress looked at the class `name`. Noted + /// before the class is loaded, so a declaration published after + /// [`read_looked_at`](Self::read_looked_at) said no is one the read + /// finds. + pub(crate) fn note_read_class(&self, name: &str) { + self.read_classes + .lock() + .insert(name.trim_start_matches('\\').to_ascii_lowercase()); + } + + /// Whether the read in progress, if any, has looked at one of + /// `classes`, so what it read of them may have changed. + pub(crate) fn read_looked_at(&self, classes: &[&str]) -> bool { + let read = self.read_classes.lock(); + !read.is_empty() + && classes + .iter() + .any(|class| read.contains(&class.trim_start_matches('\\').to_ascii_lowercase())) + } + + /// The classes the read in progress has looked at. They stay noted + /// until its index is published, which then answers for them. + pub(crate) fn read_classes(&self) -> std::collections::HashSet { + self.read_classes.lock().clone() + } + + /// Name the state of the `Backend` that reads the index. + pub(crate) fn bind(&self, backend: &Arc) { + let _ = self.backend.set(Arc::downgrade(backend)); + } + + /// Drop the index, so the next reader reads it again. Whether an index + /// was handed out since the last invalidation, so the classes that may + /// have fields from it have to go. + pub(crate) fn invalidate(&self) -> bool { + self.generation.fetch_add(1, Ordering::AcqRel); + let mut slot = self.index.write(); + *slot = None; + self.handed_out.swap(false, Ordering::AcqRel) + } +} + +/// One field of an entity type. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Field { + pub name: String, + /// The item list the field holds, with its item class as type argument + /// and, for a reference field, its target entity class after it. + pub ty: PhpType, + /// The IDs of the field types it is created with, `string` or + /// `entity_reference`: one, unless its declarations disagree. + pub field_types: Vec, + /// Where the field is declared. None for the fields Drupal's entity + /// field manager adds itself. + pub declarations: Vec, +} + +/// Where a field is declared. Where it is written is looked up when +/// asked, since an edit above it moves it without changing the fields. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum FieldDeclaration { + /// The statement at `statement` among those the base field method + /// `method` of `owner` in the file at `uri` creates fields with. + BaseField { + uri: Arc, + owner: Atom, + method: Atom, + statement: usize, + }, + /// The field storage file at `uri`. + Storage { uri: Arc }, +} + +/// The fields of one entity type declaration. +#[derive(Debug)] +pub(crate) struct EntityTypeFields { + pub id: String, + pub class: Atom, + pub fields: Vec, +} + +/// The fields of every content entity type the project declares, and the +/// properties of every field item class. +#[derive(Debug, Default)] +pub(crate) struct FieldIndex { + pub entity_types: Vec, + /// What each field item class declares about its items, by its + /// lowercased class. + pub field_items: HashMap, + /// The classes, interfaces, and traits the index was read through, + /// lowercased: the entity classes with all they extend, implement, and + /// use, and the classes their base field methods were looked up in. + pub classes: std::collections::HashSet, +} + +impl FieldIndex { + /// The fields `receiver` has: those every entity type it may hold has, + /// each typed by all of theirs. + pub(crate) fn fields_of( + &self, + receiver: &ClassInfo, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec { + let ids = receiver_entity_types( + receiver, + self.entity_types + .iter() + .map(|entity_type| (entity_type.id.as_str(), entity_type.class)), + class_loader, + ); + let mut per_type = ids.iter().map(|id| { + // An ID declared twice has the fields of either declaration. + let mut fields: Vec = Vec::new(); + for entity_type in self.entity_types.iter().filter(|e| e.id == *id) { + for field in &entity_type.fields { + match fields.iter_mut().find(|known| known.name == field.name) { + Some(known) => merge_field(known, field), + None => fields.push(field.clone()), + } + } + } + fields + }); + let Some(mut shared) = per_type.next() else { + return Vec::new(); + }; + for fields in per_type { + let by_name: HashMap<&str, &Field> = fields + .iter() + .map(|field| (field.name.as_str(), field)) + .collect(); + shared.retain_mut(|field| match by_name.get(field.name.as_str()) { + Some(other) => { + merge_field(field, other); + true + } + None => false, + }); + } + shared + } +} + +/// Make `field` the same field as it is and as `other` is. +pub(super) fn merge_field(field: &mut Field, other: &Field) { + if field.ty != other.ty { + field.ty = PhpType::union(vec![field.ty.clone(), other.ty.clone()]); + } + for field_type in &other.field_types { + if !field.field_types.contains(field_type) { + field.field_types.push(*field_type); + } + } + for declaration in &other.declarations { + if !field.declarations.contains(declaration) { + field.declarations.push(declaration.clone()); + } + } +} + +/// Whether `class` is `FieldableEntityInterface` or extends or implements +/// it. +pub(crate) fn is_fieldable(class: &ClassInfo, class_loader: &ResolvedClassLoader<'_>) -> bool { + // Read first, as a class that extends and implements nothing is most + // classes, and is no subtype of anything but itself. + (extends_or_implements(class) || class.fqn() == FIELDABLE_ENTITY) + && is_subtype_of(class, FIELDABLE_ENTITY, class_loader) +} + +/// Whether `class` names a parent or an interface, without which it is a +/// subtype of nothing but itself. +pub(super) fn extends_or_implements(class: &ClassInfo) -> bool { + class.parent_class.is_some() || !class.interfaces.is_empty() +} + +/// The members a fieldable entity interface reads and writes its fields +/// with. +pub(crate) struct FieldableEntityProvider; + +impl VirtualMemberProvider for FieldableEntityProvider { + fn applies_to(&self, class: &ClassInfo, class_loader: &ResolvedClassLoader<'_>) -> bool { + matches!(class.kind, ClassLikeKind::Interface | ClassLikeKind::Class) + && is_fieldable(class, class_loader) + } + + /// The magic methods go on interfaces only: an entity class has its own + /// `__get()` and `__set()` from `ContentEntityBase`, and passes the + /// interface's on to a class that lacks them. + fn provide( + &self, + class: &ClassInfo, + class_loader: &ResolvedClassLoader<'_>, + cache: Option<&ResolvedClassCache>, + ) -> VirtualMembers { + let methods = if class.kind == ClassLikeKind::Interface + && !class + .methods + .iter() + .any(|method| method.name.eq_ignore_ascii_case("__get")) + { + vec![Arc::new(magic_get()), Arc::new(magic_set())] + } else { + Vec::new() + }; + let properties = cache + .and_then(field_index) + .map(|index| { + index + .fields_of(class, class_loader) + .into_iter() + .map(|field| field_property(&field)) + .collect() + }) + .unwrap_or_default(); + VirtualMembers { + methods, + properties, + constants: Vec::new(), + } + } +} + +/// The field index the `Backend` reads into `cache`. +pub(super) fn field_index(cache: &ResolvedClassCache) -> Option> { + // Out of the cache's lock: reading the index resolves classes. + let slot = Arc::clone(&cache.read().drupal().fields); + slot.get() +} + +/// The property `field` reads as. +fn field_property(field: &Field) -> PropertyInfo { + PropertyInfo { + source: Some(PropertySource::DrupalField), + ..PropertyInfo::virtual_property_typed(&field.name, Some(&field.ty)) + } +} + +/// `ContentEntityBase::&__get($name)`, which declares no return type. +fn magic_get() -> MethodInfo { + MethodInfo { + parameters: SharedVec::from(vec![untyped_parameter("$name")]), + ..MethodInfo::virtual_method_typed("__get", None) + } +} + +/// `ContentEntityBase::__set($name, $value)`: a write on an interface +/// receiver is as opaque as on the class, so a later read is not narrowed +/// to the written value. +fn magic_set() -> MethodInfo { + MethodInfo { + parameters: SharedVec::from(vec![ + untyped_parameter("$name"), + untyped_parameter("$value"), + ]), + ..MethodInfo::virtual_method_typed("__set", None) + } +} + +fn untyped_parameter(name: &str) -> ParameterInfo { + ParameterInfo { + name: atom(name), + is_required: true, + type_hint: None, + native_type_hint: None, + description: None, + default_value: None, + is_variadic: false, + is_reference: false, + closure_this_type: None, + param_out_type: None, + callable_invocation: None, + expected_values: None, + } +} + +/// The IDs of the entity types `receiver` may hold, among `entity_types` +/// (each ID with its entity class): those whose class is `receiver` or a +/// subtype of it, and the one whose class `receiver` extends, as a bundle +/// class extends its entity type's class. None unless `receiver` is +/// fieldable: an `EntityInterface` holds every entity type, but has no +/// fields. +pub(crate) fn receiver_entity_types<'a>( + receiver: &ClassInfo, + entity_types: impl IntoIterator, + class_loader: &ResolvedClassLoader<'_>, +) -> Vec<&'a str> { + if !is_fieldable(receiver, class_loader) { + return Vec::new(); + } + let entity_types: Vec<(&str, Atom)> = entity_types.into_iter().collect(); + let receiver_fqn = receiver.fqn(); + let mut found: Vec<&str> = Vec::new(); + for &(id, class) in &entity_types { + let holds = class_loader(Fqn::new(&class)) + .is_some_and(|class| is_subtype_of(&class, &receiver_fqn, class_loader)); + if holds && !found.contains(&id) { + found.push(id); + } + } + // The nearest class up the receiver's parents that is an entity class, + // unless the receiver is one itself. + if receiver.kind != ClassLikeKind::Interface + && !entity_types.iter().any(|(_, class)| *class == receiver_fqn) + { + let mut parent = receiver.parent_class; + let mut depth = 0; + while let Some(name) = parent + && depth < MAX_INHERITANCE_DEPTH + { + depth += 1; + let declared: Vec<&str> = entity_types + .iter() + .filter(|(_, class)| *class == name) + .map(|(id, _)| *id) + .collect(); + if !declared.is_empty() { + for id in declared { + if !found.contains(&id) { + found.push(id); + } + } + break; + } + parent = class_loader(Fqn::new(&name)).and_then(|class| class.parent_class); + } + } + found +} + +#[cfg(test)] +#[path = "fields_tests.rs"] +mod tests; diff --git a/src/drupal/fields_tests.rs b/src/drupal/fields_tests.rs new file mode 100644 index 000000000..9b545a216 --- /dev/null +++ b/src/drupal/fields_tests.rs @@ -0,0 +1,302 @@ +use std::sync::Arc; + +use super::receiver_entity_types; +use crate::atom::atom; +use crate::class_lookup::Fqn; +use crate::types::{ClassInfo, ClassLikeKind}; + +/// A class or interface named by its FQN, with its parent and interfaces. +fn class_like( + kind: ClassLikeKind, + fqn: &str, + parent: Option<&str>, + interfaces: &[&str], +) -> Arc { + let (namespace, name) = match fqn.rsplit_once('\\') { + Some((namespace, name)) => (Some(atom(namespace)), name), + None => (None, fqn), + }; + Arc::new(ClassInfo { + kind, + name: atom(name), + file_namespace: namespace, + parent_class: parent.map(atom), + interfaces: interfaces.iter().map(|name| atom(name)).collect(), + ..Default::default() + }) +} + +fn interface(fqn: &str, extends: &[&str]) -> Arc { + class_like(ClassLikeKind::Interface, fqn, None, extends) +} + +fn class(fqn: &str, parent: Option<&str>, interfaces: &[&str]) -> Arc { + class_like(ClassLikeKind::Class, fqn, parent, interfaces) +} + +/// Core's entity interfaces and two content entity types, `node` and +/// `user`, with `extra` beside them. +fn classes(extra: &[Arc]) -> Vec> { + let mut classes = vec![ + interface("Drupal\\Core\\Entity\\EntityInterface", &[]), + interface( + "Drupal\\Core\\Entity\\FieldableEntityInterface", + &["Drupal\\Core\\Entity\\EntityInterface"], + ), + interface( + "Drupal\\Core\\Entity\\ContentEntityInterface", + &["Drupal\\Core\\Entity\\FieldableEntityInterface"], + ), + class( + "Drupal\\Core\\Entity\\ContentEntityBase", + None, + &["Drupal\\Core\\Entity\\ContentEntityInterface"], + ), + interface( + "Drupal\\node\\NodeInterface", + &["Drupal\\Core\\Entity\\ContentEntityInterface"], + ), + class( + "Drupal\\node\\Entity\\Node", + Some("Drupal\\Core\\Entity\\ContentEntityBase"), + &["Drupal\\node\\NodeInterface"], + ), + interface( + "Drupal\\user\\UserInterface", + &["Drupal\\Core\\Entity\\ContentEntityInterface"], + ), + class( + "Drupal\\user\\Entity\\User", + Some("Drupal\\Core\\Entity\\ContentEntityBase"), + &["Drupal\\user\\UserInterface"], + ), + ]; + classes.extend(extra.iter().cloned()); + classes +} + +const ENTITY_TYPES: &[(&str, &str)] = &[ + ("node", "Drupal\\node\\Entity\\Node"), + ("user", "Drupal\\user\\Entity\\User"), +]; + +/// The entity types the class or interface `receiver` holds, among +/// `entity_types`, with `classes` loadable. +fn entity_types_of( + receiver: &str, + classes: &[Arc], + entity_types: &[(&'static str, &str)], +) -> Vec<&'static str> { + let loader = |name: Fqn<'_>| { + let name = name.as_str(); + let name = name.strip_prefix('\\').unwrap_or(name); + classes.iter().find(|class| class.fqn() == name).cloned() + }; + let receiver = loader(Fqn::new(receiver)).expect("receiver should be loadable"); + receiver_entity_types( + &receiver, + entity_types.iter().map(|(id, class)| (*id, atom(class))), + &loader, + ) +} + +#[test] +fn an_interface_one_entity_class_implements_holds_its_entity_type() { + assert_eq!( + entity_types_of("Drupal\\node\\NodeInterface", &classes(&[]), ENTITY_TYPES), + vec!["node"] + ); +} + +#[test] +fn an_interface_several_entity_classes_implement_holds_each_entity_type() { + for receiver in [ + "Drupal\\Core\\Entity\\ContentEntityInterface", + "Drupal\\Core\\Entity\\FieldableEntityInterface", + ] { + assert_eq!( + entity_types_of(receiver, &classes(&[]), ENTITY_TYPES), + vec!["node", "user"], + "{receiver}" + ); + } +} + +#[test] +fn an_entity_class_holds_its_own_entity_type() { + assert_eq!( + entity_types_of("Drupal\\node\\Entity\\Node", &classes(&[]), ENTITY_TYPES), + vec!["node"] + ); +} + +#[test] +fn a_subclass_of_an_entity_class_holds_the_entity_type_it_extends() { + let classes = classes(&[ + class( + "Drupal\\acme\\Entity\\Bundle\\Article", + Some("Drupal\\acme\\Entity\\Bundle\\Page"), + &[], + ), + class( + "Drupal\\acme\\Entity\\Bundle\\Page", + Some("Drupal\\node\\Entity\\Node"), + &[], + ), + ]); + assert_eq!( + entity_types_of( + "Drupal\\acme\\Entity\\Bundle\\Article", + &classes, + ENTITY_TYPES + ), + vec!["node"] + ); +} + +/// An entity class another entity type's class extends holds both, while +/// the subclass holds only its own. +#[test] +fn an_entity_class_with_entity_subclasses_holds_theirs_too() { + let classes = classes(&[ + class( + "Drupal\\acme\\Entity\\Note", + Some("Drupal\\Core\\Entity\\ContentEntityBase"), + &[], + ), + class( + "Drupal\\acme\\Entity\\RevisionedNote", + Some("Drupal\\acme\\Entity\\Note"), + &[], + ), + ]); + let entity_types = [ + ("acme_note", "Drupal\\acme\\Entity\\Note"), + ( + "acme_revisioned_note", + "Drupal\\acme\\Entity\\RevisionedNote", + ), + ]; + assert_eq!( + entity_types_of("Drupal\\acme\\Entity\\Note", &classes, &entity_types), + vec!["acme_note", "acme_revisioned_note"] + ); + assert_eq!( + entity_types_of( + "Drupal\\acme\\Entity\\RevisionedNote", + &classes, + &entity_types + ), + vec!["acme_revisioned_note"] + ); +} + +/// A read an invalidation overtakes is read again, so the index handed out +/// is always the one published, and the next invalidation says the classes +/// that may have fields from it have to go. +#[test] +fn an_overtaken_read_is_read_again_and_published() { + let slot = super::FieldSlot::default(); + let reads = std::cell::Cell::new(0); + let index = slot.get_or_read(|| { + reads.set(reads.get() + 1); + if reads.get() == 1 { + assert!(!slot.invalidate(), "nothing was handed out yet"); + } + super::FieldIndex::default() + }); + assert!(index.is_some()); + assert_eq!(reads.get(), 2); + assert!(slot.peek().is_some()); + assert!(slot.invalidate(), "an index was handed out"); + assert!(!slot.invalidate(), "nothing was handed out since"); +} + +#[test] +fn a_receiver_without_fields_holds_no_entity_type() { + for receiver in [ + "Drupal\\Core\\Entity\\EntityInterface", + "Drupal\\acme\\Plain", + ] { + let classes = classes(&[class("Drupal\\acme\\Plain", None, &[])]); + assert!( + entity_types_of(receiver, &classes, ENTITY_TYPES).is_empty(), + "{receiver}" + ); + } +} + +/// Only core's interface makes a receiver fieldable, not a global one that +/// shares its short name. +#[test] +fn a_global_interface_named_like_cores_is_not_fieldable() { + let classes = classes(&[ + interface("FieldableEntityInterface", &[]), + interface("Drupal\\acme\\NoteInterface", &["FieldableEntityInterface"]), + class( + "Drupal\\acme\\Entity\\Note", + None, + &["Drupal\\acme\\NoteInterface"], + ), + ]); + assert!( + entity_types_of( + "Drupal\\acme\\NoteInterface", + &classes, + &[("acme_note", "Drupal\\acme\\Entity\\Note")] + ) + .is_empty() + ); +} + +/// A field `name` of the field type `field_type`, typed as its item list. +fn field(name: &str, field_type: &str, item: &str) -> super::Field { + super::Field { + name: name.to_string(), + ty: crate::php_type::PhpType::generic_atom( + atom("Drupal\\Core\\Field\\FieldItemList"), + vec![crate::php_type::PhpType::named(atom(item))], + ), + field_types: vec![atom(field_type)], + declarations: Vec::new(), + } +} + +/// A field every entity type of a receiver has, created with different +/// field types, keeps each of them, and its type is the union. +#[test] +fn a_shared_field_keeps_the_field_types_of_each_entity_type() { + let classes = classes(&[]); + let loader = |name: Fqn<'_>| { + let name = name.as_str(); + let name = name.strip_prefix('\\').unwrap_or(name); + classes.iter().find(|class| class.fqn() == name).cloned() + }; + let index = super::FieldIndex { + entity_types: vec![ + super::EntityTypeFields { + id: "node".to_string(), + class: atom("Drupal\\node\\Entity\\Node"), + fields: vec![field("label", "string", "StringItem")], + }, + super::EntityTypeFields { + id: "user".to_string(), + class: atom("Drupal\\user\\Entity\\User"), + fields: vec![field("label", "string_long", "StringLongItem")], + }, + ], + ..Default::default() + }; + let receiver = loader(Fqn::new("Drupal\\Core\\Entity\\ContentEntityInterface")).unwrap(); + let fields = index.fields_of(&receiver, &loader); + assert_eq!(fields.len(), 1); + assert_eq!( + fields[0].field_types, + vec![atom("string"), atom("string_long")] + ); + let ty = fields[0].ty.to_string(); + assert!( + ty.contains("StringItem") && ty.contains("StringLongItem"), + "{ty}" + ); +} diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index d6b539b33..9dcbc0e76 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -16,10 +16,14 @@ use std::sync::atomic::Ordering; use parking_lot::RwLock; +pub(crate) mod base_fields; pub(crate) mod computed_hooks; pub(crate) mod config; pub(crate) mod config_keys; pub(crate) mod elements; +pub(crate) mod field_items; +pub(crate) mod field_storage; +pub(crate) mod fields; pub(crate) mod hooks; pub(crate) mod libraries; pub(crate) mod links; @@ -100,6 +104,29 @@ pub(crate) struct DrupalState { service_definition_files: RwLock>, /// What those files define, once read. php_service_ids: RwLock>>, + /// The entity types and base field methods each file declares, by URI, + /// copied out of its symbol map; see [`base_fields`]. + field_sources: RwLock, + /// The fields read from them, shared with the resolved-class cache. + fields: std::sync::Arc, + /// Whether what the fields are read from changed since they were last + /// dropped. + fields_changed: std::sync::atomic::AtomicBool, + /// The field item classes whose declarations changed since then. + changed_item_classes: parking_lot::Mutex>, +} + +impl DrupalState { + /// The slot the fields are read into, for the resolved-class cache. + pub(crate) fn field_slot(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.fields) + } + + /// Let the providers read the fields through the `Backend` whose state + /// `backend` is. + pub(crate) fn bind_fields(&self, backend: &std::sync::Arc) { + self.fields.bind(backend); + } } /// A kind of file Drupal reads declarations from. @@ -352,6 +379,13 @@ pub(crate) fn is_hook_php_file(path: &Path, web_root: &Path) -> bool { /// The entity types `map` declares: each ID, with the class and storage /// handler of its declaration. fn entity_type_sites(map: &SymbolMap) -> impl Iterator)> + '_ { + entity_type_declarations(map).map(|(id, site)| (id, site.class, site.storage)) +} + +/// Each entity type declaration of `map`, with the ID it declares. +fn entity_type_declarations( + map: &SymbolMap, +) -> impl Iterator + '_ { map.entity_types.iter().filter_map(|site| { let at = map.spans.partition_point(|span| span.start < site.start); map.spans[at..] @@ -362,7 +396,7 @@ fn entity_type_sites(map: &SymbolMap) -> impl Iterator Some((key.as_str(), site.class, site.storage)), + } => Some((key.as_str(), site)), _ => None, }) }) @@ -425,6 +459,7 @@ impl Backend { .write() .retain(|_, sources| !sources.from_test); self.drupal.entity_type_test_sources.write().clear(); + self.drupal.fields.invalidate(); *self.drupal.extensions.write() = hooks::ExtensionIndex::default(); self.drupal.service_replacements.write().clear(); self.drupal.service_definition_files.write().clear(); diff --git a/src/drupal/plugins.rs b/src/drupal/plugins.rs index b5acbe5fd..865aa389c 100644 --- a/src/drupal/plugins.rs +++ b/src/drupal/plugins.rs @@ -119,6 +119,8 @@ pub(crate) struct PluginType { pub(crate) struct Plugin { pub id: String, pub class: Atom, + /// The item list class a field type's declaration names. + pub list_class: Option, } /// The plugin types and the plugins of each, read from every declaring @@ -252,6 +254,7 @@ impl PluginIndex { self.by_manager.entry(manager).or_default().push(Plugin { id: site.id.clone(), class, + list_class: site.list_class, }); } } @@ -290,6 +293,27 @@ pub(crate) struct PluginSources { pub(super) from_test: bool, } +impl PluginSources { + /// The field types the file declares, wherever they are written: the + /// ID, the item class, and the item list class of each. + fn field_types(&self) -> Vec<(&str, Atom, Option)> { + self.plugins + .iter() + .filter(|site| short_name(&site.marker).eq_ignore_ascii_case("FieldType")) + .map(|site| (site.id.as_str(), site.class, site.list_class)) + .collect() + } + + /// The field type manager's declarations the file holds, which decide + /// which plugins are field types. + fn field_type_managers(&self) -> Vec<&PluginManagerSite> { + self.managers + .iter() + .filter(|site| site.class.eq_ignore_ascii_case(FIELD_TYPES)) + .collect() + } +} + /// Whether the symbol map holds anything the plugin index reads. fn feeds_index(map: &SymbolMap) -> bool { !map.drupal_plugins.is_empty() || !map.drupal_plugin_managers.is_empty() @@ -458,7 +482,27 @@ impl Backend { from_test, }) }); - let unchanged = self.drupal.plugin_sources.read().get(uri) == sources.as_ref(); + let (unchanged, field_types_changed) = { + let stored = self.drupal.plugin_sources.read(); + let old = stored.get(uri); + let unchanged = old == sources.as_ref(); + ( + unchanged, + !unchanged + && (old.map(PluginSources::field_types).unwrap_or_default() + != sources + .as_ref() + .map(PluginSources::field_types) + .unwrap_or_default() + || old + .map(PluginSources::field_type_managers) + .unwrap_or_default() + != sources + .as_ref() + .map(PluginSources::field_type_managers) + .unwrap_or_default()), + ) + }; if !unchanged { let mut stored = self.drupal.plugin_sources.write(); match sources { @@ -484,6 +528,12 @@ impl Backend { *slot = None; } } + // The fields are typed by the field types, read from the plugin + // index, so they go once it has, or the manager that says which + // plugins are field types has. + if field_types_changed { + self.invalidate_drupal_fields(); + } } /// Read the plugin types from the project's plugin managers, then the @@ -546,6 +596,7 @@ impl Backend { plugins.extend(site.static_plugins.iter().map(|plugin| Plugin { id: plugin.id.clone(), class: plugin.class, + list_class: None, })); } index.types.push(PluginType { @@ -647,6 +698,20 @@ impl Backend { found } + /// The item class and the item list class of each declaration of the + /// field type `id`; a list class of `None` for a declaration that names + /// none, whose items Drupal keeps in a `FieldItemList`. + pub(crate) fn drupal_field_type_classes(&self, id: &str) -> Vec<(Atom, Option)> { + let mut classes: Vec<(Atom, Option)> = self + .drupal_plugin_index() + .named(FIELD_TYPES, id) + .iter() + .map(|plugin| (plugin.class, plugin.list_class)) + .collect(); + classes.dedup(); + classes + } + /// The plugin classes the plugin ID `key` (see [`plugin_key`]) names. pub(crate) fn drupal_plugin_classes_named(&self, key: &str) -> Vec { let (manager, id) = split_plugin_key(key); diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index dcd7a3e6d..681cdeb42 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -439,3 +439,44 @@ fn a_path_hash_key_is_a_route_parameter_not_a_variable() { [(StringKeyKind::TwigVariable, "node".to_string())] ); } + +/// A field type gives its item class and the item list class it names. +#[test] +fn a_field_type_gives_its_item_and_list_classes() { + let (backend, dir) = drupal_backend(&[]); + for (path, content) in [ + ( + "web/core/lib/Drupal/Core/Plugin/DefaultPluginManager.php", + "load(1)->`). The storage is tagged with the @@ -321,6 +322,40 @@ const CONFIG_SHORTCUTS: [&str; 2] = [ "Drupal\\Core\\Form\\FormBase", ]; +/// What `get('name')` on a fieldable entity `owner` returns when `name` is +/// one of its fields: the field, typed as reading `->name` types it. An +/// unknown name keeps the declared `FieldItemListInterface`. +pub(crate) fn field_list_type( + backend: &Backend, + owner: &ClassInfo, + method_name: &str, + name: &str, + class_loader: &ResolvedClassLoader<'_>, + cache: Option<&crate::virtual_members::ResolvedClassCache>, +) -> Option { + // A field's name never has a dot, which a configuration name and most + // service IDs a `get()` takes have, so those skip the subtype walk. + if !method_name.eq_ignore_ascii_case("get") + || name.is_empty() + || name.contains('.') + || !backend.is_drupal_project() + || !super::fields::is_fieldable(owner, class_loader) + { + return None; + } + let merged = + crate::virtual_members::resolve_class_fully_maybe_cached(owner, class_loader, cache); + merged + .properties + .iter() + .find(|property| { + property.name == name + && property.source == Some(crate::types::PropertySource::DrupalField) + })? + .type_hint + .clone() +} + /// What a call that reads a configuration object by its name returns: /// whatever the method declares, with the name added as a type argument /// to each class it names (`ImmutableConfig<'acme.settings'>`, both halves diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index 516fc87ab..d21ccf492 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -119,6 +119,10 @@ impl Backend { shown = crate::drupal::config_keys::split_config_key(key).1; ("Configuration key", self.drupal_config_key_detail(key)) } + StringKeyKind::DrupalFieldName => { + shown = crate::drupal::fields::split_field_key(key).1; + ("Field", self.drupal_field_name_detail(key)) + } StringKeyKind::Stack => ("Stack", self.blade_block_detail(uri, kind, key)), StringKeyKind::TwigBlock => ( "Block", diff --git a/src/hover/member.rs b/src/hover/member.rs index aff168c29..f8757bd9d 100644 --- a/src/hover/member.rs +++ b/src/hover/member.rs @@ -166,7 +166,9 @@ pub(super) fn format_property_source(source: &PropertySource) -> Vec { } // The `👻 virtual` marker already says the member comes from a // docblock tag; a "source:" line would be noise. - PropertySource::DocblockTag => Vec::new(), + PropertySource::DocblockTag + | PropertySource::DrupalField + | PropertySource::DrupalItemProperty => Vec::new(), } } diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 44f28cd6b..6a5f4b51d 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -480,8 +480,11 @@ fn property_source(src: &PropertySource) -> Sz { z += vs(pivot_columns); } PropertySource::RelationshipCount { relationship } => z.add(relationship.capacity()), - PropertySource::Pivot | PropertySource::DocblockTag | PropertySource::PromotedParameter => { - } + PropertySource::Pivot + | PropertySource::DocblockTag + | PropertySource::PromotedParameter + | PropertySource::DrupalField + | PropertySource::DrupalItemProperty => {} } z } @@ -1414,7 +1417,99 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { sm.service_decorations.capacity() * size_of::(), ); - sym.add(sm.entity_types.capacity() * size_of::()); + sym.add( + sm.entity_types.capacity() * size_of::() + + sm.entity_types + .iter() + .flat_map(|site| site.keys.iter().chain(&site.revision_metadata_keys)) + .map(|(key, value)| { + size_of::<(String, String)>() + key.capacity() + value.capacity() + }) + .sum::() + + sm.entity_types + .iter() + .filter_map(|site| site.bundle_entity_type.as_ref()) + .map(String::capacity) + .sum::(), + ); + if let Some(fields) = &sm.drupal_fields { + sym.add(size_of::()); + sym.add( + fields.base_field_methods.capacity() + * size_of::(), + ); + for site in &fields.base_field_methods { + sym.add( + site.fields.capacity() * size_of::() + + site.includes.capacity() + * size_of::() + + site.removed.capacity() + * size_of::() + + site + .removed + .iter() + .map(|name| match name { + crate::symbol_map::BaseFieldName::Literal(name) + | crate::symbol_map::BaseFieldName::Key(name) + | crate::symbol_map::BaseFieldName::RevisionMetadataKey( + name, + ) => name.capacity(), + }) + .sum::(), + ); + for field in &site.fields { + sym.add( + field.field_type.capacity() + + match &field.target { + Some(crate::symbol_map::FieldTarget::EntityType(target)) => { + target.capacity() + } + _ => 0, + }, + ); + } + } + sym.add( + fields.field_items.capacity() * size_of::(), + ); + for site in &fields.field_items { + if let Some(definitions) = &site.property_definitions { + sym.add( + definitions.properties.capacity() + * size_of::() + + definitions.removed.capacity() * size_of::() + + definitions + .removed + .iter() + .map(String::capacity) + .sum::(), + ); + for property in &definitions.properties { + sym.add( + property.name.capacity() + + property.data_type.as_ref().map_or(0, String::capacity), + ); + } + } + if let Some( + crate::symbol_map::DefaultTarget::Declared(Some(target)) + | crate::symbol_map::DefaultTarget::FromParent { + otherwise: Some(Some(target)), + }, + ) = &site.default_target + { + sym.add(target.capacity()); + } + } + if let Some(storage) = &fields.storage { + sym.add( + storage.entity_type.capacity() + + storage.field_name.capacity() + + storage.field_type.capacity() + + storage.target_type.as_ref().map_or(0, String::capacity), + ); + } + } sym.add( sm.drupal_plugin_classes.capacity() * size_of::(), diff --git a/src/parser/ast_publish.rs b/src/parser/ast_publish.rs index b0bc20007..8513a85a5 100644 --- a/src/parser/ast_publish.rs +++ b/src/parser/ast_publish.rs @@ -548,6 +548,8 @@ impl Backend { /// A parse that lowered no template keeps the lowering the file already /// had. fn publish_file_states(&self, prepared: &mut [PreparedAstIndexUpdate]) { + // The Drupal fields the batch changed go once, after the lock. + let _fields = self.drupal_field_flush(); let mut files = self.files.write(); for update in prepared.iter_mut() { let template = match update.template.take() { @@ -586,6 +588,7 @@ impl Backend { &self, maps: impl IntoIterator)>, ) { + let _fields = self.drupal_field_flush(); let mut files = self.files.write(); for (uri, map) in maps { self.note_symfony_declarations(&uri, &map); @@ -851,6 +854,9 @@ impl Backend { ) -> (bool, Vec) { let mut any_signature_changed = false; let mut evicted_fqns = Vec::new(); + // The classes whose parent, interfaces, or traits changed, which + // the Drupal fields of an entity class are read through. + let mut reparented: Vec<&str> = Vec::new(); { let mut cache = self.resolved_class_cache.write(); // A name this batch took over was resolved from the other file's @@ -874,6 +880,7 @@ impl Backend { } if update.old_fqns.is_empty() { + reparented.extend(update.new_fqns.iter().map(String::as_str)); continue; } @@ -890,6 +897,13 @@ impl Backend { match (old_cls, new_cls) { (Some(old), Some(new)) if old.signature_eq(new) => {} _ => { + if old_cls.zip(new_cls).is_none_or(|(old, new)| { + old.parent_class != new.parent_class + || old.interfaces != new.interfaces + || old.used_traits != new.used_traits + }) { + reparented.push(fqn); + } changed_names.push(crate::resolution_deps::dep_key(fqn)); evicted_fqns.extend(crate::virtual_members::evict_fqn(&mut cache, fqn)); any_signature_changed = true; @@ -899,6 +913,7 @@ impl Backend { for fqn in &update.new_fqns { if !update.old_fqns.contains(fqn) { + reparented.push(fqn); changed_names.push(crate::resolution_deps::dep_key(fqn)); evicted_fqns.extend(crate::virtual_members::evict_fqn(&mut cache, fqn)); any_signature_changed = true; @@ -906,6 +921,7 @@ impl Backend { } } } + self.note_drupal_class_graph_changes(&reparented); evicted_fqns.sort(); evicted_fqns.dedup(); // A class whose own resolution was cached read its parents, traits, diff --git a/src/reference_index.rs b/src/reference_index.rs index 11c5caba8..26c3b4e16 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -83,6 +83,11 @@ pub(crate) fn string_key_reference_key(kind: StringKeyKind, key: &str) -> Refere .1 .to_string(), }, + // Likewise a fieldable entity's call holds the field name alone. + StringKeyKind::DrupalFieldName => ReferenceIndexKey::StringKey { + kind, + key: crate::drupal::fields::split_field_key(key).1.to_string(), + }, // Likewise a plugin manager's lookup holds the plugin ID alone. StringKeyKind::DrupalPlugin => ReferenceIndexKey::StringKey { kind, diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index bc74b6ff0..42add2d6b 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -467,16 +467,44 @@ impl Backend { // Config resources and their generic config spelling share a // canonical index identity. Declaration lookup is independent // of this usage snapshot, so every request stays narrow. + // A name some of the entity types a receiver may hold lack + // is no field there. + if *kind == crate::symbol_map::StringKeyKind::DrupalFieldName + && crate::drupal::fields::field_key_entity_types(key) + .nth(1) + .is_some() + && self.drupal_field_named(key).is_none() + { + return Vec::new(); + } let reference_key = crate::reference_index::string_key_reference_key(*kind, key); let snapshot = self.user_file_symbol_maps_for_reference_keys(&[reference_key]); - laravel::find_laravel_string_key_references( + let mut locations = laravel::find_laravel_string_key_references( self, kind, key, uri, &snapshot, include_declaration, - ) + ); + // A Drupal field is read as a property of its entity too. + if *kind == crate::symbol_map::StringKeyKind::DrupalFieldName + && let Some(scope) = self.drupal_field_scope(key) + { + for location in self.find_member_references( + crate::drupal::fields::split_field_key(key).1, + false, + MemberRefKind::Property, + false, + Some(&scope), + None, + ) { + if !locations.contains(&location) { + locations.push(location); + } + } + } + locations } SymbolKind::LaravelMacroString { name } => { diff --git a/src/references/member_scope.rs b/src/references/member_scope.rs index a6913876b..b52f7e68f 100644 --- a/src/references/member_scope.rs +++ b/src/references/member_scope.rs @@ -545,6 +545,51 @@ impl Backend { MemberScope::descendants_of(roots, indexed) } + /// The classes a property read of the Drupal field a field name's span + /// `key` names is on: the classes of its entity type and the ones under + /// them, and the classes and interfaces above them that have the field, + /// as `NodeInterface` has node's. + pub(super) fn drupal_field_scope(&self, key: &str) -> Option { + let name = crate::drupal::fields::split_field_key(key).1; + // A name some of the entity types lack is no field there. + if crate::drupal::fields::field_key_entity_types(key) + .nth(1) + .is_some() + { + self.drupal_field_named(key)?; + } + let index = self.drupal_field_index()?; + let class_loader = + |name: Fqn<'_>| -> Option> { self.find_or_load_class(&name) }; + let mut roots: HashSet = HashSet::new(); + let mut above: HashSet = HashSet::new(); + for declared in index.entity_types.iter().filter(|e| { + crate::drupal::fields::field_key_entity_types(key) + .any(|entity_type| e.id == entity_type) + }) { + let class = normalize_fqn(&declared.class); + self.collect_ancestors(&class, &class_loader, &mut above); + roots.insert(class); + } + if roots.is_empty() { + return None; + } + let mut indexed = self.descendants_closure(roots.iter().cloned()); + indexed.extend(roots.iter().cloned()); + for ancestor in above { + let has_field = class_loader(Fqn::new(&ancestor)).is_some_and(|class| { + index + .fields_of(&class, &class_loader) + .iter() + .any(|field| field.name == name) + }); + if has_field { + indexed.insert(ancestor); + } + } + Some(MemberScope::descendants_of(roots, indexed)) + } + /// Whether `fqn` inherits from any of `roots`, walking up from the class /// itself. /// @@ -583,7 +628,10 @@ impl Backend { /// `seeds` and every class that extends or implements one of them, /// transitively. - fn descendants_closure(&self, seeds: impl IntoIterator) -> HashSet { + pub(crate) fn descendants_closure( + &self, + seeds: impl IntoIterator, + ) -> HashSet { let mut closure: HashSet = HashSet::new(); let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); for seed in seeds { diff --git a/src/rename/prepare.rs b/src/rename/prepare.rs index c9ad1f813..6ae7b6820 100644 --- a/src/rename/prepare.rs +++ b/src/rename/prepare.rs @@ -152,6 +152,12 @@ impl Backend { let (name, range) = self.renameable_symbol_info(uri, content, &span.kind, span.start, span.end)?; + // A Drupal field is also named by `get()` calls and the files that + // declare it, which a property rename does not edit. + if self.is_drupal_field_access(uri, content, &span) { + return None; + } + // Reject vendor symbols: if the definition lives under the // vendor directory the user shouldn't rename it. if self.is_vendor_symbol(uri, content, position) { @@ -281,6 +287,10 @@ impl Backend { return Ok(None); } + if self.is_drupal_field_access(uri, content, &span) { + return Ok(None); + } + if let SymbolKind::StringKey { kind: crate::symbol_map::StringKeyKind::WordPressHook, key, diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index 99704baac..eecf61180 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -456,9 +456,23 @@ impl Backend { } } + // A field storage file declares a configurable field wherever it + // is; which ones count is decided as the field index takes them. + let drupal_fields = (self.is_drupal_project() + && crate::drupal::field_storage::is_field_storage_uri(uri)) + .then(|| crate::drupal::field_storage::read_field_storage(content)) + .flatten() + .map(|storage| { + Box::new(crate::symbol_map::DrupalFieldSites { + storage: Some(storage), + ..Default::default() + }) + }); + SymbolMap { spans, member_access_indices, + drupal_fields, container_values, service_decorations, route_imports, diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index a3e2ce830..9d75e9bdf 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -34,6 +34,8 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC emit_drupal_computed_hook_sites(&class.members, class.name.span.start.offset, ctx); emit_drupal_route_builder_spans(&class.members, class.name.span.start.offset, ctx); emit_drupal_editable_config_spans(&class.members, ctx); + emit_drupal_base_field_methods(&class.members, class.name.span.start.offset, ctx); + emit_drupal_field_item_sites(&class.members, class.name.span.start.offset, ctx); if let Some(ref extends) = class.extends { for ident in extends.types.iter() { @@ -212,6 +214,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); emit_drupal_computed_hook_sites(&trait_def.members, trait_def.name.span.start.offset, ctx); + emit_drupal_base_field_methods(&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 76afd8c6d..efd32d54b 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -8,9 +8,10 @@ use crate::symbol_map::extraction::symfony::{ RouteDeclaration, RouteName, is_route_attribute, push_route_spans, route_declarations, }; use crate::symbol_map::{ - DeclaredParameter, DrupalRouteSite, EntityTypeSite, ModuleGuard, PluginClassSite, - PluginDeclarationSite, PluginManagerSite, ReceiverClass, StaticPluginSite, StringKeyKind, - TypedReceiverSite, + BaseFieldInclude, BaseFieldMethodSite, BaseFieldName, BaseFieldSite, DeclaredParameter, + DefaultTarget, DrupalRouteSite, EntityTypeSite, FieldItemSite, FieldTarget, ItemPropertySite, + MethodScope, ModuleGuard, PluginClassSite, PluginDeclarationSite, PluginManagerSite, + PropertyDefinitionsSite, ReceiverClass, StaticPluginSite, StringKeyKind, TypedReceiverSite, }; use crate::virtual_members::laravel::helpers::resolved_string_literal; use mago_syntax::cst::sequence::TokenSeparatedSequence; @@ -70,15 +71,24 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( 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); + let mut declared = DeclaredEntityType::of_kind(kind); 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); + declared.storage = Some(named); } } + "entity_keys" => declared.keys = string_pairs(value, ctx.content), + "revision_metadata_keys" => { + declared.revision_metadata_keys = string_pairs(value, ctx.content); + } + "translatable" => declared.translatable = is_true(value), + "bundle_entity_type" => { + declared.bundle_entity_type = resolved_string_literal(value, ctx.content) + .map(|(bundle, _, _)| bundle.to_string()); + } "admin_permission" => { push_laravel_string_span( StringKeyKind::DrupalPermission, @@ -93,7 +103,10 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( } } if let Some((key, start, end)) = id.filter(|(key, _, _)| !key.is_empty()) { - found.push((key.to_string(), start as u32, end as u32, storage)); + declared.id = key.to_string(); + declared.start = start as u32; + declared.end = end as u32; + found.push(declared); } } } @@ -116,25 +129,94 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( else { return; }; - for (key, start, end, storage) in found { + for declared in found { ctx.spans.push(SymbolSpan { - start, - end, + start: declared.start, + end: declared.end, kind: SymbolKind::StringKey { kind: StringKeyKind::DrupalEntityType, - key, + key: declared.id, is_write: true, is_optional: false, }, }); ctx.entity_types.push(EntityTypeSite { - start, + start: declared.start, class: fqn, - storage: storage.map(|storage| crate::atom::atom(&storage)), + storage: declared.storage.map(|storage| crate::atom::atom(&storage)), + keys: declared.keys, + revision_metadata_keys: declared.revision_metadata_keys, + translatable: declared.translatable, + content: declared.content, + bundle_entity_type: declared.bundle_entity_type, }); } } +/// What an entity type attribute or annotation declares, before the class +/// it is written on is known. +struct DeclaredEntityType { + id: String, + /// The range of the ID in the file. + start: u32, + end: u32, + storage: Option, + keys: Vec<(String, String)>, + revision_metadata_keys: Vec<(String, String)>, + translatable: bool, + content: bool, + bundle_entity_type: Option, +} + +impl DeclaredEntityType { + /// A declaration of `kind` that writes nothing yet. + fn of_kind(kind: &EntityTypeKind) -> Self { + Self { + id: String::new(), + start: 0, + end: 0, + storage: kind.default_storage.map(str::to_string), + keys: Vec::new(), + revision_metadata_keys: Vec::new(), + translatable: false, + content: kind.default_storage == Some(CONTENT_STORAGE), + bundle_entity_type: None, + } + } +} + +/// The `'key' => 'value'` pairs of an array literal whose keys and values +/// are both strings, in order. +fn string_pairs(array: &Expression<'_>, content: &str) -> Vec<(String, String)> { + let Some(entries) = crate::parser::array_literal_elements(array) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|entry| { + let ArrayElement::KeyValue(entry) = entry else { + return None; + }; + let (key, _, _) = resolved_string_literal(entry.key, content)?; + let (value, _, _) = + crate::virtual_members::laravel::helpers::resolved_string_literal_including_empty( + entry.value, + content, + )?; + Some((key.to_string(), value.to_string())) + }) + .collect() +} + +/// Whether `value` is the literal `true`, in any case. +fn is_true(value: &Expression<'_>) -> bool { + match value { + Expression::Literal(Literal::True(_)) => true, + Expression::ConstantAccess(access) => access.name.value().eq_ignore_ascii_case(b"true"), + _ => false, + } +} + /// A Drupal plugin attribute and the annotation older code writes instead. struct PluginMarker { attribute: &'static str, @@ -243,7 +325,7 @@ fn annotation_plugin_id(doc: &str, from: usize) -> Option<(usize, usize)> { } annotation_entries(doc, from) .into_iter() - .find(|entry| entry.path.is_empty() && entry.key == "id") + .find(|entry| entry.path.is_empty() && entry.key == "id" && entry.quoted) .map(|entry| (entry.value_start, entry.value_end)) } @@ -274,13 +356,20 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_plugin_declarations( // Plugin attributes take the ID first, by name or by place. One // that is no string, such as a class constant, is recorded empty: // its plugin type then has a plugin whose ID is not known. - let Some(value) = attribute_arguments(attribute, &["id"]) - .into_iter() + let arguments = attribute_arguments(attribute, &["id"]); + let Some(value) = arguments + .iter() .find(|(parameter, _)| *parameter == "id") - .map(|(_, value)| value) + .map(|(_, value)| *value) else { continue; }; + // A field type's item list class. + let list_class = arguments + .iter() + .find(|(parameter, _)| *parameter == "list_class") + .and_then(|(_, value)| message_class(value, ctx)) + .map(|class| crate::atom::atom(&class)); let (id, start, end) = match resolved_string_literal(value, ctx.content) { Some((id, start, end)) if !id.is_empty() => (id, start, end), Some(_) => continue, @@ -303,15 +392,23 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_plugin_declarations( is_annotation: false, doc_start: start as u32, doc_end: end as u32, + list_class, }); } let Some((doc, offset)) = get_docblock_text_with_offset(ctx.trivias, ctx.content, class) else { return; }; - if let Some((name, (start, end))) = doc_annotations(doc) - .find_map(|(name, from)| Some((name, annotation_plugin_id(doc, from)?))) - .filter(|(_, (start, end))| start < end) + if let Some((name, from, (start, end))) = doc_annotations(doc) + .find_map(|(name, from)| Some((name, from, annotation_plugin_id(doc, from)?))) + .filter(|(_, _, (start, end))| start < end) { + let list_class = annotation_entries(doc, from) + .into_iter() + .find(|entry| entry.path.is_empty() && entry.key == "list_class" && entry.quoted) + .map(|entry| { + crate::atom::atom(doc[entry.value_start..entry.value_end].trim_start_matches('\\')) + }) + .filter(|class| !class.is_empty()); ctx.drupal_plugins.push(PluginDeclarationSite { start: offset + start as u32, end: offset + end as u32, @@ -321,6 +418,7 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_plugin_declarations( is_annotation: true, doc_start: offset, doc_end: offset + doc.len() as u32, + list_class, }); } } @@ -1181,6 +1279,558 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( } } +// ─── Base fields ──────────────────────────────────────────────────────────── + +const BASE_FIELD_DEFINITION: &str = "Drupal\\Core\\Field\\BaseFieldDefinition"; + +/// Whether a method named `name` declares base fields: an entity class's +/// `baseFieldDefinitions()`, or a helper such as +/// `ownerBaseFieldDefinitions()`. +fn is_base_field_method(name: &[u8]) -> bool { + name.eq_ignore_ascii_case(b"baseFieldDefinitions") || name.ends_with(b"BaseFieldDefinitions") +} + +/// Record the base fields each base field method of a class or trait +/// creates, and the base field methods it calls (see +/// [`crate::drupal::fields`]). `name_offset` is that of the class's or +/// trait's name. +pub(in crate::symbol_map::extraction) fn emit_drupal_base_field_methods( + members: &Sequence<'_, ClassLikeMember<'_>>, + name_offset: u32, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal || !ctx.content.contains("BaseFieldDefinition") { + return; + } + let mut owner = None; + for member in members.iter() { + let ClassLikeMember::Method(method) = member else { + continue; + }; + if !is_base_field_method(method.name.value) { + continue; + } + let Some(owner) = *owner.get_or_insert_with(|| { + ctx.resolved_name_at(name_offset) + .map(|fqn| crate::atom::atom(fqn.trim_start_matches('\\'))) + }) else { + return; + }; + let mut found = BaseFieldSites::default(); + mago_syntax::walker::Walker::walk_method(&BaseFieldWalker, method, &mut found); + let fields: Vec = found + .keyed + .iter() + .filter_map(|keyed| base_field(keyed.key, keyed.value, &found, ctx)) + .collect(); + // The fields it unsets from the array it returns. + let returned: Vec<&[u8]> = found + .returned + .iter() + .filter_map(|value| match value { + Expression::Variable(Variable::Direct(variable)) => Some(variable.name), + _ => None, + }) + .collect(); + let removed: Vec = found + .unset + .iter() + .filter(|access| { + matches!(access.array, Expression::Variable(Variable::Direct(variable)) + if returned.contains(&variable.name)) + }) + .filter_map(|access| base_field_name(access.index, ctx).map(|(name, _, _)| name)) + .collect(); + let includes: Vec = found + .calls + .into_iter() + .filter_map(|call| { + let scope = match call.class { + Expression::Parent(_) => MethodScope::Parent, + Expression::Static(_) => MethodScope::Static, + Expression::Self_(_) => MethodScope::Self_, + _ => return None, + }; + let ClassLikeMemberSelector::Identifier(name) = &call.method else { + return None; + }; + is_base_field_method(name.value).then(|| BaseFieldInclude { + scope, + method: crate::atom::atom_bytes(name.value), + }) + }) + .collect(); + ctx.drupal_base_field_methods.push(BaseFieldMethodSite { + owner, + method: crate::atom::atom_bytes(method.name.value), + fields, + includes, + removed, + }); + } +} + +/// The base field `NAME` keys to `VALUE`, when `VALUE` is a +/// `BaseFieldDefinition::create('type')` chain and `NAME` a string or an +/// entity type's key lookup. +fn base_field( + name: &Expression<'_>, + value: &Expression<'_>, + found: &BaseFieldSites<'_, '_>, + ctx: &ExtractionCtx<'_>, +) -> Option { + let (field_type, target) = base_field_creation(value, found, ctx)?; + let at = name.span().start.offset; + let bundle_condition = found.bundle_branches.iter().find_map(|(then, otherwise)| { + if then.has_offset(at) { + Some(true) + } else { + otherwise + .filter(|otherwise| otherwise.has_offset(at)) + .map(|_| false) + } + }); + let (name, start, end) = base_field_name(name, ctx)?; + Some(BaseFieldSite { + name, + field_type, + target, + bundle_condition, + start, + end, + }) +} + +/// The base field `NAME` names, a string or an entity type's key lookup, +/// with its byte range. +fn base_field_name( + name: &Expression<'_>, + ctx: &ExtractionCtx<'_>, +) -> Option<(BaseFieldName, u32, u32)> { + match name { + Expression::Call(Call::Method(call)) => { + let ClassLikeMemberSelector::Identifier(method) = &call.method else { + return None; + }; + let Some(Argument::Positional(argument)) = call.argument_list.arguments.first() else { + return None; + }; + let (key, _, _) = resolved_string_literal(argument.value, ctx.content)?; + let name = if method.value.eq_ignore_ascii_case(b"getKey") { + BaseFieldName::Key(key.to_string()) + } else if method.value.eq_ignore_ascii_case(b"getRevisionMetadataKey") { + BaseFieldName::RevisionMetadataKey(key.to_string()) + } else { + return None; + }; + let span = name_span(call); + Some((name, span.0, span.1)) + } + _ => { + let (literal, start, end) = resolved_string_literal(name, ctx.content)?; + Some(( + BaseFieldName::Literal(literal.to_string()), + start as u32, + end as u32, + )) + } + } +} + +/// The byte range of a key lookup call. +fn name_span(call: &MethodCall<'_>) -> (u32, u32) { + let span = call.span(); + (span.start.offset, span.end.offset) +} + +/// The field type a `BaseFieldDefinition::create('type')` chain creates a +/// field of, and the entity type the last `setSetting('target_type', …)` +/// of the chain names. +fn base_field_creation( + value: &Expression<'_>, + found: &BaseFieldSites<'_, '_>, + ctx: &ExtractionCtx<'_>, +) -> Option<(String, Option)> { + let mut target_type = None; + let mut current = value; + loop { + match current { + Expression::Parenthesized(inner) => current = inner.expression, + Expression::Call(Call::Method(call)) => { + // The outermost call runs last, so its setting wins. + if target_type.is_none() + && let ClassLikeMemberSelector::Identifier(method) = &call.method + && method.value.eq_ignore_ascii_case(b"setSetting") + && let [ + Argument::Positional(setting), + Argument::Positional(target), + .., + ] = call.argument_list.arguments.as_slice() + && resolved_string_literal(setting.value, ctx.content) + .is_some_and(|(setting, _, _)| setting == "target_type") + { + target_type = field_target(target.value, found, ctx); + } + current = call.object; + } + Expression::Call(Call::StaticMethod(call)) => { + let Expression::Identifier(class) = call.class else { + return None; + }; + let ClassLikeMemberSelector::Identifier(method) = &call.method else { + return None; + }; + let class = ctx + .resolved_name_at(class.span().start.offset) + .unwrap_or_else(|| bytes_to_str(class.value())); + if !method.value.eq_ignore_ascii_case(b"create") + || !class + .trim_start_matches('\\') + .eq_ignore_ascii_case(BASE_FIELD_DEFINITION) + { + return None; + } + let (field_type, _, _) = + crate::call_args::first_param_arg(&call.argument_list, "type").and_then( + |argument| resolved_string_literal(argument.value(), ctx.content), + )?; + return Some((field_type.to_string(), target_type)); + } + _ => return None, + } + } +} + +/// Record what a field item class declares about its field type's items: +/// the properties of its `propertyDefinitions()`, and the target its +/// `defaultStorageSettings()` gives (see [`crate::drupal::field_items`]). +/// `name_offset` is that of the class's name. +pub(in crate::symbol_map::extraction) fn emit_drupal_field_item_sites( + members: &Sequence<'_, ClassLikeMember<'_>>, + name_offset: u32, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal + || !(ctx.content.contains("propertyDefinitions") + || ctx.content.contains("defaultStorageSettings")) + { + return; + } + let method = |name: &[u8]| { + members.iter().find_map(|member| match member { + ClassLikeMember::Method(method) if method.name.value.eq_ignore_ascii_case(name) => { + Some(method) + } + _ => None, + }) + }; + let property_definitions = + method(b"propertyDefinitions").map(|method| property_definitions(method, ctx)); + let default_target = + method(b"defaultStorageSettings").and_then(|method| default_target(method, ctx)); + if property_definitions.is_none() && default_target.is_none() { + return; + } + let Some(owner) = ctx + .resolved_name_at(name_offset) + .map(|fqn| crate::atom::atom(fqn.trim_start_matches('\\'))) + else { + return; + }; + ctx.drupal_field_items.push(FieldItemSite { + owner, + property_definitions, + default_target, + }); +} + +/// The properties a `propertyDefinitions()` method declares. +fn property_definitions(method: &Method<'_>, ctx: &ExtractionCtx<'_>) -> PropertyDefinitionsSite { + let mut found = BaseFieldSites::default(); + mago_syntax::walker::Walker::walk_method(&BaseFieldWalker, method, &mut found); + // The keys of the array the method returns. + let returned: Vec<&[u8]> = found + .returned + .iter() + .filter_map(|value| match value { + Expression::Variable(Variable::Direct(variable)) => Some(variable.name), + _ => None, + }) + .collect(); + let is_returned = |array: &Expression<'_>| { + matches!(array, Expression::Variable(Variable::Direct(variable)) + if returned.contains(&variable.name)) + }; + let literal_key = |key: &Expression<'_>| { + resolved_string_literal(key, ctx.content).map(|(key, _, _)| key.to_string()) + }; + let properties = found + .keyed + .iter() + .filter(|keyed| keyed.assigned.is_some_and(is_returned)) + .filter_map(|keyed| { + Some(ItemPropertySite { + name: literal_key(keyed.key)?, + data_type: created_data_type(keyed.value, ctx), + }) + }) + .collect(); + let removed = found + .unset + .iter() + .filter(|access| is_returned(access.array)) + .filter_map(|access| literal_key(access.index)) + .collect(); + let takes_parent = found.calls.iter().any(|call| { + matches!(call.class, Expression::Parent(_)) + && matches!(&call.method, ClassLikeMemberSelector::Identifier(name) + if name.value.eq_ignore_ascii_case(b"propertyDefinitions")) + }); + PropertyDefinitionsSite { + properties, + removed, + takes_parent, + } +} + +/// What a `defaultStorageSettings()` that returns an array literal, or +/// literals joined to `parent::defaultStorageSettings()` with `+`, says the +/// `target_type` setting is. `None` when it says nothing. +fn default_target(method: &Method<'_>, ctx: &ExtractionCtx<'_>) -> Option { + let MethodBody::Concrete(body) = &method.body else { + return None; + }; + let returned = body + .statements + .iter() + .find_map(|statement| match statement { + Statement::Return(r#return) => r#return.value, + _ => None, + })?; + // `+` keeps the keys of its left operand, so the first one that writes + // the setting wins, the parent's included. + let mut operands = vec![returned]; + let mut from_parent = false; + while let Some(operand) = operands.pop() { + match operand { + Expression::Parenthesized(inner) => operands.push(inner.expression), + Expression::Binary(binary) + if matches!(binary.operator, BinaryOperator::Addition(_)) => + { + operands.push(binary.rhs); + operands.push(binary.lhs); + } + Expression::Call(Call::StaticMethod(call)) + if matches!(call.class, Expression::Parent(_)) + && matches!(&call.method, ClassLikeMemberSelector::Identifier(name) + if name.value.eq_ignore_ascii_case(b"defaultStorageSettings")) => + { + from_parent = true; + } + _ => { + // An operand that is no array literal, such as a constant, + // says nothing that can be read. + let Some(entries) = crate::parser::array_literal_elements(operand) else { + continue; + }; + for entry in entries.iter() { + if let ArrayElement::KeyValue(entry) = entry + && resolved_string_literal(entry.key, ctx.content) + .is_some_and(|(key, _, _)| key == "target_type") + { + let target = resolved_string_literal(entry.value, ctx.content) + .map(|(target, _, _)| target.to_string()); + return Some(if from_parent { + DefaultTarget::FromParent { + otherwise: Some(target), + } + } else { + DefaultTarget::Declared(target) + }); + } + } + } + } + } + from_parent.then_some(DefaultTarget::FromParent { otherwise: None }) +} + +/// The data type a data definition chain such as +/// `DataReferenceDefinition::create('entity')->setLabel(…)` creates. +fn created_data_type(value: &Expression<'_>, ctx: &ExtractionCtx<'_>) -> Option { + let mut current = value; + loop { + match current { + Expression::Parenthesized(inner) => current = inner.expression, + Expression::Call(Call::Method(call)) => current = call.object, + Expression::Call(Call::StaticMethod(call)) => { + let ClassLikeMemberSelector::Identifier(method) = &call.method else { + return None; + }; + if !method.value.eq_ignore_ascii_case(b"create") { + return None; + } + let argument = call.argument_list.arguments.first()?; + return resolved_string_literal(argument.value(), ctx.content) + .map(|(data_type, _, _)| data_type.to_string()); + } + _ => return None, + } + } +} + +/// The entity type the value of a `target_type` setting names: a string, or +/// the entity type's bundle entity type. +fn field_target( + value: &Expression<'_>, + found: &BaseFieldSites<'_, '_>, + ctx: &ExtractionCtx<'_>, +) -> Option { + let bundle_variable = matches!(value, Expression::Variable(Variable::Direct(variable)) + if found.bundle_variables.contains(&variable.name)); + if bundle_variable || reads_bundle_entity_type(value) { + return Some(FieldTarget::BundleEntityType); + } + resolved_string_literal(value, ctx.content) + .map(|(target, _, _)| FieldTarget::EntityType(target.to_string())) +} + +/// Whether `value` is a `->getBundleEntityType()` call. +fn reads_bundle_entity_type(value: &Expression<'_>) -> bool { + matches!(value, Expression::Call(Call::Method(call)) + if matches!(&call.method, ClassLikeMemberSelector::Identifier(method) + if method.value.eq_ignore_ascii_case(b"getBundleEntityType"))) +} + +/// The keyed values, the static calls, and the unset keys of a base field +/// or property definition method. +#[derive(Default)] +struct BaseFieldSites<'ast, 'arena> { + /// Each `$fields[KEY] = VALUE` and each `KEY => VALUE` array entry. + keyed: Vec>, + calls: Vec<&'ast StaticMethodCall<'arena>>, + /// The array access of each `unset($fields[KEY])`. + unset: Vec<&'ast ArrayAccess<'arena>>, + /// The value of each `return`. + returned: Vec<&'ast Expression<'arena>>, + /// The variables assigned `$entity_type->getBundleEntityType()`. + bundle_variables: Vec<&'arena [u8]>, + /// The branches of each `if` that tests the entity type's bundle entity + /// type: the one taken when it has one, and the `else` taken when not. + bundle_branches: Vec<(mago_span::Span, Option)>, +} + +struct Keyed<'ast, 'arena> { + key: &'ast Expression<'arena>, + value: &'ast Expression<'arena>, + /// The variable a `$fields[KEY] = VALUE` assigns to; `None` for an + /// array entry. + assigned: Option<&'ast Expression<'arena>>, +} + +struct BaseFieldWalker; + +impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BaseFieldSites<'ast, 'arena>> + for BaseFieldWalker +{ + fn walk_in_assignment( + &self, + assignment: &'ast Assignment<'arena>, + found: &mut BaseFieldSites<'ast, 'arena>, + ) { + if let (AssignmentOperator::Assign(_), Expression::Variable(Variable::Direct(variable))) = + (&assignment.operator, assignment.lhs) + && reads_bundle_entity_type(assignment.rhs) + { + found.bundle_variables.push(variable.name); + } + if let (AssignmentOperator::Assign(_), Expression::ArrayAccess(access)) = + (&assignment.operator, assignment.lhs) + { + found.keyed.push(Keyed { + key: access.index, + value: assignment.rhs, + assigned: Some(access.array), + }); + } + } + + fn walk_in_if(&self, r#if: &'ast If<'arena>, found: &mut BaseFieldSites<'ast, 'arena>) { + let mut condition = r#if.condition; + while let Expression::Parenthesized(inner) = condition { + condition = inner.expression; + } + let tests_bundle = match condition { + Expression::Assignment(assignment) => reads_bundle_entity_type(assignment.rhs), + other => reads_bundle_entity_type(other), + }; + if tests_bundle + && let IfBody::Statement(body) = &r#if.body + && body.else_if_clauses.is_empty() + { + found.bundle_branches.push(( + body.statement.span(), + body.else_clause + .as_ref() + .map(|clause| clause.statement.span()), + )); + } + } + + fn walk_in_return( + &self, + r#return: &'ast Return<'arena>, + found: &mut BaseFieldSites<'ast, 'arena>, + ) { + found.returned.extend(r#return.value); + } + + fn walk_in_unset(&self, unset: &'ast Unset<'arena>, found: &mut BaseFieldSites<'ast, 'arena>) { + for value in unset.values.iter() { + if let Expression::ArrayAccess(access) = value { + found.unset.push(access); + } + } + } + + fn walk_in_array(&self, array: &'ast Array<'arena>, found: &mut BaseFieldSites<'ast, 'arena>) { + push_base_field_entries(array.elements.iter(), found); + } + + fn walk_in_legacy_array( + &self, + array: &'ast LegacyArray<'arena>, + found: &mut BaseFieldSites<'ast, 'arena>, + ) { + push_base_field_entries(array.elements.iter(), found); + } + + fn walk_in_static_method_call( + &self, + call: &'ast StaticMethodCall<'arena>, + found: &mut BaseFieldSites<'ast, 'arena>, + ) { + found.calls.push(call); + } +} + +fn push_base_field_entries<'ast, 'arena>( + elements: impl Iterator>, + found: &mut BaseFieldSites<'ast, 'arena>, +) where + 'arena: 'ast, +{ + for element in elements { + if let ArrayElement::KeyValue(entry) = element { + found.keyed.push(Keyed { + key: entry.key, + value: entry.value, + assigned: None, + }); + } + } +} + /// The calls, assignments, and array entries of a route builder's methods /// that may add a route. #[derive(Default)] @@ -2188,11 +2838,12 @@ fn push_config_key_span( }); } -/// Record the key an editable config object's `set()` or `clear()` reads, -/// for the type engine to confirm against its receiver, whose type carries -/// the object's name. Its `get()` is recorded as a container lookup's, -/// which the config object is one of the targets of. -pub(in crate::symbol_map::extraction) fn record_drupal_config_key_sites( +/// Record the field name a call of a fieldable entity's method that takes +/// one names (see [`crate::drupal::fields::field_name_call`]), for the type +/// engine to confirm against its receiver. A `get()` and a `set()` are a +/// container lookup's and a configurator's too, which their own sites +/// read. +pub(in crate::symbol_map::extraction) fn record_drupal_field_name_sites( member_name: &str, argument_list: &ArgumentList<'_>, ctx: &mut ExtractionCtx<'_>, @@ -2200,17 +2851,54 @@ pub(in crate::symbol_map::extraction) fn record_drupal_config_key_sites( if !ctx.drupal { return; } - if !member_name.eq_ignore_ascii_case("set") && !member_name.eq_ignore_ascii_case("clear") { + let Some(parameter) = crate::drupal::fields::field_name_call(member_name) else { return; - } - let Some(Argument::Positional(key)) = argument_list.arguments.first() else { + }; + let Some(name) = argument_at(argument_list, 0, parameter.as_bytes()) else { return; }; - if let Some((key, start, end)) = - resolved_string_literal(key.value, ctx.content).filter(|(key, _, _)| !key.is_empty()) - { - ctx.typed_receiver_sites.push(TypedReceiverSite { - start: start as u32, + // A field's name never has a dot, which the service IDs and + // configuration names other receivers' `get()` takes mostly have. + let Some((name, start, end)) = resolved_string_literal(name, ctx.content) + .filter(|(name, _, _)| !name.is_empty() && !name.contains('.')) + else { + return; + }; + ctx.typed_receiver_sites.push(TypedReceiverSite { + start: start as u32, + end: end as u32, + key: name.to_string(), + is_write: false, + // `hasField()` asks whether the entity has it. + is_optional: member_name.eq_ignore_ascii_case("hasField"), + guards: 0, + receiver: ReceiverClass::FieldableEntity, + }); +} + +/// Record the key an editable config object's `set()` or `clear()` reads, +/// for the type engine to confirm against its receiver, whose type carries +/// the object's name. Its `get()` is recorded as a container lookup's, +/// which the config object is one of the targets of. +pub(in crate::symbol_map::extraction) fn record_drupal_config_key_sites( + member_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + if !member_name.eq_ignore_ascii_case("set") && !member_name.eq_ignore_ascii_case("clear") { + return; + } + let Some(Argument::Positional(key)) = argument_list.arguments.first() else { + return; + }; + if let Some((key, start, end)) = + resolved_string_literal(key.value, ctx.content).filter(|(key, _, _)| !key.is_empty()) + { + ctx.typed_receiver_sites.push(TypedReceiverSite { + start: start as u32, end: end as u32, key: key.to_string(), is_write: false, @@ -2492,38 +3180,42 @@ fn handler_class(handlers: &Expression<'_>, name: &str, ctx: &ExtractionCtx<'_>) } /// 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| { +/// docblock `doc`, which starts at `offset` in the file, declares, with the +/// ID's range in the file. +fn annotation_entity_type(doc: &str, offset: u32) -> Option { + let (body, kind) = ENTITY_TYPE_KINDS.iter().find_map(|kind| { let at = doc.find(kind.annotation)?; - Some((at + kind.annotation.len(), kind.default_storage)) + Some((at + kind.annotation.len(), kind)) })?; let mut id = None; - let mut storage = default_storage.map(str::to_string); + let mut declared = DeclaredEntityType::of_kind(kind); 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 value = &doc[entry.value_start..entry.value_end]; + match (entry.path.as_slice(), entry.key, entry.quoted) { + ([], "id", true) => id = Some((entry.value_start, entry.value_end)), + ([], "translatable", false) => { + declared.translatable = value.eq_ignore_ascii_case("true") } + ([], "bundle_entity_type", true) => { + declared.bundle_entity_type = Some(value.to_string()); + } + (["handlers"], "storage", true) => { + declared.storage = Some(value.trim_start_matches('\\').to_string()); + } + (["entity_keys"], key, true) => { + declared.keys.push((key.to_string(), value.to_string())) + } + (["revision_metadata_keys"], key, true) => declared + .revision_metadata_keys + .push((key.to_string(), value.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, - ) - }) + declared.id = doc[start..end].to_string(); + declared.start = offset + start as u32; + declared.end = offset + end as u32; + (!declared.id.is_empty()).then_some(declared) } /// The span of the permission the `admin_permission` of an entity type @@ -2535,7 +3227,7 @@ fn annotation_admin_permission(doc: &str, offset: u32) -> Option { .find_map(|kind| Some(doc.find(kind.annotation)? + kind.annotation.len()))?; let entry = annotation_entries(doc, body) .into_iter() - .find(|entry| entry.path.is_empty() && entry.key == "admin_permission")?; + .find(|entry| entry.path.is_empty() && entry.key == "admin_permission" && entry.quoted)?; let key = &doc[entry.value_start..entry.value_end]; (!key.is_empty()).then(|| SymbolSpan { start: offset + entry.value_start as u32, @@ -2556,6 +3248,8 @@ struct AnnotationEntry<'d> { key: &'d str, value_start: usize, value_end: usize, + /// The value is a string; else it is a bare word, such as `TRUE`. + quoted: bool, } /// Every `key = "value"` pair of the annotation whose arguments start at @@ -2584,6 +3278,7 @@ fn annotation_entries(doc: &str, from: usize) -> Vec> { key: name, value_start: start, value_end: end, + quoted: true, }); } assigned = false; @@ -2636,8 +3331,17 @@ fn annotation_entries(doc: &str, from: usize) -> Vec> { if !assigned { key = Some(&doc[start..i]); } else { - // A bare value (`TRUE`, a constant): nothing to read. - key = None; + // A bare value: `TRUE`, a constant, or the name of a + // nested annotation. + if let Some(name) = key.take() { + entries.push(AnnotationEntry { + path: path.clone(), + key: name, + value_start: start, + value_end: i, + quoted: false, + }); + } assigned = false; } continue; @@ -3151,6 +3855,115 @@ class SettingsForm { ); } + /// The field names a fieldable entity's methods take, recorded for the + /// type engine with the name alone, written positionally or by name; + /// a name built at runtime is none, and so is a dotted service ID or + /// configuration name, which no field is named. + #[test] + fn field_names_are_left_for_the_type_engine() { + let php = r#"get('title'); + $node->set('body', 'Example'); + $node->hasField('field_tags'); + $node->getFieldDefinition(name: 'uid'); + $node->get($name); + $node->get(''); + $node->get('acme.settings'); + $node->set('acme.mode', 'Example'); + $node->other('nid'); + } +} +"#; + let sites_in = |drupal: bool| -> Vec<(String, bool)> { + 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: drupal, + drupal, + twig: false, + doctrine: false, + yii_params: false, + tempest: false, + }, + None, + ); + map.typed_receiver_sites + .iter() + .filter(|site| site.receiver == ReceiverClass::FieldableEntity) + .map(|site| { + assert_eq!(&php[site.start as usize..site.end as usize], site.key); + assert!(!site.is_write, "{}", site.key); + (site.key.clone(), site.is_optional) + }) + .collect() + }; + assert_eq!( + sites_in(true), + [ + ("title".to_string(), false), + ("body".to_string(), false), + ("field_tags".to_string(), true), + ("uid".to_string(), false), + ] + ); + assert!(sites_in(false).is_empty()); + } + + /// Each method that takes a field name reaches fieldable entities, and + /// the field name site stands beside what the other tables read the + /// call as, so neither shadows the other. + #[test] + fn every_field_name_method_reaches_fieldable_entities() { + for (method, other) in [ + ("get", Some(ReceiverClass::ContainerLookup)), + ("set", Some(ReceiverClass::ConfiguratorSet)), + ("hasField", None), + ("getFieldDefinition", None), + ] { + assert!( + crate::drupal::fields::field_name_call(method).is_some(), + "{method}" + ); + assert!( + ReceiverClass::FieldableEntity.targets().contains(&( + crate::drupal::fields::FIELDABLE_ENTITY, + StringKeyKind::DrupalFieldName + )), + "{method}" + ); + assert_eq!( + crate::symbol_map::extraction::symfony_receiver_method(method, 0) + .map(|(receiver, _)| receiver), + other, + "{method}" + ); + for (receiver, _) in other.iter().flat_map(|receiver| receiver.targets()) { + assert_ne!( + *receiver, + crate::drupal::fields::FIELDABLE_ENTITY, + "{method}" + ); + } + assert!(hook_invocation(method, 0).is_none(), "{method}"); + assert!( + crate::drupal::route_parameter_call(method).is_none(), + "{method}" + ); + } + assert!(crate::drupal::fields::field_name_call("getFields").is_none()); + } + /// Each permission a requirement, a list, or an entity type names is a /// span over its own text. #[test] @@ -3741,6 +4554,535 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { ))); } + /// The entity type declarations `php` makes. + fn entity_type_sites(php: &str) -> Vec { + drupal_index_map(php).entity_types + } + + /// The symbol map the workspace index makes of `php` in a Drupal + /// project. + fn drupal_index_map(php: &str) -> crate::symbol_map::SymbolMap { + 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)); + 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, + doctrine: false, + yii_params: false, + tempest: false, + }, + None, + ) + } + + /// A base field's name as written, the text the name spans, its field + /// type, and its target. + type ReadField = (BaseFieldName, String, String, Option); + + /// Each base field method of `php`: its class or trait, its name, the + /// fields it creates, and the methods it calls. + fn base_fields(php: &str) -> Vec<(String, String, Vec, Vec)> { + drupal_index_map(php) + .drupal_fields + .map(|fields| fields.base_field_methods) + .unwrap_or_default() + .into_iter() + .map(|site| { + let fields = site + .fields + .iter() + .map(|field| { + ( + field.name.clone(), + php[field.start as usize..field.end as usize].to_string(), + field.field_type.clone(), + field.target.clone(), + ) + }) + .collect(); + ( + site.owner.to_string(), + site.method.to_string(), + fields, + site.includes, + ) + }) + .collect() + } + + #[test] + fn property_definitions_keep_their_properties_and_parent() { + let php = r#"setLabel(new Label('@label', ['@label' => 'ID'])); + $properties['target_id'] = $target; + $properties['entity'] = DataReferenceDefinition::create('entity') + ->setLabel('Picture') + ->setComputed(TRUE); + $properties['alt'] = (DataDefinition::create('string')); + $settings['ignored'] = 'x'; + $properties['nested']['ignored'] = DataDefinition::create('string'); + return $properties; + } + public function label() { + $properties['ignored'] = DataDefinition::create('string'); + return $properties; + } +} +class PlainItem { + public static function propertyDefinitions(FieldStorageDefinitionInterface $definition) { + return []; + } +} +"#; + let property = |name: &str, data_type: Option<&str>| ItemPropertySite { + name: name.to_string(), + data_type: data_type.map(str::to_string), + }; + assert_eq!( + drupal_index_map(php) + .drupal_fields + .map(|fields| fields.field_items) + .unwrap_or_default(), + [ + FieldItemSite { + owner: crate::atom::atom("Drupal\\acme\\Plugin\\Field\\FieldType\\PictureItem"), + property_definitions: Some(PropertyDefinitionsSite { + properties: vec![ + property("target_id", None), + property("entity", Some("entity")), + property("alt", Some("string")), + ], + removed: vec!["display".to_string(), "description".to_string()], + takes_parent: true, + }), + default_target: None, + }, + FieldItemSite { + owner: crate::atom::atom("Drupal\\acme\\Plugin\\Field\\FieldType\\PlainItem"), + property_definitions: Some(PropertyDefinitionsSite { + properties: Vec::new(), + removed: Vec::new(), + takes_parent: false, + }), + default_target: None, + }, + ] + ); + } + + #[test] + fn default_storage_settings_keep_their_target() { + let php = r#" 'file', + 'display_field' => FALSE, + ] + parent::defaultStorageSettings(); + } +} +class PictureItem extends AttachmentItem { + public static function defaultStorageSettings() { + return ['default_image' => ['alt' => '']] + parent::defaultStorageSettings(); + } +} +class ReferenceItem { + public static function defaultStorageSettings() { + return [ + 'target_type' => \Drupal::moduleHandler()->moduleExists('node') ? 'node' : 'user', + ] + parent::defaultStorageSettings(); + } +} +class PlainItem { + public static function defaultStorageSettings() { + return ['max_length' => 255]; + } +} +class MediaItem extends PlainItem { + public static function defaultStorageSettings() { + return parent::defaultStorageSettings() + ['target_type' => 'media']; + } +} +class NodeItem { + const SETTINGS = ['max_length' => 255]; + public static function defaultStorageSettings() { + return static::SETTINGS + ['target_type' => 'node']; + } +} +"#; + let targets: Vec<(String, Option)> = drupal_index_map(php) + .drupal_fields + .map(|fields| fields.field_items) + .unwrap_or_default() + .into_iter() + .map(|site| (site.owner.to_string(), site.default_target)) + .collect(); + let class = |name: &str| format!("Drupal\\acme\\Plugin\\Field\\FieldType\\{name}"); + assert_eq!( + targets, + [ + ( + class("AttachmentItem"), + Some(DefaultTarget::Declared(Some("file".to_string()))) + ), + ( + class("PictureItem"), + Some(DefaultTarget::FromParent { otherwise: None }) + ), + (class("ReferenceItem"), Some(DefaultTarget::Declared(None))), + ( + class("MediaItem"), + Some(DefaultTarget::FromParent { + otherwise: Some(Some("media".to_string())) + }) + ), + ( + class("NodeItem"), + Some(DefaultTarget::Declared(Some("node".to_string()))) + ), + ] + ); + } + + #[test] + fn base_fields_follow_the_bundle_entity_type() { + let php = r#"hasKey('bundle')) { + if ($bundle_entity_type_id = $entity_type->getBundleEntityType()) { + $fields[$entity_type->getKey('bundle')] = BaseFieldDefinition::create('entity_reference') + ->setSetting('target_type', $bundle_entity_type_id); + } + else { + $fields[$entity_type->getKey('bundle')] = BaseFieldDefinition::create('string'); + } + } + $fields['kind'] = BaseFieldDefinition::create('entity_reference') + ->setSetting('target_type', $entity_type->getBundleEntityType()); + return $fields; + } +} +"#; + let fields: Vec<(String, Option, Option)> = drupal_index_map(php) + .drupal_fields + .map(|fields| fields.base_field_methods) + .unwrap_or_default() + .into_iter() + .flat_map(|site| site.fields) + .map(|field| (field.field_type, field.target, field.bundle_condition)) + .collect(); + assert_eq!( + fields, + [ + ( + "entity_reference".to_string(), + Some(FieldTarget::BundleEntityType), + Some(true) + ), + ("string".to_string(), None, Some(false)), + ( + "entity_reference".to_string(), + Some(FieldTarget::BundleEntityType), + None + ), + ] + ); + } + + #[test] + fn entity_types_keep_their_bundle_entity_type() { + let attribute = r#"setLabel('Title') + ->setRequired(TRUE); + $fields[$entity_type->getKey('bundle')] = BaseFieldDefinition::create('entity_reference') + ->setSetting('target_type', 'acme_note_type'); + $fields[$entity_type->getRevisionMetadataKey('revision_user')] = BaseFieldDefinition::create('entity_reference'); + $fields['title']->setDisplayOptions('form', ['weight' => 1]); + $fields[$name] = BaseFieldDefinition::create('string'); + $fields['other'] = \Other\BaseFieldDefinition::create('string'); + $fields['no_type'] = BaseFieldDefinition::create($type); + return $fields; + } + public function label() { + $fields['ignored'] = BaseFieldDefinition::create('string'); + } +} +trait OwnerTrait { + public static function ownerBaseFieldDefinitions(EntityTypeInterface $entity_type) { + return [ + $entity_type->getKey('owner') => BaseFieldDefinition::create('entity_reference') + ->setSetting('target_type', 'user') + ->setLabel('Owner'), + ]; + } +} +class Page extends Note { + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + return parent::baseFieldDefinitions($entity_type) + self::publishedBaseFieldDefinitions($entity_type); + } +} +"#; + let key = |name: &str| BaseFieldName::Key(name.to_string()); + let include = |scope, method: &str| BaseFieldInclude { + scope, + method: crate::atom::atom(method), + }; + assert_eq!( + base_fields(php), + [ + ( + "Drupal\\acme\\Entity\\Note".to_string(), + "baseFieldDefinitions".to_string(), + vec![ + ( + BaseFieldName::Literal("title".to_string()), + "title".to_string(), + "string".to_string(), + None + ), + ( + key("bundle"), + "$entity_type->getKey('bundle')".to_string(), + "entity_reference".to_string(), + Some(FieldTarget::EntityType("acme_note_type".to_string())) + ), + ( + BaseFieldName::RevisionMetadataKey("revision_user".to_string()), + "$entity_type->getRevisionMetadataKey('revision_user')".to_string(), + "entity_reference".to_string(), + None + ), + ], + vec![ + include(MethodScope::Parent, "baseFieldDefinitions"), + include(MethodScope::Static, "ownerBaseFieldDefinitions"), + ], + ), + ( + "Drupal\\acme\\Entity\\OwnerTrait".to_string(), + "ownerBaseFieldDefinitions".to_string(), + vec![( + key("owner"), + "$entity_type->getKey('owner')".to_string(), + "entity_reference".to_string(), + Some(FieldTarget::EntityType("user".to_string())) + )], + vec![], + ), + ( + "Drupal\\acme\\Entity\\Page".to_string(), + "baseFieldDefinitions".to_string(), + vec![], + vec![ + include(MethodScope::Parent, "baseFieldDefinitions"), + include(MethodScope::Self_, "publishedBaseFieldDefinitions"), + ], + ), + ] + ); + } + + #[test] + fn a_field_type_keeps_its_list_class() { + let php = r#")> = drupal_index_map(php) + .drupal_plugins + .iter() + .map(|site| { + ( + site.id.clone(), + site.list_class.map(|class| class.to_string()), + ) + }) + .collect(); + assert_eq!( + found, + [ + ( + "acme_listed".to_string(), + Some("Drupal\\acme\\ExampleList".to_string()) + ), + ("acme_plain".to_string(), None), + ( + "acme_legacy".to_string(), + Some("Drupal\\acme\\LegacyList".to_string()) + ), + ] + ); + } + + fn pairs(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + } + + #[test] + fn an_attribute_keeps_the_keys_and_translatability() { + let php = r#" 'nid', 'owner' => 'uid', 'bundle' => ''], + revision_metadata_keys: ['revision_user' => 'revision_uid'], + translatable: TRUE, +)] +class Note {} +"#; + let sites = entity_type_sites(php); + assert_eq!(sites.len(), 1); + let site = &sites[0]; + assert_eq!( + site.keys, + pairs(&[("id", "nid"), ("owner", "uid"), ("bundle", "")]) + ); + assert_eq!( + site.revision_metadata_keys, + pairs(&[("revision_user", "revision_uid")]) + ); + assert!(site.translatable); + assert!(site.content); + } + + #[test] + fn an_annotation_keeps_the_keys_and_translatability() { + let php = r#" 'id'])] +class Type {} +#[ContentEntityType(id: 'acme_log', translatable: FALSE)] +class Log {} +/** + * @ContentEntityType(id = "acme_entry", translatable = FALSE) + */ +class Entry {} +"#; + let sites = entity_type_sites(php); + assert_eq!(sites.len(), 3); + assert!(sites.iter().all(|site| !site.translatable)); + assert_eq!( + sites.iter().map(|site| site.content).collect::>(), + [false, true, true] + ); + assert_eq!(sites[0].keys, pairs(&[("id", "id")])); + } + #[test] fn an_attribute_declares_the_id_class_and_storage() { let php = " NodeStorage::class, 'form' => ['default' => 'X']],\n)]\nclass Node {}\n"; @@ -3783,7 +5125,13 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { #[test] fn annotations_give_the_id_and_the_storage_handler() { let doc = "/**\n * @ContentEntityType(\n * id = \"redirect\",\n * label = @Translation(\"Redirect\"),\n * handlers = {\n * \"list_builder\" = \"Drupal\\redirect\\RedirectListBuilder\",\n * \"storage\" = \"\\Drupal\\redirect\\RedirectStorage\",\n * \"form\" = {\n * \"id\" = \"nested\"\n * }\n * },\n * entity_keys = {\n * \"id\" = \"rid\"\n * }\n * )\n */"; - let (id, start, end, storage) = annotation_entity_type(doc, 100).unwrap(); + let DeclaredEntityType { + id, + start, + end, + storage, + .. + } = annotation_entity_type(doc, 100).unwrap(); assert_eq!(id, "redirect"); assert_eq!( &doc[(start - 100) as usize..(end - 100) as usize], @@ -3798,7 +5146,7 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { #[test] fn an_annotation_without_handlers_gets_its_kinds_storage() { let doc = "/**\n * @ConfigEntityType(id = \"flag\", label = @Translation(\"Flag\"))\n */"; - let (id, _, _, storage) = annotation_entity_type(doc, 0).unwrap(); + let DeclaredEntityType { id, storage, .. } = annotation_entity_type(doc, 0).unwrap(); assert_eq!(id, "flag"); assert_eq!(storage.as_deref(), Some(CONFIG_STORAGE)); } @@ -3806,7 +5154,7 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { #[test] fn a_nested_annotations_pairs_are_its_own() { let doc = "/**\n * @ContentEntityType(\n * label = @Translation(\"Note\", id = \"other\"),\n * id = \"note\",\n * extra = @Bar(handlers = {\"storage\" = \"Wrong\"}),\n * )\n */"; - let (id, _, _, storage) = annotation_entity_type(doc, 0).unwrap(); + let DeclaredEntityType { id, storage, .. } = annotation_entity_type(doc, 0).unwrap(); assert_eq!(id, "note"); assert_eq!(storage.as_deref(), Some(CONTENT_STORAGE)); } diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index a258acab3..7f5b07506 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -516,6 +516,7 @@ fn extract_method_call<'a>( emit_drupal_requirement_permission_spans(&member_name, argument_list, ctx); record_drupal_config_list_sites(&member_name, argument_list, ctx); record_drupal_config_key_sites(&member_name, argument_list, ctx); + record_drupal_field_name_sites(&member_name, argument_list, ctx); record_drupal_plugin_sites(&member_name, argument_list, ctx); record_yii_view_sites(object, &member_name, argument_list, ctx); record_yii_css_class_sites(&member_name, argument_list, ctx); diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 3dce6caa2..bc494c3e9 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -112,6 +112,8 @@ struct ExtractionCtx<'a> { service_decorations: Vec, /// The entity types the file's entity classes declare. entity_types: Vec, + drupal_base_field_methods: Vec, + drupal_field_items: Vec, /// The classes the file's Drupal plugin declarations are written on. drupal_plugin_classes: Vec, drupal_plugin_managers: Vec, @@ -627,6 +629,8 @@ fn extract_symbol_map_inner( container_values: Vec::new(), service_decorations: Vec::new(), entity_types: Vec::new(), + drupal_base_field_methods: Vec::new(), + drupal_field_items: Vec::new(), drupal_plugin_classes: Vec::new(), drupal_plugin_managers: Vec::new(), drupal_plugins: Vec::new(), @@ -870,6 +874,12 @@ fn extract_symbol_map_inner( container_values: ctx.container_values, service_decorations: ctx.service_decorations, entity_types: ctx.entity_types, + drupal_fields: crate::symbol_map::DrupalFieldSites { + base_field_methods: ctx.drupal_base_field_methods, + field_items: ctx.drupal_field_items, + storage: None, + } + .boxed(), drupal_plugin_classes: ctx.drupal_plugin_classes, drupal_plugin_managers: ctx.drupal_plugin_managers, drupal_plugins: ctx.drupal_plugins, diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index 67ff95453..caa183a25 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -317,6 +317,9 @@ 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, + /// What the file declares about Drupal entity fields. Boxed, since + /// nearly every file declares nothing. + pub drupal_fields: Option>, /// The classes the file's Drupal plugin declarations are written on, /// sorted by `start`. Empty for every file that declares none. pub drupal_plugin_classes: Vec, @@ -423,6 +426,30 @@ pub(crate) struct SymbolMap { pub source_hash: Option, } +/// What a file declares about Drupal entity fields (see +/// [`crate::drupal::base_fields`]). +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct DrupalFieldSites { + /// The methods of the file's classes and traits that declare base + /// fields. + pub base_field_methods: Vec, + /// What the file's field item classes declare about their items. + pub field_items: Vec, + /// The configurable field the file declares, when it is a field storage + /// file. + pub storage: Option, +} + +impl DrupalFieldSites { + /// The sites, unless there are none. + pub(crate) fn boxed(self) -> Option> { + (!self.base_field_methods.is_empty() + || !self.field_items.is_empty() + || self.storage.is_some()) + .then(|| Box::new(self)) + } +} + impl SymbolMap { /// Whether this map's offsets are valid indices into `content`. /// diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index cce748019..f95fc4cb2 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -292,6 +292,13 @@ pub(crate) enum StringKeyKind { /// `BaseFieldDefinition::create()`, the `type` of a field's display /// options, and `addConstraint()`. DrupalPlugin, + /// A field of a Drupal content entity, keyed by the entity type as well + /// as the name (see `drupal::fields::field_key`): `title` is node's in + /// one place and another entity type's elsewhere. Declared by an + /// entity class's base field definitions and by field storage files, + /// and read by a fieldable entity's `get()`, `set()`, `hasField()`, and + /// `getFieldDefinition()`. + DrupalFieldName, } impl StringKeyKind { @@ -366,7 +373,8 @@ impl StringKeyKind { | Self::DrupalThemeHook | Self::DrupalThemeHookVariable | Self::DrupalLibrary - | Self::DrupalPlugin => KeyFramework::Drupal, + | Self::DrupalPlugin + | Self::DrupalFieldName => KeyFramework::Drupal, } } @@ -853,6 +861,180 @@ pub(crate) struct EntityTypeSite { /// of its kind of entity type; `None` when it names none and has no /// default. pub storage: Option, + /// The `entity_keys` the declaration writes, as written: an empty field + /// name is a key the entity type does not have. + pub keys: Vec<(String, String)>, + /// The `revision_metadata_keys` the declaration writes, as written. + pub revision_metadata_keys: Vec<(String, String)>, + /// The declaration's `translatable` is true. + pub translatable: bool, + /// The declaration is a content entity type's. + pub content: bool, + /// The entity type its `bundle_entity_type` names, whose entities are + /// its bundles. + pub bundle_entity_type: Option, +} + +/// A method that declares the base fields of Drupal content entities: an +/// entity class's `baseFieldDefinitions()`, or a helper such as a trait's +/// `ownerBaseFieldDefinitions()`, whose result joins them. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct BaseFieldMethodSite { + /// The class or trait that declares the method. + pub owner: crate::atom::Atom, + /// The method's name, as declared. + pub method: crate::atom::Atom, + /// Each `$fields[NAME] = BaseFieldDefinition::create('type')` and each + /// `NAME => BaseFieldDefinition::create('type')` array entry, in source + /// order. + pub fields: Vec, + /// The other base field methods it calls on `parent::`, `static::`, or + /// `self::`, in source order. + pub includes: Vec, + /// The fields it unsets from what it takes from those. + pub removed: Vec, +} + +impl BaseFieldMethodSite { + /// Whether the two declare the same fields, wherever they are written. + pub(crate) fn declares_as(&self, other: &Self) -> bool { + self.owner == other.owner + && self.method == other.method + && self.includes == other.includes + && self.removed == other.removed + && self.fields.len() == other.fields.len() + && self.fields.iter().zip(&other.fields).all(|(a, b)| { + a.name == b.name + && a.field_type == b.field_type + && a.target == b.target + && a.bundle_condition == b.bundle_condition + }) + } +} + +/// A base field a [`BaseFieldMethodSite`] creates. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct BaseFieldSite { + pub name: BaseFieldName, + /// The field type `create()` names. + pub field_type: String, + /// The entity type a `setSetting('target_type', …)` on it names. + pub target: Option, + /// Whether it is created only when the entity type has a bundle entity + /// type (`true`) or only when it has none (`false`), as the branches + /// of an `if ($entity_type->getBundleEntityType())` decide. + pub bundle_condition: Option, + /// Byte range of the name: a string's contents, or the key lookup. + pub start: u32, + pub end: u32, +} + +/// The entity type a base field points to. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum FieldTarget { + EntityType(String), + /// `$entity_type->getBundleEntityType()`: the entity type's own + /// `bundle_entity_type`. + BundleEntityType, +} + +/// What a Drupal field storage file, `field.storage...yml`, +/// declares: a configurable field of an entity type. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct FieldStorageSite { + pub entity_type: String, + pub field_name: String, + /// The field type, its `type`. + pub field_type: String, + /// The entity type its `settings.target_type` names. + pub target_type: Option, + /// Byte range of the `field_name` value. + pub start: u32, + pub end: u32, +} + +impl FieldStorageSite { + /// Whether the two declare the same field, wherever it is written. + pub(crate) fn declares_as(&self, other: &Self) -> bool { + self.entity_type == other.entity_type + && self.field_name == other.field_name + && self.field_type == other.field_type + && self.target_type == other.target_type + } +} + +/// The name of a base field, as written. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum BaseFieldName { + Literal(String), + /// `$entity_type->getKey('key')`: the field the entity type names for + /// the entity key. + Key(String), + /// `$entity_type->getRevisionMetadataKey('key')`. + RevisionMetadataKey(String), +} + +/// A base field method another one calls, whose fields join its own. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct BaseFieldInclude { + pub scope: MethodScope, + pub method: crate::atom::Atom, +} + +/// The class a `parent::`, `static::`, or `self::` call is made on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MethodScope { + Parent, + Static, + Self_, +} + +/// What a Drupal field item class declares about its field type's items +/// in `propertyDefinitions()` and `defaultStorageSettings()`. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct FieldItemSite { + /// The class that declares the methods. + pub owner: crate::atom::Atom, + /// What its `propertyDefinitions()` declares, when it declares one. + pub property_definitions: Option, + /// What its `defaultStorageSettings()` says the `target_type` setting + /// is, when it declares one that says. + pub default_target: Option, +} + +/// The properties a field item class's `propertyDefinitions()` declares. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PropertyDefinitionsSite { + /// Each `$properties['name'] = …`, in source order. + pub properties: Vec, + /// Each `unset($properties['name'])`. + pub removed: Vec, + /// Whether it takes the properties `parent::propertyDefinitions()` + /// gives. + pub takes_parent: bool, +} + +/// What a field item class's `defaultStorageSettings()` says the +/// `target_type` setting is: the entity type a reference field of its +/// field type points to when the field sets none. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum DefaultTarget { + /// The setting it writes: the entity type, or `None` for a value that + /// is not a string literal. + Declared(Option), + /// It adds `parent::defaultStorageSettings()` before it writes any, so + /// the parent's setting wins, as `+` keeps its left operand's keys: + /// `otherwise` is the one it writes after, if it writes one. + FromParent { otherwise: Option> }, +} + +/// A property a [`PropertyDefinitionsSite`] declares. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ItemPropertySite { + pub name: String, + /// The data type the definition's `create('type')` names, such as + /// `entity` for an entity reference. + pub data_type: Option, } /// The class a Drupal plugin declaration is written on, such as a render @@ -930,6 +1112,8 @@ pub(crate) struct PluginDeclarationSite { /// attribute. pub doc_start: u32, pub doc_end: u32, + /// The `list_class` the marker names, a field type's item list class. + pub list_class: Option, } /// The value a container declaration gives, read by the kind of name it @@ -1437,6 +1621,11 @@ pub(crate) enum ReceiverClass { /// one of its keys; an `ImmutableConfig` throws on both. Its `get()` /// is a [`Self::ContainerLookup`], as every config object's is. EditableConfigKeys, + /// A fieldable Drupal entity, whose `get()`, `set()`, `hasField()`, and + /// `getFieldDefinition()` take one of its fields' names (see + /// `drupal::fields::field_name_call`). The entity types the name is + /// one of come from the receiver's type. + FieldableEntity, /// Symfony's serializer, normalizers, and denormalizers, whose /// `serialize()`, `deserialize()`, `normalize()`, and `denormalize()` /// take a context whose `groups` name serializer groups. @@ -1605,6 +1794,10 @@ impl ReceiverClass { (DRUPAL_CONTROLLER_BASE, StringKeyKind::DrupalConfigName), (DRUPAL_FORM_BASE, StringKeyKind::DrupalConfigName), ], + Self::FieldableEntity => &[( + crate::drupal::fields::FIELDABLE_ENTITY, + StringKeyKind::DrupalFieldName, + )], Self::Serializer => &[ (SYMFONY_SERIALIZER, StringKeyKind::SerializerGroup), (SYMFONY_NORMALIZER, StringKeyKind::SerializerGroup), @@ -1712,12 +1905,15 @@ impl TypedReceiverSite { /// Whether this site, once confirmed, could name `key` as a `kind`. /// The reference index files a site under each kind it could become. pub(crate) fn may_name(&self, kind: StringKeyKind, key: &str) -> bool { - // A configuration key's site holds the key alone; the object's - // name comes with its receiver's type. + // A configuration key's or a field name's site holds the key alone; + // the object's name or the entity type comes with its receiver's + // type. let key = if kind == StringKeyKind::DrupalConfigKey { crate::drupal::config_keys::split_config_key(key).1 } else if kind == StringKeyKind::DrupalPlugin && self.receiver.bare_plugin_key() { crate::drupal::plugins::split_plugin_key(key).1 + } else if kind == StringKeyKind::DrupalFieldName { + crate::drupal::fields::split_field_key(key).1 } else { key }; diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 50d71f237..5180bda17 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -241,6 +241,7 @@ impl Backend { self.note_drupal_library_builder_sources(uri, Some(map)); self.note_drupal_plugin_sources(uri, Some(map)); self.note_drupal_entity_type_test_sources(uri, Some(map)); + self.note_drupal_field_sources(uri, Some(map)); self.note_drupal_theme_suggestions(map); self.note_container_declarations(uri, declares.then_some(map)); let was_declaring = self.symfony.declaring_uris.read().contains(uri); @@ -258,12 +259,14 @@ impl Backend { /// Forget a file that no longer exists. pub(crate) fn forget_symfony_declarations(&self, uri: &str) { + let _fields = self.drupal_field_flush(); self.note_drupal_routes(uri, None); self.note_drupal_hook_declarations(uri, None); self.note_drupal_theme_hook_sources(uri, None); self.note_drupal_library_builder_sources(uri, None); self.note_drupal_plugin_sources(uri, None); self.note_drupal_entity_type_test_sources(uri, None); + self.note_drupal_field_sources(uri, None); self.note_container_declarations(uri, None); if self.symfony.declaring_uris.read().contains(uri) { self.symfony.declaring_uris.write().remove(uri); 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 a57e321f0..9b521bd13 100644 --- a/src/type_engine/call_resolution/return_types/call_arms.rs +++ b/src/type_engine/call_resolution/return_types/call_arms.rs @@ -170,6 +170,35 @@ impl Backend { } return classes; } + // A fieldable entity's `get('title')` reads the field `->title` does. + if let Some(backend) = in_drupal + && method_name.eq_ignore_ascii_case("get") + && let Some(name) = first_literal_text_arg(text_args, "field_name") + && let Some(ty) = lhs_resolved + .iter() + .filter_map(|resolved| resolved.class_info.as_deref()) + .find_map(|owner| { + crate::drupal::types::field_list_type( + backend, + owner, + method_name, + name, + ctx.file.class_loader, + ctx.resolved_class_cache, + ) + }) + { + let classes = crate::type_engine::type_resolution::type_hint_to_classes_typed_returned( + &ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ); + if let Some(ref mut hint_out) = return_type_hint_out { + **hint_out = Some(ty); + } + return classes; + } if method_name.eq_ignore_ascii_case("getStorage") && let Some(backend) = in_drupal && let Some(id) = first_literal_text_arg(text_args, "entity_type_id") diff --git a/src/type_engine/type_ops/resolution.rs b/src/type_engine/type_ops/resolution.rs index 5ba342e41..7420d05fd 100644 --- a/src/type_engine/type_ops/resolution.rs +++ b/src/type_engine/type_ops/resolution.rs @@ -537,21 +537,36 @@ fn resolve_named_type( // never bound (a `static` rebind on a // concrete collection subclass, a custom Eloquent builder // specialised to its model). - if !generic_args.is_empty() + let generic = !generic_args.is_empty() && (!cls.template_params.is_empty() || crate::inheritance::is_extends_only_generic_rebindable( &cls, generic_args.len(), class_loader, - )) - { + )); + // A Drupal field item list or item is asked about once. + let drupal = (!generic && !generic_args.is_empty()) + .then(|| { + crate::drupal::field_items::field_type_hint(&cls, generic_args, class_loader) + }) + .flatten(); + if generic || drupal.is_some() { let generic_arg_strings: Vec = generic_args.iter().map(|a| a.to_string()).collect(); - vec![virtual_members::resolve_class_for_type_hint( + vec![virtual_members::resolve_class_for_field_type_hint( &cls, class_loader, &generic_arg_strings, generic_args, + || { + drupal.or_else(|| { + crate::drupal::field_items::field_type_hint( + &cls, + generic_args, + class_loader, + ) + }) + }, )] } else { vec![cls] diff --git a/src/type_engine/variable/rhs_resolution/drupal_calls.rs b/src/type_engine/variable/rhs_resolution/drupal_calls.rs index 85a2d0f4c..dd84fb35f 100644 --- a/src/type_engine/variable/rhs_resolution/drupal_calls.rs +++ b/src/type_engine/variable/rhs_resolution/drupal_calls.rs @@ -131,6 +131,40 @@ pub(super) fn try_resolve_config_object_type( ) } +/// What a fieldable entity's `get()` returns for a literal field name: the +/// field, as reading it as a property gives it. +pub(super) fn try_resolve_field_list_type( + owner: &ClassInfo, + method_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &VarResolutionCtx<'_>, +) -> Option> { + if !method_name.eq_ignore_ascii_case("get") { + return None; + } + let backend = ctx + .file + .backend + .filter(|backend| backend.is_drupal_project())?; + let name = literal_first(argument_list, "field_name", ctx.file.content)?; + let ty = crate::drupal::types::field_list_type( + backend, + owner, + method_name, + name, + ctx.file.class_loader, + ctx.resolved_class_cache, + )?; + Some( + crate::type_engine::type_ops::resolution::resolved_types_for_hint( + ty, + "", + ctx.file.all_classes, + ctx.file.class_loader, + ), + ) +} + /// What a load on a tagged entity storage returns. pub(super) fn try_resolve_entity_load_type( method_name: &str, diff --git a/src/type_engine/variable/rhs_resolution/method_calls.rs b/src/type_engine/variable/rhs_resolution/method_calls.rs index b7d5f5b82..fcddba580 100644 --- a/src/type_engine/variable/rhs_resolution/method_calls.rs +++ b/src/type_engine/variable/rhs_resolution/method_calls.rs @@ -341,6 +341,14 @@ pub(super) fn resolve_member_call_on_receiver<'b>( ) { return result; } + if let Some(result) = super::drupal_calls::try_resolve_field_list_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/types/property.rs b/src/types/property.rs index cb71dadbb..adb36f64c 100644 --- a/src/types/property.rs +++ b/src/types/property.rs @@ -165,6 +165,18 @@ pub enum PropertySource { /// framework conventions, while still losing to types derived from /// real PHP code (casts, accessors, relationship methods). DocblockTag, + /// A field of a Drupal content entity. The entity reads and writes it + /// through its magic `__get()` and `__set()`: a write stores the value + /// into the field's item list, so the property keeps its type whatever + /// is written. Where it is declared moves with every edit above the + /// declaration, so it is looked up when asked (see + /// [`crate::drupal::fields`]). + DrupalField, + /// A property of a Drupal field item, which the item, and the item + /// list for its first item, reads and writes through its magic + /// `__get()` and `__set()`, so the property keeps its type whatever is + /// written (see [`crate::drupal::field_items`]). + DrupalItemProperty, } /// What a property's docblock says about writing to it. @@ -202,6 +214,15 @@ impl PropertyInfo { && self.source == other.source } + /// Whether a write to this property goes through the magic `__set()` + /// of a class that has one, rather than replacing the property's value. + pub fn writes_through_magic_set(&self) -> bool { + matches!( + self.source, + Some(PropertySource::DrupalField | PropertySource::DrupalItemProperty) + ) + } + /// Return the type hint as a string, if present. /// /// Convenience wrapper around `self.type_hint.as_ref().map(|t| t.to_string())`. diff --git a/src/virtual_members/cache.rs b/src/virtual_members/cache.rs index f075ea700..840bbd19a 100644 --- a/src/virtual_members/cache.rs +++ b/src/virtual_members/cache.rs @@ -62,6 +62,8 @@ pub struct ResolvedCacheInner { laravel: crate::virtual_members::laravel::LaravelCacheState, /// The project facts the Doctrine repository members read. doctrine: crate::doctrine::DoctrineCacheState, + /// The project facts the Drupal providers read. + drupal: crate::drupal::fields::DrupalCacheState, /// Classes currently being resolved by `resolve_class_fully_inner`, /// keyed per thread so one thread's in-progress resolution never /// degrades another thread's independent resolution of the same @@ -332,6 +334,14 @@ impl ResolvedCacheInner { self.map.contains_key(key) } + /// Whether [`evict_fqn`] would find anything to evict for `fqn`: a + /// resolution of it, or one that depends on it. + pub(crate) fn holds_or_depends_on(&self, fqn: &str) -> bool { + self.fqn_keys.contains_key(fqn) + || self.reverse_deps.contains_key(fqn) + || self.reverse_deps.contains_key(crate::util::short_name(fqn)) + } + /// The project facts the Laravel providers read. pub(crate) fn laravel(&self) -> &crate::virtual_members::laravel::LaravelCacheState { &self.laravel @@ -345,6 +355,15 @@ impl ResolvedCacheInner { self.doctrine = doctrine; } + /// The project facts the Drupal providers read. + pub(crate) fn drupal(&self) -> &crate::drupal::fields::DrupalCacheState { + &self.drupal + } + + pub(crate) fn set_drupal(&mut self, drupal: crate::drupal::fields::DrupalCacheState) { + self.drupal = drupal; + } + pub(crate) fn laravel_mut( &mut self, ) -> &mut crate::virtual_members::laravel::LaravelCacheState { diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 939a0ee88..3e04c734c 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -95,6 +95,7 @@ pub(crate) fn resolve_laravel_string_key( StringKeyKind::DrupalHook => backend.drupal_hook_documentation(key), StringKeyKind::DrupalConfigName => backend.drupal_config_locations(key), StringKeyKind::DrupalConfigKey => backend.drupal_config_key_locations(key), + StringKeyKind::DrupalFieldName => backend.drupal_field_name_locations(key), StringKeyKind::DrupalRenderElement => backend.drupal_render_element_definitions(key), StringKeyKind::DrupalPlugin => backend.drupal_plugin_definitions(key), StringKeyKind::DrupalThemeHook => backend.drupal_theme_hook_definitions(key), @@ -391,6 +392,7 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::SymfonyRoute | StringKeyKind::DrupalConfigName | StringKeyKind::DrupalConfigKey + | StringKeyKind::DrupalFieldName | StringKeyKind::DrupalThemeHook | StringKeyKind::DrupalThemeHookVariable => { find_string_key_usages(kind, key, backend, snapshot, false) @@ -511,7 +513,11 @@ fn find_string_key_usages( key: span_key, is_write, .. - } if span_kind == kind && span_key == key && (include_writes || !is_write) + } if span_kind == kind + && (span_key == key + || *kind == crate::symbol_map::StringKeyKind::DrupalFieldName + && backend.drupal_field_keys_match(span_key, key)) + && (include_writes || !is_write) ) }; let has_match = spans().any(matches); diff --git a/src/virtual_members/mod.rs b/src/virtual_members/mod.rs index 0c6cab854..988934aab 100644 --- a/src/virtual_members/mod.rs +++ b/src/virtual_members/mod.rs @@ -61,12 +61,12 @@ pub use cache::{ pub(crate) use cache::{ TransformFingerprint, intern_transformed_method, intern_transformed_property, }; -pub(crate) use resolve::resolve_class_base_cached; pub use resolve::{ populate_from_sorted, property_write_is_magic, resolve_class_for_type_hint, resolve_class_fully, resolve_class_fully_cached, resolve_class_fully_maybe_cached, resolve_class_fully_with_generics, resolve_class_fully_with_type_args, }; +pub(crate) use resolve::{resolve_class_base_cached, resolve_class_for_field_type_hint}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -341,6 +341,8 @@ pub fn apply_virtual_members( /// 2. Laravel factory provider (convention-based create/make methods) /// 3. PHPDoc provider (`@method` / `@property` / `@mixin` tags) /// 4. Laravel facade provider (concrete class forwarded as static) +/// 5. Drupal fieldable entity provider (the fields of content entities, and +/// the magic `__get()` and `__set()` of their interfaces) /// /// The facade provider deliberately comes last: a facade that carries /// generated `@method static` tags should keep them, and only a facade @@ -348,8 +350,10 @@ pub fn apply_virtual_members( /// /// When `is_laravel` is `false`, the Laravel providers are omitted so /// that a non-Laravel project does not pay for their parent-chain walks. -/// The framework-agnostic PHPDoc provider is always included. -pub fn default_providers(is_laravel: bool) -> Vec> { +/// The framework-agnostic PHPDoc provider is always included. The Drupal +/// provider runs only in a Drupal project, after the PHPDoc provider, so an +/// interface's own `@method __get` tag would win. +pub fn default_providers(is_laravel: bool, is_drupal: bool) -> Vec> { let mut providers: Vec> = Vec::new(); if is_laravel { providers.push(Box::new(laravel::LaravelModelProvider)); @@ -359,6 +363,10 @@ pub fn default_providers(is_laravel: bool) -> Vec if is_laravel { providers.push(Box::new(laravel::LaravelFacadeProvider)); } + if is_drupal { + providers.push(Box::new(crate::drupal::fields::FieldableEntityProvider)); + providers.push(Box::new(crate::drupal::field_items::FieldItemProvider)); + } providers } diff --git a/src/virtual_members/resolve.rs b/src/virtual_members/resolve.rs index 02ef0431f..1d48b4398 100644 --- a/src/virtual_members/resolve.rs +++ b/src/virtual_members/resolve.rs @@ -330,14 +330,24 @@ pub fn resolve_class_fully_maybe_cached( /// them the same way). /// /// `@property` tags count as declared: they name a type for the path, -/// which the write is expected to respect. +/// which the write is expected to respect. A property a framework stores +/// through `__set` does not: the setter keeps its type whatever is written +/// (see [`writes_through_magic_set`]). +/// +/// [`writes_through_magic_set`]: crate::types::PropertyInfo::writes_through_magic_set pub fn property_write_is_magic( class: &ClassInfo, prop_name: &str, class_loader: &ResolvedClassLoader<'_>, cache: Option<&ResolvedClassCache>, ) -> bool { - if class.has_property(prop_name) { + let stores_value = |class: &ClassInfo| { + class + .properties + .iter() + .any(|property| property.name == prop_name && !property.writes_through_magic_set()) + }; + if stores_value(class) { return false; } // The property and `__set` may each come from a parent, a trait, a @@ -350,7 +360,7 @@ pub fn property_write_is_magic( None => active_resolved_class_cache(), }; let merged = resolve_class_fully_maybe_cached(class, class_loader, cache.or(ambient)); - merged.has_method("__set") && !merged.has_property(prop_name) + merged.has_method("__set") && !stores_value(&merged) } /// The cache key for `fqn` instantiated with `args`. @@ -592,6 +602,25 @@ pub fn resolve_class_for_type_hint( class_loader: &ResolvedClassLoader<'_>, generic_arg_strings: &[String], generic_args: &[PhpType], +) -> Arc { + resolve_class_for_field_type_hint( + class, + class_loader, + generic_arg_strings, + generic_args, + || crate::drupal::field_items::field_type_hint(class, generic_args, class_loader), + ) +} + +/// [`resolve_class_for_type_hint`] for a caller that has already asked +/// what the hint says about a Drupal field (`drupal`), so it is asked only +/// once, and only when the result is not cached. +pub(crate) fn resolve_class_for_field_type_hint( + class: &ClassInfo, + class_loader: &ResolvedClassLoader<'_>, + generic_arg_strings: &[String], + generic_args: &[PhpType], + drupal: impl FnOnce() -> Option, ) -> Arc { let cache = super::cache::active_resolved_class_cache(); let fqn = class.fqn(); @@ -607,18 +636,29 @@ pub fn resolve_class_for_type_hint( return cached; } + // A Drupal field item list's template is its item, which carries the + // reference field's target the list names after it. + let drupal = drupal(); + let drupal_args = drupal.as_ref().map(|hint| hint.specialisation()); + let drupal_arg_strings: Option> = drupal_args + .as_ref() + .map(|args| args.iter().map(ToString::to_string).collect()); + let (specialised_arg_strings, specialised_args) = match (&drupal_arg_strings, &drupal_args) { + (Some(strings), Some(args)) => (strings.as_slice(), args.as_slice()), + _ => (generic_arg_strings, generic_args), + }; let specialised = resolve_class_fully_with_generics( class, class_loader, cache, - generic_arg_strings, - generic_args, + specialised_arg_strings, + specialised_args, ); // A specialisation that was left uncached is a partial result handed // to a re-entrant caller, and so is anything built on it. let cacheable = cache.is_some_and(|c| { c.read() - .contains_key(&resolved_key(fqn, generic_arg_strings.to_vec())) + .contains_key(&resolved_key(fqn, specialised_arg_strings.to_vec())) }); let mut result = Arc::unwrap_or_clone(specialised); @@ -662,6 +702,9 @@ pub fn resolve_class_for_type_hint( &doctrine, ); } + if let Some(hint) = &drupal { + crate::drupal::field_items::apply_to_field_type(&mut result, hint, class_loader); + } let result = Arc::new(result); if let Some(c) = cache @@ -838,10 +881,13 @@ fn apply_post_merge_stages( ) { let fqn = effective_class.fqn(); - // Whether Laravel-specific resolution should run. A throwaway - // cache defaults to `true`, so behaviour is unchanged for - // cache-less code paths and tests. - let is_laravel = cache.read().laravel().is_enabled(); + // Whether Laravel- and Drupal-specific resolution should run. A + // throwaway cache has Laravel on, so behaviour is unchanged for + // cache-less code paths and tests, and Drupal off. + let (is_laravel, is_drupal) = { + let cache = cache.read(); + (cache.laravel().is_enabled(), cache.drupal().is_enabled()) + }; // ── Pre-provider patches ──────────────────────────────────────── // Inject missing `@mixin` annotations before virtual member @@ -874,7 +920,7 @@ fn apply_post_merge_stages( } } - let providers = default_providers(is_laravel); + let providers = default_providers(is_laravel, is_drupal); if !providers.is_empty() { apply_virtual_members(merged, class_loader, &providers, Some(cache)); } diff --git a/src/virtual_members/tests.rs b/src/virtual_members/tests.rs index bdcf30bbb..9dd8d2a2f 100644 --- a/src/virtual_members/tests.rs +++ b/src/virtual_members/tests.rs @@ -1137,7 +1137,7 @@ fn apply_providers_same_specificity_preserves_high_priority() { #[test] fn default_providers_has_laravel_and_phpdoc() { - let providers = default_providers(true); + let providers = default_providers(true, false); assert_eq!( providers.len(), 4, @@ -1148,7 +1148,7 @@ fn default_providers_has_laravel_and_phpdoc() { #[test] fn default_providers_omits_laravel_when_not_laravel() { - let providers = default_providers(false); + let providers = default_providers(false, false); assert_eq!( providers.len(), 1, @@ -1156,6 +1156,15 @@ fn default_providers_omits_laravel_when_not_laravel() { ); } +#[test] +fn default_providers_adds_drupal_only_in_drupal() { + assert_eq!( + default_providers(false, true).len(), + 3, + "a Drupal project should register the PHPDoc, fieldable entity, and field item providers" + ); +} + // ── resolve_class_fully tests ─────────────────────────────────────── #[test] @@ -2090,3 +2099,53 @@ fn concurrent_first_resolution_of_the_same_class_is_single_flighted() { "the interface method must survive the merge" ); } + +// ── property_write_is_magic tests ─────────────────────────────────── + +/// A class with a magic `__set()`, an `@property`-style virtual property +/// `tagged`, and a field property `title` that a framework stores through +/// `__set()`. +fn class_with_magic_set() -> ClassInfo { + let mut class = make_class("Entity"); + class.methods.push(Arc::new(make_method("__set", None))); + class + .properties + .push(Arc::new(make_property("tagged", Some("string")))); + class.properties.push(Arc::new(PropertyInfo { + source: Some(crate::types::PropertySource::DrupalField), + ..make_property("title", Some("FieldItemList")) + })); + class +} + +#[test] +fn a_write_to_an_undeclared_property_with_magic_set_is_magic() { + let class = class_with_magic_set(); + let loader = |_: Fqn<'_>| None; + assert!(property_write_is_magic(&class, "missing", &loader, None)); +} + +#[test] +fn a_write_to_a_tagged_property_is_not_magic() { + let class = class_with_magic_set(); + let loader = |_: Fqn<'_>| None; + assert!(!property_write_is_magic(&class, "tagged", &loader, None)); +} + +/// The field keeps its type whatever is written: `__set()` stores the +/// value into it. +#[test] +fn a_write_to_a_field_stored_through_magic_set_is_magic() { + let class = class_with_magic_set(); + let loader = |_: Fqn<'_>| None; + assert!(property_write_is_magic(&class, "title", &loader, None)); +} + +/// Without `__set()` the write is an ordinary dynamic one. +#[test] +fn a_write_to_a_field_without_magic_set_is_not_magic() { + let mut class = class_with_magic_set(); + class.methods = Default::default(); + let loader = |_: Fqn<'_>| None; + assert!(!property_write_is_magic(&class, "title", &loader, None)); +} diff --git a/tests/integration/drupal_fields.rs b/tests/integration/drupal_fields.rs new file mode 100644 index 000000000..91d4ecaa7 --- /dev/null +++ b/tests/integration/drupal_fields.rs @@ -0,0 +1,2395 @@ +//! Drupal's entity fields: what reading a property on a content entity +//! gives, on the entity class and on the interfaces it implements. + +use crate::common::{hover_at, hover_text, initialize_with_options, open_document}; +use crate::drupal_services::{ + definitions_at, position_in, uri_for, workspace, workspace_opened_early, +}; +use phpantom_lsp::Backend; + +/// Just enough of core's entity interfaces and classes. +const ENTITY_STUBS: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal/Core/Entity/EntityInterface.php", + "body->value; + $raw = $user->passRaw; + $title = $entity->title; + $entity->__get('title'); + $node->title = 'Example'; + $user->passRaw = 'example'; + } + + public function plain(EntityInterface $entity, RevisionableInterface $revision, ConfigEntityInterface $config): void { + $entity->body; + $revision->body; + $config->body; + } +} +"#; + +const READER_PATH: &str = "web/modules/custom/acme/src/Reader.php"; + +/// The unknown-member messages the diagnostics of [`READER`] report. +fn unknown_members(backend: &Backend, uri: &str) -> Vec { + crate::common::slow_diagnostic_messages(backend, uri, READER, "unknown_member") +} + +/// A content entity's interface reads any property through the magic +/// `__get()` every content entity has, the way the entity class does; an +/// interface without fields still reports one it does not declare. +#[tokio::test] +async fn fieldable_interfaces_read_any_property_as_the_class_does() { + let (backend, dir) = workspace(&[ENTITY_STUBS, &[(READER_PATH, READER)]].concat()).await; + let uri = uri_for(&dir, READER_PATH); + + let mut messages = unknown_members(&backend, uri.as_str()); + messages.sort(); + assert_eq!(messages.len(), 3, "{messages:?}"); + for (message, receiver) in messages.iter().zip([ + "ConfigEntityInterface", + "EntityInterface", + "RevisionableInterface", + ]) { + assert!(message.contains(&format!("{receiver}::$body")), "{message}"); + } + + let on_class = variable_hover(&backend, uri.as_str(), "$title"); + assert_eq!(variable_hover(&backend, uri.as_str(), "$raw"), on_class); + assert_eq!(variable_hover(&backend, uri.as_str(), "$body"), on_class); +} + +/// The hover on the variable `var` assigned in [`READER`], with its name +/// left out. +fn variable_hover(backend: &Backend, uri: &str, var: &str) -> String { + let (line, text) = READER + .lines() + .enumerate() + .find(|(_, line)| line.trim_start().starts_with(&format!("{var} = "))) + .expect("the variable should be assigned"); + let character = (text.len() - text.trim_start().len() + 1) as u32; + let hover = hover_at(backend, uri, READER, line as u32, character) + .unwrap_or_else(|| panic!("no hover on {var}")); + hover_text(&hover).replace(var, "$_") +} + +/// A value written on a content entity's interface goes through the same +/// magic `__set()` the class has, so a later read is not the written value. +#[tokio::test] +async fn a_write_on_a_fieldable_interface_does_not_narrow_the_read() { + const WRITER: &str = r#"example_state = ''; + $node->example_state->value; + $node->example_state = NULL; + $node->example_state->value; + } +} +"#; + const WRITER_PATH: &str = "web/modules/custom/acme/src/Writer.php"; + let (backend, dir) = workspace(&[ENTITY_STUBS, &[(WRITER_PATH, WRITER)]].concat()).await; + let uri = uri_for(&dir, WRITER_PATH); + let messages = crate::common::slow_diagnostic_messages( + &backend, + uri.as_str(), + WRITER, + "scalar_member_access", + ); + assert!(messages.is_empty(), "{messages:?}"); +} + +/// The entity class keeps the `__get()` it inherits. +#[tokio::test] +async fn an_entity_class_keeps_its_own_magic_get() { + let (backend, dir) = workspace(&[ENTITY_STUBS, &[(READER_PATH, READER)]].concat()).await; + let uri = uri_for(&dir, READER_PATH); + let found = definitions_at(&backend, &uri, position_in(READER, "__get('title')", 2)).await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!( + found[0].uri, + uri_for( + &dir, + "web/core/lib/Drupal/Core/Entity/ContentEntityBase.php" + ) + ); +} + +/// A file the editor restores while the workspace is read sees the same +/// interfaces once it is. +#[tokio::test] +async fn files_opened_before_indexing_ends_read_any_property() { + let (backend, dir) = workspace_opened_early( + tempfile::tempdir().unwrap(), + &[ENTITY_STUBS, &[(READER_PATH, READER)]].concat(), + ) + .await; + let uri = uri_for(&dir, READER_PATH); + let messages = unknown_members(&backend, uri.as_str()); + assert_eq!(messages.len(), 3, "{messages:?}"); +} + +/// Outside a Drupal project the same interfaces are plain interfaces. +#[tokio::test] +async fn fieldable_interfaces_have_no_magic_outside_drupal() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("composer.json"), "{}").unwrap(); + for (path, content) in ENTITY_STUBS.iter().chain([&(READER_PATH, READER)]) { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let backend = Backend::new_test(); + initialize_with_options(&backend, dir.path(), None).await; + let uri = uri_for(&dir, READER_PATH); + open_document(&backend, &uri, "php", READER).await; + + let messages = unknown_members(&backend, uri.as_str()); + assert!( + messages + .iter() + .any(|message| message.contains("NodeInterface::$body")), + "{messages:?}" + ); + assert!( + messages + .iter() + .any(|message| message.contains("UserInterface::$passRaw")), + "{messages:?}" + ); +} + +// ─── Base fields ──────────────────────────────────────────────────────────── + +/// Core's entity API, field API, field types, and two entity types: `node`, +/// declared by attribute, translatable and revisionable, and `user`, +/// declared by annotation, translatable only. Written where Composer puts +/// core, which the workspace's `.gitignore` leaves out. +const CORE: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal/Core/Entity/EntityInterface.php", + "hasKey('id')) { + $fields[$entity_type->getKey('id')] = BaseFieldDefinition::create('integer'); + } + if ($entity_type->hasKey('uuid')) { + $fields[$entity_type->getKey('uuid')] = BaseFieldDefinition::create('uuid'); + } + if ($entity_type->hasKey('revision')) { + $fields[$entity_type->getKey('revision')] = BaseFieldDefinition::create('integer'); + } + if ($entity_type->hasKey('langcode')) { + $fields[$entity_type->getKey('langcode')] = BaseFieldDefinition::create('language'); + } + if ($entity_type->hasKey('bundle')) { + if ($bundle_entity_type_id = $entity_type->getBundleEntityType()) { + $fields[$entity_type->getKey('bundle')] = BaseFieldDefinition::create('entity_reference') + ->setSetting('target_type', $bundle_entity_type_id); + } + else { + $fields[$entity_type->getKey('bundle')] = BaseFieldDefinition::create('string'); + } + } + return $fields; + } +} +"#, + ), + ( + "web/core/modules/user/src/EntityOwnerTrait.php", + r#"getKey('owner') => BaseFieldDefinition::create('entity_reference') + ->setLabel('Authored by') + ->setSetting('target_type', 'user'), + ]; + } +} +"#, + ), + ( + "web/core/lib/Drupal/Core/Field/BaseFieldDefinition.php", + " + * @implements \IteratorAggregate + */ +class FieldItemList implements \IteratorAggregate, FieldItemListInterface { + protected $langcode; + /** {@inheritdoc} */ + public function first() {} + public function getIterator(): \ArrayIterator {} + public function __get($property_name) {} + public function __set($property_name, $value) {} +} +"#, + ), + ( + "web/core/lib/Drupal/Core/Field/EntityReferenceFieldItemList.php", + r#"setComputed(TRUE); + return $properties; + } +} +"#, + ), + ( + "web/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/StringItem.php", + r#" \Drupal::moduleHandler()->moduleExists('node') ? 'node' : 'user', + ] + parent::defaultStorageSettings(); + } + public static function propertyDefinitions($field_definition) { + $target_id_definition = DataReferenceTargetDefinition::create('integer'); + $properties['target_id'] = $target_id_definition; + $properties['entity'] = DataReferenceDefinition::create('entity') + ->setComputed(TRUE) + ->setReadOnly(FALSE); + return $properties; + } +} +"#, + ), + ( + "web/core/modules/node/src/NodeInterface.php", + " 'nid', + 'revision' => 'vid', + 'bundle' => 'type', + 'langcode' => 'langcode', + 'uuid' => 'uuid', + 'owner' => 'uid', + ], + translatable: TRUE, +)] +class Node extends ContentEntityBase implements NodeInterface { + use EntityOwnerTrait; + + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields = parent::baseFieldDefinitions($entity_type); + $fields += static::ownerBaseFieldDefinitions($entity_type); + $fields['title'] = BaseFieldDefinition::create('string') + ->setLabel('Title'); + $fields['uid']->setLabel('Authored by'); + return $fields; + } +} +"#; + +/// A custom entity type, neither translatable nor revisionable, with an +/// interface of its own. +const NOTE_PATH: &str = "web/modules/custom/acme/src/Entity/Note.php"; +const NOTE: &str = r#" 'id', 'uuid' => 'uuid', 'owner' => 'author'], +)] +class Note extends ContentEntityBase implements NoteInterface { + use EntityOwnerTrait; + + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields = parent::baseFieldDefinitions($entity_type); + $fields += static::ownerBaseFieldDefinitions($entity_type); + $fields['body'] = BaseFieldDefinition::create('string'); + $fields['excerpt'] = BaseFieldDefinition::create('acme_excerpt'); + return $fields; + } +} +"#; +const NOTE_INTERFACE_PATH: &str = "web/modules/custom/acme/src/Entity/NoteInterface.php"; +const NOTE_INTERFACE: &str = " (Backend, tempfile::TempDir) { + site_in(tempfile::tempdir().unwrap(), files).await +} + +/// [`site`] in `dir`, which may hold files already. +async fn site_in(dir: tempfile::TempDir, files: &[(&str, &str)]) -> (Backend, tempfile::TempDir) { + for (path, content) in CORE.iter().chain(&[ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + (NOTE_PATH, NOTE), + (NOTE_INTERFACE_PATH, NOTE_INTERFACE), + (EXCERPT_ITEM_PATH, EXCERPT_ITEM), + ]) { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let (backend, dir) = crate::drupal_services::workspace_in(dir, files).await; + backend.ensure_workspace_indexed(); + (backend, dir) +} + +/// The members completion offers after `$receiver->` on a receiver of +/// `class`, written in a file of its own. +async fn members_of(backend: &Backend, dir: &tempfile::TempDir, class: &str) -> Vec { + let probe = format!( + "\n}}\n" + ); + let uri = uri_for(dir, PROBE_PATH); + open_document(backend, &uri, "php", &probe).await; + let position = position_in(&probe, "$receiver->\n", "$receiver->".len()); + crate::common::response_labels( + crate::common::completion_response(backend, &uri, position.line, position.character, None) + .await, + ) +} + +fn offers(labels: &[String], name: &str) -> bool { + labels.iter().any(|label| label == name) +} + +/// Node has its own fields, `ContentEntityBase`'s key fields under the +/// names node gives the keys, and the owner trait's field; a key node does +/// not declare drops its field. +#[tokio::test] +async fn an_entity_type_has_its_own_inherited_and_trait_fields() { + let (backend, dir) = site(&[]).await; + for receiver in ["Drupal\\node\\NodeInterface", "Drupal\\node\\Entity\\Node"] { + let labels = members_of(&backend, &dir, receiver).await; + for field in ["nid", "uuid", "vid", "langcode", "uid", "title"] { + assert!( + offers(&labels, field), + "{receiver} lacks {field}: {labels:?}" + ); + } + for not_field in ["id", "owner", "bundle", "name", "body"] { + assert!(!offers(&labels, not_field), "{receiver} has {not_field}"); + } + } +} + +/// A custom entity type whose base field method is a trait's, which takes +/// fields from its parent and from another trait of the class. +const CARD: &[(&str, &str)] = &[ + ( + "web/modules/custom/acme/src/Entity/StampedTrait.php", + r#" 'id', 'uuid' => 'uuid'])] +class Card extends ContentEntityBase { + use StampedTrait; + use StampTrait; +} +"#, + ), +]; + +/// `parent::` and `self::` in a trait's base field method are the using +/// class's, as PHP binds them. +#[tokio::test] +async fn a_trait_s_base_field_method_follows_the_class_that_uses_it() { + let (backend, dir) = site_with(CARD, &[]).await; + let labels = members_of(&backend, &dir, "Drupal\\acme\\Entity\\Card").await; + for field in ["id", "uuid", "stamp", "label"] { + assert!(offers(&labels, field), "{field}: {labels:?}"); + } +} + +/// A field a base field method unsets is gone, though the method it takes +/// it from creates it. +#[tokio::test] +async fn a_field_a_base_field_method_unsets_is_gone() { + let unset = NOTE.replace( + "$fields['body']", + "unset($fields['uuid']);\n $fields['body']", + ); + let (backend, dir) = site(&[(NOTE_PATH, &unset)]).await; + for receiver in [ + "Drupal\\acme\\Entity\\NoteInterface", + "Drupal\\acme\\Entity\\Note", + ] { + let labels = members_of(&backend, &dir, receiver).await; + assert!(offers(&labels, "body"), "{receiver}: {labels:?}"); + assert!(!offers(&labels, "uuid"), "{receiver}: {labels:?}"); + } +} + +/// The annotation-declared `user` and the custom `acme_note` have their own. +#[tokio::test] +async fn annotated_and_custom_entity_types_have_their_fields() { + let (backend, dir) = site(&[]).await; + let user = members_of(&backend, &dir, "Drupal\\user\\UserInterface").await; + for field in ["uid", "uuid", "langcode", "name"] { + assert!(offers(&user, field), "user lacks {field}: {user:?}"); + } + assert!(!offers(&user, "title"), "{user:?}"); + let note = members_of(&backend, &dir, "Drupal\\acme\\Entity\\NoteInterface").await; + for field in ["id", "uuid", "author", "body"] { + assert!(offers(¬e, field), "acme_note lacks {field}: {note:?}"); + } + assert!(!offers(¬e, "langcode"), "{note:?}"); +} + +/// The entity field manager's own fields: all three on a translatable, +/// revisionable type, `default_langcode` alone on a translatable one, none +/// on the rest. +#[tokio::test] +async fn the_field_managers_fields_follow_its_conditions() { + let (backend, dir) = site(&[]).await; + let manager_fields = [ + "default_langcode", + "revision_default", + "revision_translation_affected", + ]; + let present = |labels: &[String]| -> Vec<&str> { + manager_fields + .iter() + .copied() + .filter(|field| offers(labels, field)) + .collect() + }; + let node = members_of(&backend, &dir, "Drupal\\node\\NodeInterface").await; + assert_eq!(present(&node), manager_fields); + let user = members_of(&backend, &dir, "Drupal\\user\\UserInterface").await; + assert_eq!(present(&user), ["default_langcode"]); + let note = members_of(&backend, &dir, "Drupal\\acme\\Entity\\NoteInterface").await; + assert!(present(¬e).is_empty(), "{note:?}"); +} + +/// An interface several entity classes implement has the fields all of +/// them have. +#[tokio::test] +async fn a_shared_interface_has_the_fields_every_entity_type_has() { + let (backend, dir) = site(&[]).await; + let labels = members_of( + &backend, + &dir, + "Drupal\\Core\\Entity\\ContentEntityInterface", + ) + .await; + assert!(offers(&labels, "uuid"), "{labels:?}"); + for not_shared in [ + "nid", + "title", + "name", + "body", + "langcode", + "default_langcode", + ] { + assert!(!offers(&labels, not_shared), "{not_shared}: {labels:?}"); + } +} + +const TYPED: &str = r#"title; + $owner = $node->uid; + $flag = $node->default_langcode; + $class_title = $entity->title; + $class_owner = $entity->uid; + } +} +"#; +const TYPED_PATH: &str = "web/modules/custom/acme/src/Typed.php"; + +/// The type the hover on the variable `var`, assigned in `content`, gives. +fn hovered_type(backend: &Backend, uri: &str, content: &str, var: &str) -> String { + let (line, text) = content + .lines() + .enumerate() + .find(|(_, line)| line.trim_start().starts_with(&format!("{var} = "))) + .expect("the variable should be assigned"); + let character = (text.len() - text.trim_start().len() + 1) as u32; + let hover = hover_at(backend, uri, content, line as u32, character) + .unwrap_or_else(|| panic!("no hover on {var}")); + hover_text(&hover).to_string() +} + +/// A field is its field type's item list, the item class its type +/// argument; a reference field's target class follows. +#[tokio::test] +async fn a_field_is_typed_by_its_field_types_item_list() { + let (backend, dir) = site(&[(TYPED_PATH, TYPED)]).await; + let uri = uri_for(&dir, TYPED_PATH); + let hover = |var: &str| hovered_type(&backend, uri.as_str(), TYPED, var); + for var in ["$title", "$class_title"] { + let text = hover(var); + assert!(text.contains("FieldItemList"), "{var}: {text}"); + } + for var in ["$owner", "$class_owner"] { + let text = hover(var); + assert!( + text.contains("EntityReferenceFieldItemList"), + "{var}: {text}" + ); + } + let text = hover("$flag"); + assert!(text.contains("FieldItemList"), "{text}"); +} + +const NAVIGATED: &str = r#"title; + $node->nid; + $node->uid; + $note->body; + } +} +"#; +const NAVIGATED_PATH: &str = "web/modules/custom/acme/src/Navigated.php"; + +/// Go-to-definition on a field goes to the name its declaration creates +/// it under: in the entity class, in `ContentEntityBase` for a key field, +/// in the trait for the owner field, and in a custom entity class. +#[tokio::test] +async fn a_field_goes_to_where_it_is_declared() { + let (backend, dir) = site(&[(NAVIGATED_PATH, NAVIGATED)]).await; + let uri = uri_for(&dir, NAVIGATED_PATH); + // Each access, the file it goes to, and the text the declared name + // starts in, with how far into that text the name starts. + for (access, path, declared, inside) in [ + ("$node->title", NODE_PATH, "$fields['title']", 9), + ( + "$node->nid", + "web/core/lib/Drupal/Core/Entity/ContentEntityBase.php", + "$entity_type->getKey('id')", + 0, + ), + ( + "$node->uid", + "web/core/modules/user/src/EntityOwnerTrait.php", + "$entity_type->getKey('owner')", + 0, + ), + ("$note->body", NOTE_PATH, "$fields['body']", 9), + ] { + let found = definitions_at( + &backend, + &uri, + position_in(NAVIGATED, access, access.len() - 1), + ) + .await; + assert_eq!(found.len(), 1, "{access}: {found:?}"); + assert_eq!(found[0].uri, uri_for(&dir, path), "{access}"); + let content = CORE + .iter() + .chain(&[(NOTE_PATH, NOTE)]) + .find(|(file, _)| *file == path) + .map(|(_, content)| *content) + .unwrap(); + assert_eq!( + found[0].range.start, + position_in(content, declared, inside), + "{access}" + ); + } +} + +const WRITTEN: &str = r#"title = 'Example'; + $node->title->value; + $entity->title = 'Example'; + $entity->title->value; + } +} +"#; +const WRITTEN_PATH: &str = "web/modules/custom/acme/src/Written.php"; + +/// Writing a field stores the value into it: a later read is still the +/// field, on the class and on its interface. +#[tokio::test] +async fn writing_a_field_keeps_its_type() { + let (backend, dir) = site(&[(WRITTEN_PATH, WRITTEN)]).await; + let uri = uri_for(&dir, WRITTEN_PATH); + let messages = crate::common::slow_diagnostic_messages( + &backend, + uri.as_str(), + WRITTEN, + "scalar_member_access", + ); + assert!(messages.is_empty(), "{messages:?}"); +} + +/// Editing a custom entity's `baseFieldDefinitions()` refreshes its fields +/// on the class and on its interface, whose resolved members do not depend +/// on the class. +#[tokio::test] +async fn editing_base_fields_refreshes_the_class_and_its_interfaces() { + let (backend, dir) = site(&[(NOTE_PATH, NOTE)]).await; + let before_interface = members_of(&backend, &dir, "Drupal\\acme\\Entity\\NoteInterface").await; + let before_class = members_of(&backend, &dir, "Drupal\\acme\\Entity\\Note").await; + for labels in [&before_interface, &before_class] { + assert!(offers(labels, "body"), "{labels:?}"); + assert!(!offers(labels, "summary"), "{labels:?}"); + } + + let edited = NOTE.replace("$fields['body']", "$fields['summary']"); + edit(&backend, &uri_for(&dir, NOTE_PATH), &edited).await; + + let after_interface = members_of(&backend, &dir, "Drupal\\acme\\Entity\\NoteInterface").await; + let after_class = members_of(&backend, &dir, "Drupal\\acme\\Entity\\Note").await; + for labels in [&after_interface, &after_class] { + assert!(offers(labels, "summary"), "{labels:?}"); + assert!(!offers(labels, "body"), "{labels:?}"); + } +} + +/// A base class between the entity class and `ContentEntityBase` that +/// adds a field, and a trait that gives one. +const EDITORIAL: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal/Core/Entity/EditorialContentEntityBase.php", + " BaseFieldDefinition::create('boolean')];\n }\n}\n", + ), +]; + +/// Changing the parent, the traits, or the interfaces of an entity class +/// refreshes its fields, on the class and on its interface, though its base +/// field methods stay the same. +#[tokio::test] +async fn editing_an_entity_class_s_parent_traits_or_interfaces_refreshes_its_fields() { + let imported = NOTE.replace( + "use Drupal\\user\\EntityOwnerTrait;", + "use Drupal\\user\\EntityOwnerTrait;\nuse Drupal\\Core\\Entity\\EditorialContentEntityBase;\nuse Drupal\\Core\\Entity\\EntityPublishedTrait;", + ); + let publishing = imported.replace( + "$fields['body']", + "$fields += static::publishedBaseFieldDefinitions($entity_type);\n $fields['body']", + ); + let unimplemented = imported.replace(" implements NoteInterface", ""); + // Each edit: the text before and after it, and the field it gives. + for (before, after, field) in [ + ( + imported.clone(), + imported.replace( + "extends ContentEntityBase", + "extends EditorialContentEntityBase", + ), + "status", + ), + ( + publishing.clone(), + publishing.replace( + "use EntityOwnerTrait;", + "use EntityOwnerTrait;\n use EntityPublishedTrait;", + ), + "published", + ), + (unimplemented.clone(), imported.clone(), "body"), + ] { + let (backend, dir) = site_with(EDITORIAL, &[(NOTE_PATH, &before)]).await; + let receivers = [ + "Drupal\\acme\\Entity\\NoteInterface", + "Drupal\\acme\\Entity\\Note", + ]; + let receivers = if field == "body" { + &receivers[..1] + } else { + &receivers[..] + }; + for receiver in receivers { + let labels = members_of(&backend, &dir, receiver).await; + assert!(!offers(&labels, field), "{receiver} before: {labels:?}"); + } + edit(&backend, &uri_for(&dir, NOTE_PATH), &after).await; + for receiver in receivers { + let labels = members_of(&backend, &dir, receiver).await; + assert!(offers(&labels, field), "{receiver} after: {labels:?}"); + } + } +} + +/// A base class an entity class names before it exists gives its fields +/// once it is created. +#[tokio::test] +async fn a_base_class_created_after_the_fields_are_read_gives_its_fields() { + let based = NOTE.replace( + "extends ContentEntityBase", + "extends \\Drupal\\acme\\Entity\\NoteBase", + ); + let (backend, dir) = site_with(EDITORIAL, &[(NOTE_PATH, &based)]).await; + let receivers = [ + "Drupal\\acme\\Entity\\NoteInterface", + "Drupal\\acme\\Entity\\Note", + ]; + for receiver in receivers { + let labels = members_of(&backend, &dir, receiver).await; + assert!(!offers(&labels, "status"), "{receiver} before: {labels:?}"); + } + open_document( + &backend, + &uri_for(&dir, "web/modules/custom/acme/src/Entity/NoteBase.php"), + "php", + "excerpt; + } +} +"#; +const EXCERPTED_PATH: &str = "web/modules/custom/acme/src/Excerpted.php"; + +/// Giving a field type an item list class retypes the fields of that type. +#[tokio::test] +async fn editing_a_field_type_retypes_its_fields() { + let (backend, dir) = site(&[ + (EXCERPTED_PATH, EXCERPTED), + (EXCERPT_ITEM_PATH, EXCERPT_ITEM), + ]) + .await; + let uri = uri_for(&dir, EXCERPTED_PATH); + let before = hovered_type(&backend, uri.as_str(), EXCERPTED, "$excerpt"); + assert!(before.contains("FieldItemList"), "{before}"); + + let listed = EXCERPT_ITEM.replace( + "#[FieldType(id: 'acme_excerpt')]", + "#[FieldType(id: 'acme_excerpt', list_class: ExcerptList::class)]", + ); + edit(&backend, &uri_for(&dir, EXCERPT_ITEM_PATH), &listed).await; + + let after = hovered_type(&backend, uri.as_str(), EXCERPTED, "$excerpt"); + assert!(after.contains("ExcerptList"), "{after}"); +} + +/// The field type manager read after the fields types them once it is. +#[tokio::test] +async fn a_field_type_manager_read_late_types_the_fields() { + const MANAGER_PATH: &str = "web/core/lib/Drupal/Core/Field/FieldTypePluginManager.php"; + let manager = CORE + .iter() + .find(|(path, _)| *path == MANAGER_PATH) + .map(|(_, content)| *content) + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + for (path, content) in CORE + .iter() + .filter(|(path, _)| *path != MANAGER_PATH) + .chain(&[ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + (NOTE_PATH, NOTE), + (NOTE_INTERFACE_PATH, NOTE_INTERFACE), + ]) + { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let (backend, dir) = crate::drupal_services::workspace_in(dir, &[(TYPED_PATH, TYPED)]).await; + backend.ensure_workspace_indexed(); + let uri = uri_for(&dir, TYPED_PATH); + let before = hovered_type(&backend, uri.as_str(), TYPED, "$title"); + assert!(!before.contains("StringItem"), "{before}"); + + open_document(&backend, &uri_for(&dir, MANAGER_PATH), "php", manager).await; + + let after = hovered_type(&backend, uri.as_str(), TYPED, "$title"); + assert!(after.contains("FieldItemList"), "{after}"); +} + +/// Lines added above a field's declaration move where go-to-definition +/// lands, though the fields stay the same. +#[tokio::test] +async fn a_field_goes_to_its_declaration_after_lines_above_it_move() { + let (backend, dir) = site(&[(NAVIGATED_PATH, NAVIGATED), (NOTE_PATH, NOTE)]).await; + let uri = uri_for(&dir, NAVIGATED_PATH); + let body = position_in(NAVIGATED, "$note->body", "$note->b".len()); + let before = definitions_at(&backend, &uri, body).await; + assert_eq!( + before.first().map(|location| location.range.start), + Some(position_in(NOTE, "$fields['body']", 9)) + ); + + let moved = NOTE.replace("class Note", "// A note.\n// Kept short.\nclass Note"); + edit(&backend, &uri_for(&dir, NOTE_PATH), &moved).await; + + let after = definitions_at(&backend, &uri, body).await; + assert_eq!( + after.first().map(|location| location.range.start), + Some(position_in(&moved, "$fields['body']", 9)) + ); +} + +/// A custom entity's file the editor restores while the workspace is read +/// gives its fields once it is. +#[tokio::test] +async fn an_entity_file_opened_before_indexing_ends_gives_its_fields() { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in CORE.iter().chain(&[ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + (NOTE_INTERFACE_PATH, NOTE_INTERFACE), + (EXCERPT_ITEM_PATH, EXCERPT_ITEM), + ]) { + 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_opened_early(dir, &[(NOTE_PATH, NOTE)]).await; + backend.ensure_workspace_indexed(); + let labels = members_of(&backend, &dir, "Drupal\\acme\\Entity\\NoteInterface").await; + for field in ["body", "author", "uuid"] { + assert!(offers(&labels, field), "{field}: {labels:?}"); + } +} + +// ─── Configurable fields ──────────────────────────────────────────────────── + +/// Core's taxonomy term entity type, which reference fields point to. +const TERM: (&str, &str) = ( + "web/core/modules/taxonomy/src/Entity/Term.php", + " 'tid', 'uuid' => 'uuid'])]\nclass Term extends ContentEntityBase {}\n", +); + +/// A field storage file for the field `name` of `entity_type`, of the field +/// type `field_type`, pointing to `target` when given. +fn storage(entity_type: &str, name: &str, field_type: &str, target: Option<&str>) -> String { + let settings = match target { + Some(target) => format!("settings:\n target_type: {target}\n"), + None => "settings: { }\n".to_string(), + }; + format!( + "uuid: 0d4c5b0e-0000-4000-8000-000000000000\nlangcode: en\nstatus: true\ndependencies:\n module:\n - {entity_type}\nid: {entity_type}.{name}\nfield_name: {name}\nentity_type: {entity_type}\ntype: {field_type}\n{settings}module: core\nlocked: false\ncardinality: -1\ntranslatable: true\n" + ) +} + +/// A Drupal site with [`CORE`], the custom entity type, the term entity +/// type, and `disk` on disk, `open` open, indexed. +async fn site_with(disk: &[(&str, &str)], open: &[(&str, &str)]) -> (Backend, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in disk.iter().chain([&TERM]) { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + site_in(dir, open).await +} + +/// Configurable fields come from an extension's default configuration, +/// core's included, and from the site's exported configuration beside the +/// web root, each on its own entity type only. +#[tokio::test] +async fn configurable_fields_come_from_extensions_and_exported_configuration() { + let tags = storage( + "node", + "field_tags", + "entity_reference", + Some("taxonomy_term"), + ); + let rating = storage("acme_note", "field_rating", "integer", None); + let picture = storage("user", "user_picture", "string", None); + let (backend, dir) = site_with( + &[ + ("config/sync/field.storage.node.field_tags.yml", &tags), + ( + "web/modules/custom/acme/config/install/field.storage.acme_note.field_rating.yml", + &rating, + ), + ( + "web/core/profiles/standard/config/install/field.storage.user.user_picture.yml", + &picture, + ), + ], + &[], + ) + .await; + let node = members_of(&backend, &dir, "Drupal\\node\\NodeInterface").await; + let user = members_of(&backend, &dir, "Drupal\\user\\UserInterface").await; + let note = members_of(&backend, &dir, "Drupal\\acme\\Entity\\Note").await; + let shared = members_of( + &backend, + &dir, + "Drupal\\Core\\Entity\\ContentEntityInterface", + ) + .await; + for (labels, own) in [ + (&node, "field_tags"), + (&user, "user_picture"), + (¬e, "field_rating"), + ] { + for field in ["field_tags", "user_picture", "field_rating"] { + assert_eq!(offers(labels, field), field == own, "{field}: {labels:?}"); + } + } + for field in ["field_tags", "user_picture", "field_rating"] { + assert!(!offers(&shared, field), "{field}: {shared:?}"); + } +} + +const CONFIGURED: &str = r#"field_tags; + $rating = $note->field_rating; + } +} +"#; +const CONFIGURED_PATH: &str = "web/modules/custom/acme/src/Configured.php"; +const SYNC_TAGS: &str = "config/sync/field.storage.node.field_tags.yml"; +const MODULE_TAGS: &str = + "web/modules/custom/acme/config/install/field.storage.node.field_tags.yml"; + +/// A configurable field is its field type's item list, and a reference +/// field names the class of the entity type its `target_type` gives. +#[tokio::test] +async fn a_configurable_field_is_typed_by_its_field_type() { + let tags = storage( + "node", + "field_tags", + "entity_reference", + Some("taxonomy_term"), + ); + let rating = storage("acme_note", "field_rating", "integer", None); + let (backend, dir) = site_with( + &[ + (SYNC_TAGS, &tags), + ( + "config/sync/field.storage.acme_note.field_rating.yml", + &rating, + ), + ], + &[(CONFIGURED_PATH, CONFIGURED)], + ) + .await; + let uri = uri_for(&dir, CONFIGURED_PATH); + let tags = hovered_type(&backend, uri.as_str(), CONFIGURED, "$tags"); + assert!( + tags.contains("EntityReferenceFieldItemList"), + "{tags}" + ); + let rating = hovered_type(&backend, uri.as_str(), CONFIGURED, "$rating"); + assert!(rating.contains("FieldItemList"), "{rating}"); +} + +/// A field a module installs and the site exports is one field, offered +/// once, that goes to both files. +#[tokio::test] +async fn a_field_declared_twice_is_one_field_with_both_files() { + let tags = storage( + "node", + "field_tags", + "entity_reference", + Some("taxonomy_term"), + ); + let (backend, dir) = site_with( + &[(SYNC_TAGS, &tags), (MODULE_TAGS, &tags)], + &[(CONFIGURED_PATH, CONFIGURED)], + ) + .await; + let labels = members_of(&backend, &dir, "Drupal\\node\\NodeInterface").await; + assert_eq!( + labels.iter().filter(|label| *label == "field_tags").count(), + 1, + "{labels:?}" + ); + let uri = uri_for(&dir, CONFIGURED_PATH); + let found = definitions_at( + &backend, + &uri, + position_in(CONFIGURED, "$node->field_tags", "$node->field".len()), + ) + .await; + let mut targets: Vec<_> = found.iter().map(|location| location.uri.clone()).collect(); + targets.sort_by_key(|uri| uri.to_string()); + let mut expected = vec![uri_for(&dir, SYNC_TAGS), uri_for(&dir, MODULE_TAGS)]; + expected.sort_by_key(|uri| uri.to_string()); + assert_eq!(targets, expected); + for location in &found { + assert_eq!( + location.range.start, + position_in(&tags, "field_name: field_tags", "field_name: ".len()) + ); + } +} + +/// Files under `vendor/` and in recipes are no source, and a test module's +/// counts only while it is open. +#[tokio::test] +async fn recipes_dependencies_and_closed_test_modules_declare_no_field() { + const TEST_MODULE: &str = "web/modules/custom/acme/tests/modules/acme_test/config/install/field.storage.node.field_test.yml"; + const VENDOR: &str = "vendor/example/fields/config/install/field.storage.node.field_vendor.yml"; + let recipe = storage("node", "field_recipe", "string", None); + let core_recipe = storage("node", "field_core_recipe", "string", None); + let test = storage("node", "field_test", "string", None); + let vendor = storage("node", "field_vendor", "string", None); + let (backend, dir) = site_with( + &[ + ( + "recipes/example/config/field.storage.node.field_recipe.yml", + &recipe, + ), + ( + "web/core/recipes/example/config/field.storage.node.field_core_recipe.yml", + &core_recipe, + ), + (TEST_MODULE, &test), + (VENDOR, &vendor), + ], + &[(VENDOR, &vendor)], + ) + .await; + let labels = members_of(&backend, &dir, "Drupal\\node\\NodeInterface").await; + for field in [ + "field_recipe", + "field_core_recipe", + "field_test", + "field_vendor", + ] { + assert!(!offers(&labels, field), "{field}: {labels:?}"); + } + + let test_uri = uri_for(&dir, TEST_MODULE); + open_document(&backend, &test_uri, "yaml", &test).await; + let labels = members_of(&backend, &dir, "Drupal\\node\\NodeInterface").await; + assert!(offers(&labels, "field_test"), "{labels:?}"); + + close(&backend, &test_uri).await; + let labels = members_of(&backend, &dir, "Drupal\\node\\NodeInterface").await; + assert!(!offers(&labels, "field_test"), "{labels:?}"); +} + +async fn close(backend: &Backend, uri: &tower_lsp_server::gen_lsp_types::Uri) { + use tower_lsp_server::LanguageServer; + use tower_lsp_server::gen_lsp_types::*; + backend + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + }) + .await; +} + +async fn notify( + backend: &Backend, + uri: &tower_lsp_server::gen_lsp_types::Uri, + kind: tower_lsp_server::gen_lsp_types::FileChangeType, +) { + use tower_lsp_server::LanguageServer; + use tower_lsp_server::gen_lsp_types::*; + backend + .did_change_watched_files(DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: uri.clone(), + kind, + }], + }) + .await; +} + +/// Adding, editing, and deleting a field storage file refreshes the fields +/// of its entity type, on the class and on its interface. +#[tokio::test] +async fn field_storage_changes_refresh_the_fields() { + use tower_lsp_server::gen_lsp_types::FileChangeType; + let tags = storage( + "node", + "field_tags", + "entity_reference", + Some("taxonomy_term"), + ); + let (backend, dir) = site_with(&[(SYNC_TAGS, &tags)], &[]).await; + let receivers = ["Drupal\\node\\NodeInterface", "Drupal\\node\\Entity\\Node"]; + + let topics_path = "config/sync/field.storage.node.field_topics.yml"; + let topics = dir.path().join(topics_path); + std::fs::write(&topics, storage("node", "field_topics", "string", None)).unwrap(); + let topics_uri = uri_for(&dir, topics_path); + notify(&backend, &topics_uri, FileChangeType::Created).await; + for receiver in receivers { + let labels = members_of(&backend, &dir, receiver).await; + assert!(offers(&labels, "field_topics"), "{receiver}: {labels:?}"); + } + + let tags_uri = uri_for(&dir, SYNC_TAGS); + open_document(&backend, &tags_uri, "yaml", &tags).await; + edit( + &backend, + &tags_uri, + &tags.replace("field_name: field_tags", "field_name: field_labels"), + ) + .await; + for receiver in receivers { + let labels = members_of(&backend, &dir, receiver).await; + assert!(offers(&labels, "field_labels"), "{receiver}: {labels:?}"); + assert!(!offers(&labels, "field_tags"), "{receiver}: {labels:?}"); + } + + // An export or a checkout rewrites a closed file on disk. + std::fs::write(&topics, storage("node", "field_subjects", "string", None)).unwrap(); + notify(&backend, &topics_uri, FileChangeType::Changed).await; + for receiver in receivers { + let labels = members_of(&backend, &dir, receiver).await; + assert!(offers(&labels, "field_subjects"), "{receiver}: {labels:?}"); + assert!(!offers(&labels, "field_topics"), "{receiver}: {labels:?}"); + } + + std::fs::remove_file(&topics).unwrap(); + notify(&backend, &topics_uri, FileChangeType::Deleted).await; + for receiver in receivers { + let labels = members_of(&backend, &dir, receiver).await; + assert!(!offers(&labels, "field_subjects"), "{receiver}: {labels:?}"); + } +} + +/// An exported field storage file the editor restores while the workspace +/// is read gives its field once it is. +#[tokio::test] +async fn a_storage_opened_before_indexing_ends_gives_its_field() { + let tags = storage( + "node", + "field_tags", + "entity_reference", + Some("taxonomy_term"), + ); + let dir = tempfile::tempdir().unwrap(); + for (path, content) in CORE.iter().chain(&[ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + TERM, + ]) { + 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_opened_early(dir, &[(SYNC_TAGS, &tags)]).await; + backend.ensure_workspace_indexed(); + let labels = members_of(&backend, &dir, "Drupal\\node\\NodeInterface").await; + assert!(offers(&labels, "field_tags"), "{labels:?}"); +} + +// ─── Field properties ─────────────────────────────────────────────────────── + +/// A custom field item that takes the reference item's properties and adds +/// two. +const ATTACHMENT_ITEM_PATH: &str = + "web/modules/custom/acme/src/Plugin/Field/FieldType/AttachmentItem.php"; +const ATTACHMENT_ITEM: &str = r#" 'acme_note'] + parent::defaultStorageSettings(); + } + public static function propertyDefinitions($field_definition) { + $properties = parent::propertyDefinitions($field_definition); + $properties['display'] = DataDefinition::create('boolean'); + $properties['description'] = DataDefinition::create('string'); + $properties['langcode'] = DataDefinition::create('string'); + return $properties; + } +} +"#; + +/// A field type whose item takes the attachment's properties, drops two, +/// and adds its own. +const PICTURE_ITEM_PATH: &str = + "web/modules/custom/acme/src/Plugin/Field/FieldType/PictureItem.php"; +const PICTURE_ITEM: &str = r#" ['alt' => '']] + parent::defaultStorageSettings(); + } + public static function propertyDefinitions($field_definition) { + $properties = parent::propertyDefinitions($field_definition); + unset($properties['display']); + unset($properties['description']); + $properties['alt'] = DataDefinition::create('string'); + return $properties; + } +} +"#; + +const PICTURE: &str = "Drupal\\acme\\Plugin\\Field\\FieldType\\PictureItem"; + +/// A Drupal site with the custom item classes and three configurable node +/// fields: tags pointing to terms, a picture pointing to notes, and a +/// reference that names no target; `open` open, indexed. +async fn property_site(open: &[(&str, &str)]) -> (Backend, tempfile::TempDir) { + let tags = storage( + "node", + "field_tags", + "entity_reference", + Some("taxonomy_term"), + ); + // The picture field names no target: its field type's default one. + let picture = storage("node", "field_picture", "acme_picture", None); + let any = storage("node", "field_any", "entity_reference", None); + site_with( + &[ + (ATTACHMENT_ITEM_PATH, ATTACHMENT_ITEM), + (PICTURE_ITEM_PATH, PICTURE_ITEM), + (SYNC_TAGS, &tags), + ("config/sync/field.storage.node.field_picture.yml", &picture), + ("config/sync/field.storage.node.field_any.yml", &any), + ], + open, + ) + .await +} + +/// The members completion offers after `{subject}->` in a function that +/// takes `\Drupal\node\NodeInterface $node`. +async fn members_after(backend: &Backend, dir: &tempfile::TempDir, subject: &str) -> Vec { + members_after_on(backend, dir, "\\Drupal\\node\\NodeInterface $node", subject).await +} + +/// The members completion offers after `{subject}->` in a function that +/// takes `parameter`. +async fn members_after_on( + backend: &Backend, + dir: &tempfile::TempDir, + parameter: &str, + subject: &str, +) -> Vec { + let probe = format!( + "\n}}\n" + ); + let uri = uri_for(dir, PROBE_PATH); + open_document(backend, &uri, "php", &probe).await; + let typed = format!("{subject}->\n"); + let position = position_in(&probe, &typed, typed.len() - 1); + crate::common::response_labels( + crate::common::completion_response(backend, &uri, position.line, position.character, None) + .await, + ) +} + +/// An item offers the properties its `propertyDefinitions()` declares, with +/// those it takes from its parent's and without those it unsets, and so do +/// the lists that hold it and their first item. +#[tokio::test] +async fn items_and_their_lists_offer_the_item_properties() { + let (backend, dir) = property_site(&[]).await; + let attachment = members_of( + &backend, + &dir, + "Drupal\\acme\\Plugin\\Field\\FieldType\\AttachmentItem", + ) + .await; + for property in ["target_id", "entity", "display", "description"] { + assert!(offers(&attachment, property), "{property}: {attachment:?}"); + } + let picture = members_of(&backend, &dir, PICTURE).await; + let list = members_after(&backend, &dir, "$node->field_picture").await; + let first = members_after(&backend, &dir, "$node->field_picture->first()").await; + for labels in [&picture, &list, &first] { + for property in ["alt", "target_id", "entity"] { + assert!(offers(labels, property), "{property}: {labels:?}"); + } + for unset in ["display", "description"] { + assert!(!offers(labels, unset), "{unset}: {labels:?}"); + } + } + let title = members_after(&backend, &dir, "$node->title").await; + assert!(offers(&title, "value"), "{title:?}"); + assert!(!offers(&title, "entity"), "{title:?}"); +} + +const PROPERTIES: &str = r#"title->value; + $first = $node->title->first()->value; + $owner = $node->uid->entity; + $owner_id = $node->uid->target_id; + $item_owner = $node->uid->first()->entity; + $tag = $node->field_tags->entity; + $tags = $node->field_tags->referencedEntities(); + $any = $node->field_any->entity; + $picture = $node->field_picture->entity; + $alt = $node->field_picture->alt; + $picture_langcode = $node->field_picture->langcode; + $language = $node->langcode->language; + $bundle = $node->type->entity; + $bundle_list = $node->type; + foreach ($node->title as $item) { + $looped = $item->value; + } + $untyped = $list->value; + $node->field_tags->entity->id(); + $node->langcode->language->getId(); + $list->anything; + $written = $node->title->first(); + $written->value = 'Example'; + $after = $written->value; + } +} +"#; +const PROPERTIES_PATH: &str = "web/modules/custom/acme/src/Properties.php"; + +/// A property read on a list is its first item's; a reference's `entity` +/// is its target class, or any entity when the field names none, and null +/// when the field is empty; the stored values stay `mixed`, written or not. +#[tokio::test] +async fn item_properties_are_typed_on_items_and_lists() { + let (backend, dir) = property_site(&[(PROPERTIES_PATH, PROPERTIES)]).await; + let uri = uri_for(&dir, PROPERTIES_PATH); + let hover = |var: &str| hovered_type(&backend, uri.as_str(), PROPERTIES, var); + for var in [ + "$value", + "$first", + "$owner_id", + "$alt", + "$picture_langcode", + "$looped", + "$untyped", + "$after", + ] { + let text = hover(var); + assert!(text.contains("mixed"), "{var}: {text}"); + } + for (var, class) in [ + ("$owner", "User"), + ("$item_owner", "User"), + ("$tag", "Term"), + ("$any", "EntityInterface"), + ("$picture", "Note"), + ("$language", "LanguageInterface"), + ("$bundle", "NodeType"), + ] { + let text = hover(var); + assert!(text.contains(&format!("?{class}")), "{var}: {text}"); + } + let text = hover("$tags"); + assert!(text.contains("array"), "{text}"); + // The bundle field is a reference only, since node names a bundle + // entity type. + let text = hover("$bundle_list"); + assert!( + text.contains("EntityReferenceFieldItemList") + && !text.contains("StringItem"), + "{text}" + ); + + let mut messages = Vec::new(); + backend.update_ast(uri.as_str(), PROPERTIES); + backend.collect_slow_diagnostics(uri.as_str(), PROPERTIES, &mut messages); + let messages: Vec = messages + .iter() + .map(|diagnostic| format!("{:?}", diagnostic.message)) + .collect(); + assert!(messages.is_empty(), "{messages:?}"); +} + +const PAYLOAD_PATH: &str = "web/modules/custom/acme/src/Plugin/Field/FieldType/PayloadItem.php"; +const PAYLOAD: &str = r#" $item + * @param FieldItemList> $list + */ + public function read($item, $list): void { + $from_item = $item->payload(); + $from_list = $list->first()->payload(); + } +} +"#; + +/// A field item class with templates of its own keeps the type arguments a +/// type hint gives it, alone or in a list. +#[tokio::test] +async fn a_templated_item_keeps_its_type_arguments() { + let (backend, dir) = site(&[(PAYLOAD_PATH, PAYLOAD), (PAYLOADS_PATH, PAYLOADS)]).await; + let uri = uri_for(&dir, PAYLOADS_PATH); + for var in ["$from_item", "$from_list"] { + let text = hovered_type(&backend, uri.as_str(), PAYLOADS, var); + assert!(text.contains("Note"), "{var}: {text}"); + } +} + +const RENAMED_PATH: &str = "web/modules/custom/acme/src/Renamed.php"; +const RENAMED: &str = r#"body; + $node->field_tags; + $node->field_picture->alt; + } +} +"#; + +/// A field or an item property is named in places a property rename does +/// not edit (`get()` calls, field storage files, their names), so neither +/// is offered for renaming. +#[tokio::test] +async fn fields_and_item_properties_are_not_renamed() { + let (backend, dir) = property_site(&[(RENAMED_PATH, RENAMED)]).await; + let uri = uri_for(&dir, RENAMED_PATH); + for access in ["$note->body", "$node->field_tags", "->alt"] { + let at = position_in(RENAMED, access, access.len() - 1); + assert!( + crate::common::prepare_rename(&backend, &uri, at.line, at.character) + .await + .is_none(), + "{access}" + ); + assert!( + crate::common::rename(&backend, &uri, at.line, at.character, "renamed") + .await + .is_none(), + "{access}" + ); + } +} + +/// Editing an item's `propertyDefinitions()` refreshes its properties on +/// the item and on the lists that hold it. +#[tokio::test] +async fn editing_property_definitions_refreshes_items_and_lists() { + let (backend, dir) = property_site(&[(PICTURE_ITEM_PATH, PICTURE_ITEM)]).await; + for labels in [ + members_of(&backend, &dir, PICTURE).await, + members_after(&backend, &dir, "$node->field_picture").await, + ] { + assert!(offers(&labels, "alt"), "{labels:?}"); + assert!(!offers(&labels, "caption"), "{labels:?}"); + } + + let edited = PICTURE_ITEM.replace("$properties['alt']", "$properties['caption']"); + edit(&backend, &uri_for(&dir, PICTURE_ITEM_PATH), &edited).await; + + for labels in [ + members_of(&backend, &dir, PICTURE).await, + members_after(&backend, &dir, "$node->field_picture").await, + ] { + assert!(offers(&labels, "caption"), "{labels:?}"); + assert!(!offers(&labels, "alt"), "{labels:?}"); + } +} + +/// Outside a Drupal project an item class has only what it declares. +#[tokio::test] +async fn items_have_no_properties_outside_drupal() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("composer.json"), "{}").unwrap(); + for (path, content) in CORE.iter().chain(&[ + (ATTACHMENT_ITEM_PATH, ATTACHMENT_ITEM), + (PICTURE_ITEM_PATH, PICTURE_ITEM), + ]) { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let backend = Backend::new_test(); + initialize_with_options(&backend, dir.path(), None).await; + let labels = members_of(&backend, &dir, PICTURE).await; + assert!(!offers(&labels, "alt"), "{labels:?}"); +} + +/// An item class the editor restores while the workspace is read gives its +/// properties to the fields that hold it once it is. +#[tokio::test] +async fn item_files_opened_before_indexing_ends_give_their_properties() { + let picture = storage("node", "field_picture", "acme_picture", None); + let dir = tempfile::tempdir().unwrap(); + for (path, content) in CORE.iter().chain(&[ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ("config/sync/field.storage.node.field_picture.yml", &picture), + ]) { + 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_opened_early( + dir, + &[ + (ATTACHMENT_ITEM_PATH, ATTACHMENT_ITEM), + (PICTURE_ITEM_PATH, PICTURE_ITEM), + ], + ) + .await; + backend.ensure_workspace_indexed(); + let labels = members_after(&backend, &dir, "$node->field_picture").await; + assert!(offers(&labels, "alt"), "{labels:?}"); + assert!(!offers(&labels, "display"), "{labels:?}"); +} + +/// A custom entity type reached through two interfaces of its own. +const MEMO_PATH: &str = "web/modules/custom/acme/src/Entity/Memo.php"; +const MEMO: &str = r#" 'id', 'bundle' => 'kind'], +)] +class Memo extends ContentEntityBase implements MemoInterface { + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields = parent::baseFieldDefinitions($entity_type); + $fields['subject'] = BaseFieldDefinition::create('string'); + return $fields; + } +} +"#; + +/// An interface whose fields come through an interface nothing else +/// resolves refreshes when its entity class changes, and an entity type +/// with a bundle key but no bundle entity type has a string bundle field. +#[tokio::test] +async fn editing_base_fields_refreshes_interfaces_further_down() { + let (backend, dir) = site_with( + &[ + ( + "web/modules/custom/acme/src/Entity/MemoBaseInterface.php", + "kind", + ) + .await; + assert!(offers(&kind, "value"), "{kind:?}"); + assert!(!offers(&kind, "entity"), "{kind:?}"); + + let edited = MEMO.replace("$fields['subject']", "$fields['topic']"); + edit(&backend, &uri_for(&dir, MEMO_PATH), &edited).await; + + let after = members_of(&backend, &dir, "Drupal\\acme\\Entity\\MemoInterface").await; + assert!(offers(&after, "topic"), "{after:?}"); + assert!(!offers(&after, "subject"), "{after:?}"); +} + +// ─── Field names in code ──────────────────────────────────────────────────── + +const NAMED_PATH: &str = "web/modules/custom/acme/src/Named.php"; + +/// The calls that take a field name, each written with the arguments after +/// the name. +const FIELD_NAME_CALLS: &[(&str, &str)] = &[ + ("get", ""), + ("set", ", 'Example'"), + ("hasField", ""), + ("getFieldDefinition", ""), +]; + +/// Every call that takes a field name, on an interface receiver and on a +/// class receiver, for a base field and a configurable field, one per +/// line, then the fields read as properties, and the same calls on +/// receivers that are not fieldable. +fn named() -> String { + let mut body = String::new(); + for field in ["title", "field_tags"] { + for receiver in ["$node", "$entity"] { + for (method, rest) in FIELD_NAME_CALLS { + body.push_str(&format!(" {receiver}->{method}('{field}'{rest});\n")); + } + } + } + format!( + r#"title; + $entity->field_tags; + $bag->get('title'); + $fake->get('title'); + $plain->get('title'); + $node->get($name); + }} +}} + +final class Bag {{ + public function get($key) {{}} +}} + +final class Fake implements \FieldableEntityInterface {{ + public function get($field_name) {{}} +}} +"# + ) +} + +/// An interface named like core's, outside core's namespace. +const GLOBAL_FIELDABLE: (&str, &str) = ( + "web/modules/custom/acme/src/FieldableEntityInterface.php", + " String { + storage( + "node", + "field_tags", + "entity_reference", + Some("taxonomy_term"), + ) +} + +/// A site with node's `field_tags` exported, and `open` open. +async fn named_site(open: &[(&str, &str)]) -> (Backend, tempfile::TempDir) { + let tags = tags_storage(); + site_with(&[(SYNC_TAGS, &tags), GLOBAL_FIELDABLE], open).await +} + +/// The position in the string the first `call` in `content` opens, +/// `inside` characters past its quote. +fn named_site_at( + content: &str, + call: &str, + inside: usize, +) -> tower_lsp_server::gen_lsp_types::Position { + position_in(content, call, call.find('\'').unwrap() + 1 + inside) +} + +/// The line of `call` in `content`. +fn line_of(content: &str, call: &str) -> u32 { + position_in(content, call, 0).line +} + +/// Completion, go-to-definition, hover, and references work on each call +/// that takes a field name, for a base field and a configurable field, on +/// an interface receiver and on a class receiver. +#[tokio::test] +async fn field_names_complete_navigate_and_hover_at_every_call() { + let named = named(); + let (backend, dir) = named_site(&[(NAMED_PATH, &named)]).await; + let uri = uri_for(&dir, NAMED_PATH); + let tags = tags_storage(); + // Each field: where it is declared, what its hover says, and the lines + // that read it as a property. + let title_declared = ( + uri_for(&dir, NODE_PATH), + position_in(NODE, "$fields['title']", 9), + ); + let tags_declared = ( + uri_for(&dir, SYNC_TAGS), + position_in(&tags, "field_name: field_tags", "field_name: ".len()), + ); + let fields = [ + ( + "title", + &title_declared, + [ + "Field type `string`", + "FieldItemList", + "Node.php", + ], + "$node->title;", + ), + ( + "field_tags", + &tags_declared, + [ + "Field type `entity_reference`", + "EntityReferenceFieldItemList", + "field.storage.node.field_tags.yml", + ], + "$entity->field_tags;", + ), + ]; + for (field, (declared_uri, declared_at), hover_parts, property_read) in fields { + // Every line that names the field, and the file that declares it. + let mut expected: Vec<(tower_lsp_server::gen_lsp_types::Uri, u32)> = Vec::new(); + for receiver in ["$node", "$entity"] { + for (method, _) in FIELD_NAME_CALLS { + let call = format!("{receiver}->{method}('{field}'"); + expected.push((uri.clone(), line_of(&named, &call))); + } + } + expected.push((uri.clone(), line_of(&named, property_read))); + expected.push((declared_uri.clone(), declared_at.line)); + expected.sort(); + for receiver in ["$node", "$entity"] { + for (method, _) in FIELD_NAME_CALLS { + let call = format!("{receiver}->{method}('{field}'"); + let at = named_site_at(&named, &call, 1); + + let found = definitions_at(&backend, &uri, at).await; + assert_eq!(found.len(), 1, "{call}: {found:?}"); + assert_eq!(&found[0].uri, declared_uri, "{call}"); + assert_eq!(found[0].range.start, *declared_at, "{call}"); + + let hover = hover_at(&backend, uri.as_str(), &named, at.line, at.character) + .unwrap_or_else(|| panic!("no hover on {call}")); + let text = hover_text(&hover); + for part in hover_parts.iter().chain(&["Entity type `node`"]) { + assert!(text.contains(part), "{call}: {part} not in {text}"); + } + + let found = crate::drupal_services::references_at(&backend, &uri, at).await; + assert_eq!(found, expected, "{call}"); + + let at = named_site_at(&named, &call, 0); + let labels = crate::common::response_labels( + crate::common::completion_response(&backend, &uri, at.line, at.character, None) + .await, + ); + for offered in ["title", "field_tags", "nid", "uid"] { + assert!(offers(&labels, offered), "{call}: {offered} in {labels:?}"); + } + for not_offered in ["name", "body", "acme.settings"] { + assert!(!offers(&labels, not_offered), "{call}: {not_offered}"); + } + } + } + } +} + +/// A receiver that is not fieldable, core's interface's namesake outside +/// its namespace included, and a name built at runtime name no field. +#[tokio::test] +async fn other_receivers_and_runtime_names_name_no_field() { + let named = named(); + let (backend, dir) = named_site(&[(NAMED_PATH, &named)]).await; + let uri = uri_for(&dir, NAMED_PATH); + for call in [ + "$bag->get('title'", + "$fake->get('title'", + "$plain->get('title'", + ] { + let at = named_site_at(&named, call, 1); + assert!( + definitions_at(&backend, &uri, at).await.is_empty(), + "{call}" + ); + let hover = hover_at(&backend, uri.as_str(), &named, at.line, at.character) + .map(|hover| hover_text(&hover).to_string()) + .unwrap_or_default(); + assert!(!hover.contains("Entity type"), "{call}: {hover}"); + let at = named_site_at(&named, call, 0); + let labels = crate::common::response_labels( + crate::common::completion_response(&backend, &uri, at.line, at.character, None).await, + ); + assert!(!offers(&labels, "title"), "{call}: {labels:?}"); + } + let at = position_in(&named, "get($name)", 5); + let hover = hover_at(&backend, uri.as_str(), &named, at.line, at.character) + .map(|hover| hover_text(&hover).to_string()) + .unwrap_or_default(); + assert!(!hover.contains("Entity type"), "{hover}"); +} + +const SHARED_PATH: &str = "web/modules/custom/acme/src/Shared.php"; +const SHARED: &str = r#"get('uid'); + $authored->get('title'); + $content->get('uuid'); + $content->get('title'); + $node->get('uid'); + $node->get('title'); + } +} +"#; + +/// An interface only node's and user's entity classes implement. +const AUTHORED: (&str, &str) = ( + "web/modules/custom/acme/src/Authored.php", + "get('uid'", 1); + let mut found: Vec<_> = definitions_at(&backend, &uri, at) + .await + .into_iter() + .map(|location| location.uri) + .collect(); + found.sort(); + let mut expected = vec![ + uri_for( + &dir, + "web/core/lib/Drupal/Core/Entity/ContentEntityBase.php", + ), + uri_for(&dir, "web/core/modules/user/src/EntityOwnerTrait.php"), + ]; + expected.sort(); + assert_eq!(found, expected); + let text = hover("$authored->get('uid'"); + assert!(text.contains("Entity types `node`, `user`"), "{text}"); + assert!(text.contains("EntityOwnerTrait.php"), "{text}"); + assert!(text.contains("ContentEntityBase.php"), "{text}"); + let found = crate::drupal_services::references_at(&backend, &uri, at).await; + for call in ["$authored->get('uid'", "$node->get('uid'"] { + assert!( + found.contains(&(uri.clone(), line_of(SHARED, call))), + "{call}: {found:?}" + ); + } + + // Every content entity type has a UUID. + let at = named_site_at(SHARED, "$content->get('uuid'", 1); + assert_eq!(definitions_at(&backend, &uri, at).await.len(), 1); + assert!(!hover("$content->get('uuid'").contains("Not a")); + + // Only node has a title. + for call in ["$authored->get('title'", "$content->get('title'"] { + let at = named_site_at(SHARED, call, 1); + assert!( + definitions_at(&backend, &uri, at).await.is_empty(), + "{call}" + ); + let text = hover(call); + assert!(!text.contains("Field type"), "{call}: {text}"); + } + let at = named_site_at(SHARED, "$node->get('title'", 1); + let found = crate::drupal_services::references_at(&backend, &uri, at).await; + for call in ["$authored->get('title'", "$content->get('title'"] { + assert!( + !found.contains(&(uri.clone(), line_of(SHARED, call))), + "{call}: {found:?}" + ); + } +} + +/// Outside a Drupal project the same calls name nothing. +#[tokio::test] +async fn field_names_are_plain_strings_outside_drupal() { + let named = named(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("composer.json"), "{}").unwrap(); + let tags = tags_storage(); + for (path, content) in CORE + .iter() + .copied() + .chain([(NAMED_PATH, named.as_str()), (SYNC_TAGS, tags.as_str())]) + { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let backend = Backend::new_test(); + initialize_with_options(&backend, dir.path(), None).await; + let uri = uri_for(&dir, NAMED_PATH); + open_document(&backend, &uri, "php", &named).await; + let at = named_site_at(&named, "$node->get('title'", 1); + assert!(definitions_at(&backend, &uri, at).await.is_empty()); + let at = named_site_at(&named, "$node->get('title'", 0); + let labels = crate::common::response_labels( + crate::common::completion_response(&backend, &uri, at.line, at.character, None).await, + ); + assert!(!offers(&labels, "title"), "{labels:?}"); +} + +const TYPED_NAMES: &str = r#"get('title'); + $class_title = $entity->get('title'); + $tags = $node->get('field_tags'); + $unknown = $node->get('field_missing'); + $dynamic = $node->get($name); + $first_tag = $node->get('field_tags')->entity; + $class_first_tag = $entity->get('field_tags')->entity; + } +} +"#; +const TYPED_NAMES_PATH: &str = "web/modules/custom/acme/src/TypedNames.php"; + +/// `get('name')` is the field `->name` reads, assigned or chained, on an +/// interface and on a class; an unknown or a runtime name keeps the +/// declared item list interface. +#[tokio::test] +async fn get_with_a_field_name_is_typed_as_the_field() { + let (backend, dir) = named_site(&[(TYPED_NAMES_PATH, TYPED_NAMES)]).await; + let uri = uri_for(&dir, TYPED_NAMES_PATH); + let hover = |var: &str| hovered_type(&backend, uri.as_str(), TYPED_NAMES, var); + for var in ["$title", "$class_title"] { + let text = hover(var); + assert!(text.contains("FieldItemList"), "{var}: {text}"); + } + let text = hover("$tags"); + assert!( + text.contains("EntityReferenceFieldItemList"), + "{text}" + ); + for var in ["$unknown", "$dynamic"] { + let text = hover(var); + assert!(text.contains("FieldItemListInterface"), "{var}: {text}"); + assert!(!text.contains("StringItem"), "{var}: {text}"); + } + for var in ["$first_tag", "$class_first_tag"] { + let text = hover(var); + assert!(text.contains("Term"), "{var}: {text}"); + } +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 76cb7840d..60c7b8ce0 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -221,6 +221,7 @@ mod drupal_config; mod drupal_config_keys; mod drupal_directories; mod drupal_entities; +mod drupal_fields; mod drupal_hooks; mod drupal_libraries; mod drupal_mixed_workspace;