Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions benches/drupal_completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -74,6 +77,22 @@ const STUBS: &[(&str, &str)] = &[
"web/core/lib/Drupal/Core/Entity/Attribute/ContentEntityType.php",
"<?php\nnamespace Drupal\\Core\\Entity\\Attribute;\n#[\\Attribute(\\Attribute::TARGET_CLASS)]\nclass ContentEntityType {\n public function __construct(public readonly string $id) {}\n}\n",
),
(
"web/core/lib/Drupal/Core/Entity/EntityInterface.php",
"<?php\nnamespace Drupal\\Core\\Entity;\ninterface EntityInterface {\n public function id();\n}\n",
),
(
"web/core/lib/Drupal/Core/Entity/FieldableEntityInterface.php",
"<?php\nnamespace Drupal\\Core\\Entity;\ninterface FieldableEntityInterface extends EntityInterface {\n public function get($field_name);\n}\n",
),
(
"web/core/lib/Drupal/Core/Entity/ContentEntityBase.php",
"<?php\nnamespace Drupal\\Core\\Entity;\nabstract class ContentEntityBase implements FieldableEntityInterface {\n public function id() {}\n public function get($field_name) {}\n}\n",
),
(
"web/core/lib/Drupal/Core/Field/BaseFieldDefinition.php",
"<?php\nnamespace Drupal\\Core\\Field;\nclass BaseFieldDefinition {\n public static function create($type): static { return new static(); }\n}\n",
),
(
"web/core/lib/Drupal/Core/Render/Attribute/RenderElement.php",
"<?php\nnamespace Drupal\\Core\\Render\\Attribute;\n#[\\Attribute(\\Attribute::TARGET_CLASS)]\nclass RenderElement {\n public function __construct(public readonly string $id) {}\n}\n",
Expand Down Expand Up @@ -103,11 +122,13 @@ use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\mod1\Entity\Entity0;

final class Consumer
{
public function __construct(
private EntityTypeManagerInterface $manager,
private Entity0 $entity,
private ModuleHandlerInterface $modules,
private AccountInterface $account,
private BlockManagerInterface $blocks,
Expand All @@ -126,6 +147,7 @@ final class Consumer
$this->blocks->createInstance('mod1_');
$themed = ['#theme' => 'mod1_'];
$build = ['#attached' => ['library' => ['mod1/']]];
$this->entity->get('mod1_');
}
}
"#;
Expand Down Expand Up @@ -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!(
"<?php\nnamespace Drupal\\mod{m}\\Entity;\n\nuse Drupal\\Core\\Entity\\Attribute\\ContentEntityType;\n\n#[ContentEntityType(\n id: 'mod{m}_entity{e}',\n)]\nclass Entity{e} {{}}\n"
"<?php\nnamespace Drupal\\mod{m}\\Entity;\n\nuse Drupal\\Core\\Entity\\Attribute\\ContentEntityType;\nuse Drupal\\Core\\Entity\\ContentEntityBase;\nuse Drupal\\Core\\Field\\BaseFieldDefinition;\n\n#[ContentEntityType(\n id: 'mod{m}_entity{e}',\n)]\nclass Entity{e} extends ContentEntityBase {{\n public static function baseFieldDefinitions($entity_type) {{\n $fields = [];\n{fields} return $fields;\n }}\n}}\n"
),
));
}
Expand Down Expand Up @@ -445,6 +474,13 @@ fn bench_drupal_name_completion(c: &mut Criterion) {
"['library' => ['mod1/'",
"mod1/library7",
),
(
"field_name",
&consumer,
CONSUMER_PHP,
"get('mod1_'",
"mod1_field7",
),
(
"hook_implementation",
&module,
Expand Down
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<array-key, Status>`, custom cast classes to their `get()` return type). Highest priority among virtual member providers. Scope methods are also injected onto `Builder<Model>` 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<DocblockMembers>`, `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<Item>` 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

Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
97 changes: 65 additions & 32 deletions docs/todo/drupal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.<entity>.<field>.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**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Loading
Loading