Skip to content
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Symfony service IDs and container parameters.** In a Symfony project, a service ID or parameter declared in YAML, XML, or a PHP configurator completes, jumps to its declaration, and lists its references wherever configuration or code names it, from `@app.mailer` and `%app.sender%` to `$container->get()` and `#[Autowire]`. An application's own `app.` name that nothing declares is reported. Contributed by @sidux.
- **Symfony services that bundles and the container builder declare.** The service IDs and parameters an installed bundle declares in its own configuration complete, jump to their declaration, and type `$container->get()`, and so do the ones a kernel, an extension, or a compiler pass declares with `register()`, `setAlias()`, `setDefinition()`, `setParameter()`, or `getKernelParameters()`. A parameter set under a name built at runtime, like `'app.' . $key`, leaves the names it can produce unreported. A configurator's `parent()` and the `%name%` placeholders in its arguments are read as the service and parameter they name.
- **Drupal services.** In a Drupal project, the service IDs and container parameters that core, contributed, and custom modules declare in their `*.services.yml` complete, jump to their declaration, and list their references wherever a services file, `$container->get()`, or `\Drupal::service()` names them, and a lookup is typed by the class the service is built from, or by the class or interface a lookup names by `::class`. Rename leaves core and contributed extensions alone, as it does vendor code. Contributed by @giuseppe-esposito-87.
- **Drupal entity types.** In a Drupal project, an entity type ID declared by an entity class's attribute or annotation completes, jumps to its declaration, and lists its references wherever the entity type manager is asked for it, and `getStorage()` is typed as that entity type's storage, so `load()`, `loadMultiple()`, `create()`, and the other loads return the entity class. An entity query started by `\Drupal::entityQuery()` or a storage's `getQuery()` gives the IDs of that entity type, or their number after `count()`. Contributed by @giuseppe-esposito-87.
- **Symfony route names.** In a Symfony project, a route name declared by a `#[Route]` attribute, the routing YAML or XML, or a PHP routing configurator completes, jumps to its declaration, and lists its references wherever a controller or a Twig template builds a URL to it, from `generateUrl()` and `redirectToRoute()` to `path()` and `url()`. Names are followed through the imports that load them, bundles' own routing files included, along with the ones Symfony derives from a route, such as `contact.en` for a localized one. A route name nothing declares is reported, unless the router is configured to load its routes some other way. Contributed by @sidux.
- **Symfony translations.** In a Symfony project, a translation key declared in a YAML or XLIFF catalogue completes, jumps to its entry in every locale, and lists its references wherever PHP or a Twig template translates it, from `trans()`, `TranslatableMessage`, and `t()` to the `trans` filter. A key is looked up in the domain the call names, and a domain jumps to its catalogues. A key spelled like one (`demo.title`) that nothing declares is reported, in a domain the project's own catalogues hold and no installed bundle ships. Contributed by @sidux.
- **Symfony Twig templates.** In a Symfony project, a template name completes, jumps to its file, and lists its references wherever a controller, an email, or another template names it, from `render()` and `#[Template]` to `extends` and `include`. A template the application's `templates/` directory does not hold is reported, with a quick fix that creates it, unless the project has a Twig loader of its own that could serve it. Contributed by @sidux.
Expand Down
4 changes: 2 additions & 2 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ ships, remove every row that names it.
| DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium |
| DR8 | [Permission and configuration names](todo/drupal.md#dr8-permission-and-configuration-names) | Low-Medium | Medium |
| DR15 | [Benchmark for Drupal names](todo/drupal.md#dr15-benchmark-for-drupal-names) (depends on DR2) | Low-Medium | Low |
| DR3 | [Entity storage and entity type IDs](todo/drupal.md#dr3-entity-storage-and-entity-type-ids) | High | Medium-High |
| DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High |
| DR4 | [Entity field access](todo/drupal.md#dr4-entity-field-access) (depends on DR3) | Medium-High | High |
| DR4 | [Entity field access](todo/drupal.md#dr4-entity-field-access) | Medium-High | High |
| DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium |
| DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium |
| DR18 | [Warn on unknown entity type IDs](todo/drupal.md#dr18-warn-on-unknown-entity-type-ids) | Medium | Low-Medium |
| DR21 | [Return types core's docblocks leave wide](todo/drupal.md#dr21-return-types-cores-docblocks-leave-wide) | Medium | Low-Medium |

## Yii
Expand Down
49 changes: 21 additions & 28 deletions docs/todo/drupal.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
Known gaps and missing features in PHPantom's Drupal support. The web
root is detected, core, contrib, and the Drupal file extensions
(`.module`, `.install`, `.theme`, …) are indexed past `.gitignore`, and
the services the `*.services.yml` files declare are read. Nothing reads
Drupal's other declarations yet: entity types, fields, routes, or hooks.
the services the `*.services.yml` files declare and the entity types
entity classes declare are read. Nothing reads Drupal's other
declarations yet: fields, routes, or hooks.

The requests in
[#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the
Expand All @@ -28,34 +29,9 @@ within the same impact tier.

---

## DR3. Entity storage and entity type IDs

**Impact: High · Complexity: Medium-High**

```php
$storage = \Drupal::entityTypeManager()->getStorage('node'); // NodeStorageInterface
$node = $storage->load($nid); // ?NodeInterface
```

`getStorage()` returns the generic `EntityStorageInterface`, so every
loaded entity is the generic `EntityInterface` and the methods a node
or user has are reported as unknown. The entity type ID maps to an
entity class and its storage handler through the entity type's
declaration: the `#[ContentEntityType]` / `#[ConfigEntityType]`
attribute, or the annotation older code uses. Read those statically,
type `getStorage()`, `load()`, `loadMultiple()`, and `create()` from
them, and complete and navigate entity type IDs. Entity queries
(`\Drupal::entityQuery('node')`, `$storage->getQuery()`) take the same
IDs and return IDs of that entity type.

**Where to look:** PHPStan's Drupal extension (its entity data
repository and the storage return-type extensions).

---

## DR4. Entity field access

**Impact: Medium-High · Complexity: High** (depends on DR3)
**Impact: Medium-High · Complexity: High**

```php
$tags = $node->field_tags; // FieldItemListInterface
Expand Down Expand Up @@ -349,6 +325,23 @@ lists, so an ID such a provider may register must not warn.

---

## DR18. Warn on unknown entity type IDs

**Impact: Medium · Complexity: Low-Medium**

```php
$storage = \Drupal::entityTypeManager()->getStorage('nod');
```

An entity type ID no entity class declares is a typo or a module the
site lacks, and the entity type manager throws for it at run time.
Report one where `getStorage()`, `\Drupal::entityQuery()`, and the other
calls that take an entity type ID take it. A module can add entity types
in code from `hook_entity_type_build()`, which no class declares, so a
site where an implementation of that hook may add one stays silent.

---

## DR21. Return types core's docblocks leave wide

**Impact: Medium · Complexity: Low-Medium**
Expand Down
10 changes: 10 additions & 0 deletions examples/drupal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ Run `composer install` here first. Composer puts core and contrib into
`core.services.yml`. Complete an ID inside `\Drupal::service('')`,
`$container->get('')`, or after `@` in `acme.services.yml`, and find the
references of a service from its declaration.
- Entity type IDs navigate between the entity class that declares them and
the code that asks the entity type manager for them, and type what that
code gets back. In `web/modules/custom/acme/src/NoteCounter.php`, hover
`$storage` and `$note` in `first()`, typed as the storage and as `Note`,
or go to the definition of `'acme_note'`, which lands on the `id` in
`Note`'s attribute. Complete an ID inside `getStorage('')`; core's entity
types are offered too. In `ids()` and `count()`, hover `$ids` and
`$count`: an entity query gives the IDs of its entity type, or their
number once it counts, and the ID inside `\Drupal::entityQuery('')`
navigates and completes too.

## What the Symfony support already reads

Expand Down
13 changes: 13 additions & 0 deletions examples/drupal/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
use Drupal\Core\DependencyInjection\YamlFileLoader;
use Drupal\Core\Entity\Attribute\ContentEntityType;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
use Drupal\Core\Extension\InfoParser;
use Drupal\Core\Hook\Attribute\Hook;

Expand Down Expand Up @@ -125,6 +128,16 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string
check("acme_note's label key is the title base field", ($noteType['entity_keys']['label'] ?? null) === 'title');
check('Note is a content entity', $note->isSubclassOf(ContentEntityBase::class));
check('Note declares its own base fields', $note->getMethod('baseFieldDefinitions')->getDeclaringClass()->getName() === Note::class);
// Attribute discovery tells the attribute which class it is on.
$noteAttribute = $note->getAttributes(ContentEntityType::class)[0]->newInstance();
$noteAttribute->setClass(Note::class);
$noteDefinition = $noteAttribute->get();
check("acme_note's storage, which NoteCounter::first() loads from, is core's SQL storage", $noteDefinition->getStorageClass() === SqlContentEntityStorage::class);
check('acme_note loads Note', $noteDefinition->getClass() === Note::class);
check("NoteCounter::ids() starts an entity query on acme_note", str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "\\Drupal::entityQuery('acme_note')"));
check('acme_note is a content entity type, whose entity query gives IDs under integer keys', $noteDefinition instanceof ContentEntityTypeInterface && $noteDefinition->entityClassImplements(ContentEntityInterface::class));
check('NoteCounter::count() counts the entity query it starts from the storage', str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "->getQuery()") && str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "->count()"));
check('NoteCounter::first() asks for acme_note', str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "getStorage('acme_note');"));

// ─── Hooks and theme hooks ──────────────────────────────────────────────────

Expand Down
40 changes: 37 additions & 3 deletions examples/drupal/web/modules/custom/acme/src/NoteCounter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Drupal\acme;

use Drupal\acme\Entity\Note;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
Expand All @@ -20,18 +21,51 @@ public function __construct(
public static function create(ContainerInterface $container): static
{
// Try: go to the definition of 'entity_type.manager', which lands in
// core's core.services.yml. Complete a service ID inside
// core's core.services.yml, or hover `$manager`, typed by the class
// core declares for it. Complete a service ID inside
// `$container->get('')`.
return new static($container->get('entity_type.manager'));
$manager = $container->get('entity_type.manager');

return new static($manager);
}

public function first(): ?Note
{
// Try: hover `$storage`, the storage of the acme_note entity type,
// and `$note`, typed as `Note`, the class that declares it. Go to the
// definition of 'acme_note', which lands on the `id` in Note's
// attribute, or complete an entity type ID inside `getStorage('')`.
$storage = $this->entityTypeManager->getStorage('acme_note');
$note = $storage->load(1);

return $note;
}

public function count(): int
{
return (int) $this->entityTypeManager
// Try: hover `$count`. An entity query that counts gives the number
// of matches, and keeps counting through the methods it chains.
$count = $this->entityTypeManager
->getStorage('acme_note')
->getQuery()
->accessCheck(false)
->count()
->execute();

return $count;
}

/**
* @return array<int, string>
*/
public function ids(): array
{
// Try: hover `$ids`, the IDs of the notes, which a content entity
// query gives as strings under integer keys. Go to the definition of
// 'acme_note', or complete an entity type ID inside
// `\Drupal::entityQuery('')`.
$ids = \Drupal::entityQuery('acme_note')->accessCheck(false)->execute();

return $ids;
}
}
4 changes: 3 additions & 1 deletion src/completion/laravel_string_keys/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind {
StringKeyKind::GateAbility => CompletionItemKind::Method,
StringKeyKind::Env => CompletionItemKind::Constant,
StringKeyKind::ServiceId => CompletionItemKind::Reference,
StringKeyKind::DrupalEntityType => CompletionItemKind::Class,
StringKeyKind::ContainerParameter => CompletionItemKind::Property,
StringKeyKind::SymfonyConfigKey => CompletionItemKind::Field,
StringKeyKind::SymfonyEvent | StringKeyKind::WordPressHook => CompletionItemKind::Event,
Expand Down Expand Up @@ -181,7 +182,8 @@ impl Backend {
| StringKeyKind::TwigTemplate
| StringKeyKind::SymfonyEvent
| StringKeyKind::MessengerBus
| StringKeyKind::YiiRoute => self.symfony_declared_names(*kind).into(),
| StringKeyKind::YiiRoute
| StringKeyKind::DrupalEntityType => self.symfony_declared_names(*kind).into(),
StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => {
self.twig_callable_names(*kind).into()
}
Expand Down
15 changes: 10 additions & 5 deletions src/completion/symfony_string_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,17 @@ impl Backend {
};
};
if call.is_static {
// `\Drupal::service('|')` and `\Drupal::hasService('|')`.
// `\Drupal::service('|')`, `\Drupal::hasService('|')`, and
// `\Drupal::entityQuery('|')`.
if call.arg_index != 0 || !self.is_drupal_project() {
return None;
}
let class = ctx.resolve_name_at(subject, cursor as u32);
return (call.arg_index == 0
&& self.is_drupal_project()
&& crate::drupal::is_service_lookup(&class, &call.method_name))
.then_some(StringKeyKind::ServiceId);
if crate::drupal::is_service_lookup(&class, &call.method_name) {
return Some(StringKeyKind::ServiceId);
}
return crate::drupal::is_entity_query(&class, &call.method_name)
.then_some(StringKeyKind::DrupalEntityType);
}
self.receiver_call_key(
subject,
Expand Down
4 changes: 3 additions & 1 deletion src/diagnostics/laravel_string_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ impl CheckedStringKind {
// since another plugin or a later core may fire it. A block
// name that nothing above defines starts a new block. A
// template variable no render was found to pass may be passed
// by one that builds its context at runtime.
// by one that builds its context at runtime. A module can add
// an entity type in PHP, through `hook_entity_type_build()`.
StringKeyKind::WordPressHook
| StringKeyKind::DrupalEntityType
| StringKeyKind::TranslationDomain
| StringKeyKind::Section
| StringKeyKind::Stack
Expand Down
62 changes: 62 additions & 0 deletions src/drupal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ use parking_lot::RwLock;
pub(crate) mod types;

use crate::Backend;
use crate::atom::Atom;
use crate::symbol_map::{StringKeyKind, SymbolKind};

/// The web roots of the workspace's Drupal projects, and the declaration
/// files found under them.
Expand Down Expand Up @@ -69,6 +71,16 @@ pub(crate) fn is_service_lookup(class: &str, method: &str) -> bool {
&& (method.eq_ignore_ascii_case("service") || method.eq_ignore_ascii_case("hasService"))
}

/// Whether a static call of `method` on `class`, a resolved class name,
/// starts an entity query on an entity type ID: `\Drupal::entityQuery()` or
/// `\Drupal::entityQueryAggregate()`. Only the global `Drupal` class
/// counts.
pub(crate) fn is_entity_query(class: &str, method: &str) -> bool {
class.strip_prefix('\\').unwrap_or(class) == "Drupal"
&& (method.eq_ignore_ascii_case("entityQuery")
|| method.eq_ignore_ascii_case("entityQueryAggregate"))
}

/// Whether `path` is a file Drupal reads declarations from.
pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool {
path.file_name()
Expand Down Expand Up @@ -155,6 +167,56 @@ impl Backend {
})
}

/// The entity class and storage handler of each declaration of the
/// entity type `id`; a storage of `None` for a declaration that names
/// none and has no default.
pub(crate) fn drupal_entity_types(&self, id: &str) -> Vec<(Atom, Option<Atom>)> {
let mut found: Vec<(Atom, Option<Atom>)> = Vec::new();
self.visit_declaring_symbol_maps(|_, map| {
for site in &map.entity_types {
let at = map.spans.partition_point(|span| span.start < site.start);
let declares_id = map.spans[at..]
.iter()
.take_while(|span| span.start == site.start)
.any(|span| {
matches!(
&span.kind,
SymbolKind::StringKey {
kind: StringKeyKind::DrupalEntityType,
key,
..
} if key == id
)
});
if declares_id
&& !found
.iter()
.any(|(class, storage)| *class == site.class && *storage == site.storage)
{
found.push((site.class, site.storage));
}
}
});
found
}

/// Whether `uri` belongs to a test module of a Drupal project and is not
/// open: what it declares is left out, since no real site enables it.
pub(crate) fn is_closed_drupal_test_fixture(&self, uri: &str) -> bool {
if !self.is_drupal_project() {
return false;
}
let Some(path) = crate::util::uri_to_path(uri) else {
return false;
};
self.drupal
.web_roots
.read()
.iter()
.any(|root| path.starts_with(root) && is_test_fixture(&path, root))
&& !self.open_files.read().contains_key(uri)
}

/// Whether `uri` is a file a Drupal project loads services from: a
/// services file inside one of its web roots, outside test modules
/// unless it is open.
Expand Down
Loading
Loading