From a24aa77daa8746f64d42d53d5340c680378960a2 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 17:31:47 +0100 Subject: [PATCH 01/11] Read any property on a content entity interface Drupal content entities read their fields and any other stored value through ContentEntityBase::__get(), but the interfaces they are typed with (NodeInterface, ContentEntityInterface) declare no __get(), so every field read on them was reported as an unknown property. In a Drupal project, interfaces that extend FieldableEntityInterface now get the same untyped __get() the class has, so a read on them is mixed, as on the class. Interfaces without fields keep reporting unknown properties. Also adds the mapping from a fieldable receiver to the entity types it may hold, which the field readers build on. Assisted-by: claude-code/claude-opus-5-5 --- src/backend/construction.rs | 5 + src/drupal/fields.rs | 197 +++++++++++++++++++++++++ src/drupal/fields_tests.rs | 229 +++++++++++++++++++++++++++++ src/drupal/mod.rs | 1 + src/virtual_members/cache.rs | 11 ++ src/virtual_members/mod.rs | 11 +- src/virtual_members/resolve.rs | 13 +- src/virtual_members/tests.rs | 13 +- tests/integration/drupal_fields.rs | 211 ++++++++++++++++++++++++++ tests/integration/main.rs | 1 + 10 files changed, 683 insertions(+), 9 deletions(-) create mode 100644 src/drupal/fields.rs create mode 100644 src/drupal/fields_tests.rs create mode 100644 tests/integration/drupal_fields.rs diff --git a/src/backend/construction.rs b/src/backend/construction.rs index 995d1bbf7..ffff701f0 100644 --- a/src/backend/construction.rs +++ b/src/backend/construction.rs @@ -83,6 +83,11 @@ impl BackendState { Arc::clone(&is_doctrine), Arc::clone(&doctrine_mapping_files), )); + resolved_class_cache + .write() + .set_drupal(crate::drupal::fields::DrupalCacheState::new(Arc::clone( + &workspace.is_drupal, + ))); Self { name: "PHPantom".to_string(), version: env!("PHPANTOM_GIT_VERSION").to_string(), diff --git a/src/drupal/fields.rs b/src/drupal/fields.rs new file mode 100644 index 000000000..d66740833 --- /dev/null +++ b/src/drupal/fields.rs @@ -0,0 +1,197 @@ +//! 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. +//! +//! [`receiver_entity_types`] maps such a receiver to the entity types it may +//! hold, whose fields it has. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::atom::{Atom, atom}; +use crate::class_lookup::{Fqn, ResolvedClassLoader, is_subtype_of}; +use crate::types::{ + ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, MethodInfo, ParameterInfo, 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"; + +/// 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, +} + +impl Default for DrupalCacheState { + fn default() -> Self { + Self::new(Arc::new(AtomicBool::new(false))) + } +} + +impl DrupalCacheState { + pub(crate) fn new(enabled: Arc) -> Self { + Self { enabled } + } + + pub(crate) fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Acquire) + } +} + +/// 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 { + /// 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 applies_to(&self, class: &ClassInfo, class_loader: &ResolvedClassLoader<'_>) -> bool { + class.kind == ClassLikeKind::Interface + && !class + .methods + .iter() + .any(|method| method.name.eq_ignore_ascii_case("__get")) + && is_fieldable(class, class_loader) + } + + fn provide( + &self, + _class: &ClassInfo, + _class_loader: &ResolvedClassLoader<'_>, + _cache: Option<&ResolvedClassCache>, + ) -> VirtualMembers { + VirtualMembers { + methods: vec![Arc::new(magic_get()), Arc::new(magic_set())], + properties: Vec::new(), + constants: Vec::new(), + } + } +} + +/// `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. +#[cfg_attr( + not(test), + expect(dead_code, reason = "the field readers are its callers") +)] +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..bdb82942f --- /dev/null +++ b/src/drupal/fields_tests.rs @@ -0,0 +1,229 @@ +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"] + ); +} + +#[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() + ); +} diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index d6b539b33..f1d789e17 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod computed_hooks; pub(crate) mod config; pub(crate) mod config_keys; pub(crate) mod elements; +pub(crate) mod fields; pub(crate) mod hooks; pub(crate) mod libraries; pub(crate) mod links; diff --git a/src/virtual_members/cache.rs b/src/virtual_members/cache.rs index f075ea700..23703118b 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 @@ -345,6 +347,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/mod.rs b/src/virtual_members/mod.rs index 0c6cab854..07d74d137 100644 --- a/src/virtual_members/mod.rs +++ b/src/virtual_members/mod.rs @@ -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 magic `__get()` of content +/// entity 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,9 @@ 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 } diff --git a/src/virtual_members/resolve.rs b/src/virtual_members/resolve.rs index 02ef0431f..d0ec55355 100644 --- a/src/virtual_members/resolve.rs +++ b/src/virtual_members/resolve.rs @@ -838,10 +838,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 +877,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..8ce660807 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(), + 2, + "a Drupal project should register the PHPDoc and fieldable entity providers" + ); +} + // ── resolve_class_fully tests ─────────────────────────────────────── #[test] diff --git a/tests/integration/drupal_fields.rs b/tests/integration/drupal_fields.rs new file mode 100644 index 000000000..77d02078d --- /dev/null +++ b/tests/integration/drupal_fields.rs @@ -0,0 +1,211 @@ +//! 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:?}" + ); +} 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; From 17d258a18da23b4afd3b270ac1d0c9da4cf578c9 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 17:51:53 +0100 Subject: [PATCH 02/11] Keep the type of a property a magic setter stores A property a framework stores through __set(), such as a Drupal entity field, now counts as a magic write: assigning to it no longer narrows a later read to the assigned value, since the setter stores the value into the field and the read keeps the field's type. Properties declared by @property tags still narrow as before. Assisted-by: claude-code/claude-opus-5-5 --- src/hover/member.rs | 2 +- src/mem_audit.rs | 5 ++++ src/types/property.rs | 22 ++++++++++++++ src/virtual_members/resolve.rs | 16 +++++++++-- src/virtual_members/tests.rs | 52 ++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/hover/member.rs b/src/hover/member.rs index aff168c29..79b1d782e 100644 --- a/src/hover/member.rs +++ b/src/hover/member.rs @@ -166,7 +166,7 @@ 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 { .. } => Vec::new(), } } diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 44f28cd6b..f8c21332b 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -480,6 +480,11 @@ fn property_source(src: &PropertySource) -> Sz { z += vs(pivot_columns); } PropertySource::RelationshipCount { relationship } => z.add(relationship.capacity()), + PropertySource::DrupalField { declarations } => { + for declaration in declarations.iter() { + z.add(size_of::() + declaration.uri.capacity()); + } + } PropertySource::Pivot | PropertySource::DocblockTag | PropertySource::PromotedParameter => { } } diff --git a/src/types/property.rs b/src/types/property.rs index cb71dadbb..91f18553b 100644 --- a/src/types/property.rs +++ b/src/types/property.rs @@ -165,6 +165,22 @@ 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. + DrupalField { + /// Where the field is declared, in no particular order. + declarations: std::sync::Arc<[FieldDeclaration]>, + }, +} + +/// A place a framework declares a field a [`PropertySource`] names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldDeclaration { + pub uri: String, + /// Byte offset of the field's name in the file. + pub offset: u32, } /// What a property's docblock says about writing to it. @@ -202,6 +218,12 @@ 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 { .. })) + } + /// 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/resolve.rs b/src/virtual_members/resolve.rs index d0ec55355..a290ce955 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`. diff --git a/src/virtual_members/tests.rs b/src/virtual_members/tests.rs index 8ce660807..ef10fea21 100644 --- a/src/virtual_members/tests.rs +++ b/src/virtual_members/tests.rs @@ -2099,3 +2099,55 @@ 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 { + declarations: Arc::from([]), + }), + ..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)); +} From faac3ef805c6199ed603fb80e51b0cfec1f71da9 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 17:56:38 +0100 Subject: [PATCH 03/11] Read the keys and translatability of Drupal entity types The entity type reader now also keeps the entity_keys and revision_metadata_keys a declaration writes, whether it is translatable, and whether it is a content entity type, from attributes and from annotations alike. Annotation values written as bare words, such as translatable = TRUE, are now read too. Assisted-by: claude-code/claude-opus-5-5 --- src/mem_audit.rs | 11 +- src/symbol_map/extraction/drupal.rs | 280 ++++++++++++++++++++++++---- src/symbol_map/string_keys.rs | 9 + 3 files changed, 260 insertions(+), 40 deletions(-) diff --git a/src/mem_audit.rs b/src/mem_audit.rs index f8c21332b..218dfd8df 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1419,7 +1419,16 @@ 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::(), + ); sym.add( sm.drupal_plugin_classes.capacity() * size_of::(), diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index 76afd8c6d..13e978c8e 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -70,15 +70,20 @@ 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), "admin_permission" => { push_laravel_string_span( StringKeyKind::DrupalPermission, @@ -93,7 +98,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 +124,87 @@ 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, }); } } +/// 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, +} + +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), + } + } +} + +/// 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 +313,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)) } @@ -2492,38 +2562,35 @@ 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"), + (["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 +2602,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 +2623,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 +2653,7 @@ fn annotation_entries(doc: &str, from: usize) -> Vec> { key: name, value_start: start, value_end: end, + quoted: true, }); } assigned = false; @@ -2636,8 +2706,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; @@ -3741,6 +3820,123 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { ))); } + /// The entity type declarations `php` makes. + fn entity_type_sites(php: &str) -> Vec { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + 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, + ) + .entity_types + } + + 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 +3979,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 +4000,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 +4008,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/string_keys.rs b/src/symbol_map/string_keys.rs index cce748019..aa77deb7b 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -853,6 +853,15 @@ 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 class a Drupal plugin declaration is written on, such as a render From 59e13997396162db500f275c8e6c48c5d2aa83a0 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 18:01:42 +0100 Subject: [PATCH 04/11] Keep the item list class of Drupal field types The plugin index now keeps the list_class a field type's attribute or annotation names, so a field's item list class can be found from its field type. Assisted-by: claude-code/claude-opus-5-5 --- src/drupal/plugins.rs | 22 +++++++++ src/drupal/tests.rs | 41 ++++++++++++++++ src/symbol_map/extraction/drupal.rs | 75 ++++++++++++++++++++++++++--- src/symbol_map/string_keys.rs | 2 + 4 files changed, 133 insertions(+), 7 deletions(-) diff --git a/src/drupal/plugins.rs b/src/drupal/plugins.rs index b5acbe5fd..e6243e478 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, }); } } @@ -546,6 +549,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 +651,24 @@ 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`. + #[cfg_attr( + not(test), + expect(dead_code, reason = "the field readers are its callers") + )] + 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", + " (id, start, end), Some(_) => continue, @@ -373,15 +380,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, @@ -391,6 +406,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, }); } } @@ -3822,6 +3838,12 @@ 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()); @@ -3842,7 +3864,46 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { }, None, ) - .entity_types + } + + #[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)> { diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index aa77deb7b..1a9c6e738 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -939,6 +939,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 From 6e15fff5cb094ffe9b85de975c52c5a5948c5000 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 18:40:54 +0100 Subject: [PATCH 05/11] Type the base fields of Drupal content entities The base fields an entity class's baseFieldDefinitions() declares are now typed properties of the class and of the interfaces it implements: $node->title is a FieldItemList, and a reference field such as $node->uid also names the class of the entity type it points to. Fields taken from the parent class and from core's owner, published and revision log helpers count, under the names the entity type's keys give them, as do the boolean fields Drupal's entity field manager adds itself. An interface several entity classes implement has the fields all of them have. The names complete after ->, go-to-definition lands on the line that declares the field, and writing to a field keeps its type. Editing an entity class or a field type refreshes the fields everywhere they show. Assisted-by: claude-code/claude-opus-5-5 --- docs/ARCHITECTURE.md | 3 +- examples/drupal/README.md | 8 + examples/drupal/assertions.php | 39 +- .../modules/custom/acme/src/Entity/Note.php | 11 +- .../custom/acme/src/Entity/NoteInterface.php | 14 + .../modules/custom/acme/src/NoteSummary.php | 29 + src/backend/construction.rs | 10 +- src/backend/settings.rs | 2 + src/definition/member/mod.rs | 8 + src/drupal/base_fields.rs | 568 +++++++++++++++ src/drupal/base_fields_tests.rs | 56 ++ src/drupal/fields.rs | 305 +++++++- src/drupal/fields_tests.rs | 21 + src/drupal/mod.rs | 29 +- src/drupal/plugins.rs | 53 +- src/hover/member.rs | 2 +- src/mem_audit.rs | 28 +- src/symbol_map/extraction/class_like.rs | 2 + src/symbol_map/extraction/drupal.rs | 401 +++++++++- src/symbol_map/extraction/mod.rs | 3 + src/symbol_map/map.rs | 3 + src/symbol_map/string_keys.rs | 70 ++ src/symfony/mod.rs | 2 + src/types/property.rs | 19 +- src/virtual_members/mod.rs | 4 +- src/virtual_members/tests.rs | 4 +- tests/integration/drupal_fields.rs | 682 ++++++++++++++++++ 27 files changed, 2305 insertions(+), 71 deletions(-) create mode 100644 examples/drupal/web/modules/custom/acme/src/Entity/NoteInterface.php create mode 100644 examples/drupal/web/modules/custom/acme/src/NoteSummary.php create mode 100644 src/drupal/base_fields.rs create mode 100644 src/drupal/base_fields_tests.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85a351e15..bc17ac5ae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -905,10 +905,11 @@ 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 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, and the Backend reads the index when it loads `FieldableEntityInterface` (a class-load hook). An entity class's fields show on interfaces that do not depend on the entity class, so a change to them evicts `FieldableEntityInterface` and everything under it. ### Laravel Class Patches diff --git a/examples/drupal/README.md b/examples/drupal/README.md index a58191439..6b442b851 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -43,6 +43,14 @@ 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`. - 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..526bfdc2d 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; @@ -34,6 +36,9 @@ use Drupal\Core\Block\BlockManager; 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; @@ -66,6 +71,9 @@ use Drupal\Core\Url; use Drupal\language\ConfigurableLanguageManager; use Drupal\language\LanguageServiceProvider; +use Drupal\user\Entity\User; +use Drupal\user\EntityOwnerInterface; +use Drupal\user\EntityOwnerTrait; $passed = 0; $failed = 0; @@ -98,10 +106,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 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", '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 +248,31 @@ 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)); +check('NoteSummary returns a string', (new ReflectionMethod(NoteSummary::class, 'summarize'))->getReturnType()?->getName() === 'string'); + // 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/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; + + return sprintf('%s (%d)', (string) $title->value, count($author)); + } +} diff --git a/src/backend/construction.rs b/src/backend/construction.rs index ffff701f0..4a8ed5185 100644 --- a/src/backend/construction.rs +++ b/src/backend/construction.rs @@ -83,11 +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, - ))); + .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(), @@ -132,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/definition/member/mod.rs b/src/definition/member/mod.rs index 7e28b4aa7..b07d6202d 100644 --- a/src/definition/member/mod.rs +++ b/src/definition/member/mod.rs @@ -321,6 +321,14 @@ impl Backend { return Some(location); } + // A Drupal entity field goes to where its entity type declares it. + if lookup.access_hint == MemberAccessHint::PropertyAccess + && let Some(location) = + self.drupal_field_location(lookup_class, &effective_name, lookup.class_loader) + { + return Some(location); + } + let is_model = extends_eloquent_model(lookup_class, lookup.class_loader); if is_model && let Some(location) = diff --git a/src/drupal/base_fields.rs b/src/drupal/base_fields.rs new file mode 100644 index 000000000..648adf3df --- /dev/null +++ b/src/drupal/base_fields.rs @@ -0,0 +1,568 @@ +//! 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, 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, EntityTypeSite, 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, +} + +impl FieldSources { + /// What `map`, the symbol map of the file at `uri`, gives, if anything. + fn of(uri: &str, map: &SymbolMap) -> Option { + if map.entity_types.is_empty() && map.drupal_base_field_methods.is_empty() { + return None; + } + Some(Self { + uri: Arc::from(uri), + entity_types: super::entity_type_declarations(map) + .map(|(id, site)| (id.to_string(), site.clone())) + .collect(), + methods: map.drupal_base_field_methods.clone(), + }) + } + + /// 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)) + } +} + +/// 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>, +} + +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, + } + } + + /// 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, 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 { + let from = match include.scope { + MethodScope::Static => Some(entity_class), + MethodScope::Self_ => Some(owner), + MethodScope::Parent => self.load(owner).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)); + } + } + } + // 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; + }; + let creation = Creation { + field_type: &field.field_type, + target_type: field.target_type.as_deref(), + declaration: Some(FieldDeclaration { + 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`, as PHP finds it: the class's own, then its traits', then + /// its parent's. + fn find( + &self, + start: Atom, + method: &str, + ) -> Option<(Atom, &'a Arc, &'a BaseFieldMethodSite)> { + let method = method.to_ascii_lowercase(); + let mut current = Some(start); + let mut depth = 0; + while let Some(class) = current + && depth < MAX_INHERITANCE_DEPTH + { + depth += 1; + if let Some(found) = self.declared_in(class, &method, 0) { + return Some(found); + } + current = self.load(class).and_then(|info| info.parent_class); + } + None + } + + /// The base field method `method` of `class` or of a trait it uses. + fn declared_in( + &self, + class: Atom, + method: &str, + depth: u32, + ) -> Option<(Atom, &'a Arc, &'a BaseFieldMethodSite)> { + if let Some((uri, site)) = self + .methods + .get(&(class.to_ascii_lowercase(), method.to_string())) + { + return Some((class, uri, site)); + } + if depth >= MAX_INHERITANCE_DEPTH { + return None; + } + self.load(class)? + .used_traits + .iter() + .find_map(|used| self.declared_in(*used, method, depth + 1)) + } + + fn load(&self, class: Atom) -> Option> { + (self.class_loader)(Fqn::new(&class)) + } +} + +/// 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)) + .filter(|_| { + // 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())) + }); + 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, + }; + { + 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(); + } + } + + /// 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. + pub(crate) fn invalidate_drupal_fields(&self) { + self.drupal.fields.invalidate(); + { + let mut cache = self.resolved_class_cache.write(); + crate::virtual_members::evict_fqn(&mut cache, FIELDABLE_ENTITY); + } + // A cached receiver resolution records no dependency on the files + // the fields are read from. + self.clear_resolved_member_files(); + } + + /// 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 loader = self.resolved_class_loader_with(&[]); + let mut methods = HashMap::new(); + let mut declared: Vec<(&str, &EntityTypeSite)> = Vec::new(); + for source in &sources { + 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 &(id, site) in declared.iter().filter(|(_, site)| site.content) { + 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); + 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) + }) + .collect(), + ); + let mut declarations = Vec::new(); + for creation in creations { + if let Some(declaration) = creation.declaration + && !declarations.contains(&declaration) + { + declarations.push(declaration); + } + } + Field { + name, + ty, + declarations, + } + }) + .collect(); + index.entity_types.push(EntityTypeFields { + id: id.to_string(), + class: site.class, + fields, + }); + } + index + } + + /// Where the field `name` of `receiver` is declared: the first of the + /// declarations the entity types it may hold give, when the property + /// `name` of `receiver` is that field. + pub(crate) fn drupal_field_location( + &self, + receiver: &ClassInfo, + name: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> Option { + if !self.is_drupal_project() { + return None; + } + 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.writes_through_magic_set()) + { + return None; + } + let index = self.drupal_field_index()?; + let field = index + .fields_of(receiver, class_loader) + .into_iter() + .find(|field| field.name == name)?; + let declaration = field.declarations.first()?; + let (start, end) = { + let sources = self.drupal.field_sources.read(); + let site = sources + .get(&*declaration.uri)? + .methods + .iter() + .find(|site| { + site.owner == declaration.owner && site.method == declaration.method + })?; + let statement = site.fields.get(declaration.statement)?; + (statement.start, statement.end) + }; + let uri = &*declaration.uri; + 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), + }, + }) + } +} + +/// 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. +fn field_type( + classes: &[(Atom, Option)], + target_type: Option<&str>, + entity_types: &[(&str, &EntityTypeSite)], +) -> PhpType { + if classes.is_empty() { + return PhpType::named(crate::atom::atom(FIELD_ITEM_LIST_INTERFACE)); + } + let mut targets: Vec = Vec::new(); + if let Some(target_type) = target_type { + for (id, site) in entity_types { + let target = PhpType::named(site.class); + if *id == target_type && !targets.contains(&target) { + targets.push(target); + } + } + } + PhpType::union( + classes + .iter() + .map(|(item, list)| { + let mut args = vec![PhpType::named(*item)]; + if !targets.is_empty() { + args.push(PhpType::union(targets.clone())); + } + PhpType::generic_atom( + list.unwrap_or_else(|| crate::atom::atom(FIELD_ITEM_LIST)), + args, + ) + }) + .collect(), + ) +} + +#[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..3c12bdc73 --- /dev/null +++ b/src/drupal/base_fields_tests.rs @@ -0,0 +1,56 @@ +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).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'"), + ] { + assert!(!before.declares_as(&sources(&changed)), "{changed}"); + } +} diff --git a/src/drupal/fields.rs b/src/drupal/fields.rs index d66740833..59d8c5715 100644 --- a/src/drupal/fields.rs +++ b/src/drupal/fields.rs @@ -8,16 +8,26 @@ //! [`FieldableEntityProvider`] gives them the same untyped ones the class //! has. //! -//! [`receiver_entity_types`] maps such a receiver to the entity types it may -//! hold, whose fields it 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`]), +//! 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, Ordering}; +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::types::{ - ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, MethodInfo, ParameterInfo, SharedVec, + ClassInfo, ClassLikeKind, MAX_INHERITANCE_DEPTH, MethodInfo, ParameterInfo, PropertyInfo, + PropertySource, SharedVec, }; use crate::virtual_members::{ResolvedClassCache, VirtualMemberProvider, VirtualMembers}; @@ -32,17 +42,20 @@ pub(crate) struct DrupalCacheState { /// 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))) + Self::new(Arc::new(AtomicBool::new(false)), Arc::default()) } } impl DrupalCacheState { - pub(crate) fn new(enabled: Arc) -> Self { - Self { enabled } + pub(crate) fn new(enabled: Arc, fields: Arc) -> Self { + Self { enabled, fields } } pub(crate) fn is_enabled(&self) -> bool { @@ -50,6 +63,223 @@ impl DrupalCacheState { } } +/// 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>, +} + +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); + 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)); + 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() + } + + /// 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, + /// Where the field is declared. None for the fields Drupal's entity + /// field manager adds itself. + pub declarations: Vec, +} + +/// A statement that declares a field: the one at `statement` among those +/// the base field method `method` of `owner` in the file at `uri` creates +/// fields with. 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) struct FieldDeclaration { + pub uri: Arc, + pub owner: Atom, + pub method: Atom, + pub statement: usize, +} + +/// 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. +#[derive(Debug, Default)] +pub(crate) struct FieldIndex { + pub entity_types: Vec, +} + +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. +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 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 { @@ -70,32 +300,63 @@ pub(super) fn extends_or_implements(class: &ClassInfo) -> bool { pub(crate) struct FieldableEntityProvider; impl VirtualMemberProvider for FieldableEntityProvider { - /// 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 applies_to(&self, class: &ClassInfo, class_loader: &ResolvedClassLoader<'_>) -> bool { - class.kind == ClassLikeKind::Interface - && !class - .methods - .iter() - .any(|method| method.name.eq_ignore_ascii_case("__get")) + 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>, + 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: vec![Arc::new(magic_get()), Arc::new(magic_set())], - properties: Vec::new(), + methods, + properties, constants: Vec::new(), } } } +/// The field index the `Backend` reads into `cache`. +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 { @@ -140,10 +401,6 @@ fn untyped_parameter(name: &str) -> ParameterInfo { /// class extends its entity type's class. None unless `receiver` is /// fieldable: an `EntityInterface` holds every entity type, but has no /// fields. -#[cfg_attr( - not(test), - expect(dead_code, reason = "the field readers are its callers") -)] pub(crate) fn receiver_entity_types<'a>( receiver: &ClassInfo, entity_types: impl IntoIterator, diff --git a/src/drupal/fields_tests.rs b/src/drupal/fields_tests.rs index bdb82942f..0166d0382 100644 --- a/src/drupal/fields_tests.rs +++ b/src/drupal/fields_tests.rs @@ -191,6 +191,27 @@ fn an_entity_class_with_entity_subclasses_holds_theirs_too() { ); } +/// 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 [ diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index f1d789e17..9de0464e0 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -16,6 +16,7 @@ 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; @@ -101,6 +102,24 @@ 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, +} + +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. @@ -353,6 +372,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..] @@ -363,7 +389,7 @@ fn entity_type_sites(map: &SymbolMap) -> impl Iterator Some((key.as_str(), site.class, site.storage)), + } => Some((key.as_str(), site)), _ => None, }) }) @@ -426,6 +452,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 e6243e478..865aa389c 100644 --- a/src/drupal/plugins.rs +++ b/src/drupal/plugins.rs @@ -293,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() @@ -461,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 { @@ -487,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 @@ -654,10 +701,6 @@ impl Backend { /// 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`. - #[cfg_attr( - not(test), - expect(dead_code, reason = "the field readers are its callers") - )] pub(crate) fn drupal_field_type_classes(&self, id: &str) -> Vec<(Atom, Option)> { let mut classes: Vec<(Atom, Option)> = self .drupal_plugin_index() diff --git a/src/hover/member.rs b/src/hover/member.rs index 79b1d782e..53044a821 100644 --- a/src/hover/member.rs +++ b/src/hover/member.rs @@ -166,7 +166,7 @@ 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 | PropertySource::DrupalField { .. } => Vec::new(), + PropertySource::DocblockTag | PropertySource::DrupalField => Vec::new(), } } diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 218dfd8df..4e276fcae 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -480,13 +480,10 @@ fn property_source(src: &PropertySource) -> Sz { z += vs(pivot_columns); } PropertySource::RelationshipCount { relationship } => z.add(relationship.capacity()), - PropertySource::DrupalField { declarations } => { - for declaration in declarations.iter() { - z.add(size_of::() + declaration.uri.capacity()); - } - } - PropertySource::Pivot | PropertySource::DocblockTag | PropertySource::PromotedParameter => { - } + PropertySource::Pivot + | PropertySource::DocblockTag + | PropertySource::PromotedParameter + | PropertySource::DrupalField => {} } z } @@ -1429,6 +1426,23 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { }) .sum::(), ); + sym.add( + sm.drupal_base_field_methods.capacity() + * size_of::(), + ); + for site in &sm.drupal_base_field_methods { + sym.add( + site.fields.capacity() * size_of::() + + site.includes.capacity() + * size_of::(), + ); + for field in &site.fields { + sym.add( + field.field_type.capacity() + + field.target_type.as_ref().map_or(0, String::capacity), + ); + } + } sym.add( sm.drupal_plugin_classes.capacity() * size_of::(), diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index a3e2ce830..ed97f887a 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -34,6 +34,7 @@ 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); if let Some(ref extends) = class.extends { for ident in extends.types.iter() { @@ -212,6 +213,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 b71e55e0b..ac9ef8c62 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -8,7 +8,8 @@ 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, + BaseFieldInclude, BaseFieldMethodSite, BaseFieldName, BaseFieldSite, DeclaredParameter, + DrupalRouteSite, EntityTypeSite, MethodScope, ModuleGuard, PluginClassSite, PluginDeclarationSite, PluginManagerSite, ReceiverClass, StaticPluginSite, StringKeyKind, TypedReceiverSite, }; @@ -190,7 +191,11 @@ fn string_pairs(array: &Expression<'_>, content: &str) -> Vec<(String, String)> 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)?; + let (value, _, _) = + crate::virtual_members::laravel::helpers::resolved_string_literal_including_empty( + entry.value, + content, + )?; Some((key.to_string(), value.to_string())) }) .collect() @@ -1267,6 +1272,248 @@ 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 + .into_iter() + .filter_map(|(name, value)| base_field(name, value, ctx)) + .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, + }); + } +} + +/// 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<'_>, + ctx: &ExtractionCtx<'_>, +) -> Option { + let (field_type, target_type) = base_field_creation(value, ctx)?; + let (name, start, end) = 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); + (name, span.0, span.1) + } + _ => { + let (literal, start, end) = resolved_string_literal(name, ctx.content)?; + ( + BaseFieldName::Literal(literal.to_string()), + start as u32, + end as u32, + ) + } + }; + Some(BaseFieldSite { + name, + field_type, + target_type, + start, + end, + }) +} + +/// 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<'_>, + 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 = resolved_string_literal(target.value, ctx.content) + .map(|(target, _, _)| target.to_string()); + } + 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, + } + } +} + +/// The keyed values and the static calls of a base field method. +#[derive(Default)] +struct BaseFieldSites<'ast, 'arena> { + /// The key and the value of each `$fields[KEY] = VALUE` and each + /// `KEY => VALUE` array entry. + keyed: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, + calls: Vec<&'ast StaticMethodCall<'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::ArrayAccess(access)) = + (&assignment.operator, assignment.lhs) + { + found.keyed.push((access.index, assignment.rhs)); + } + } + + 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((entry.key, entry.value)); + } + } +} + /// The calls, assignments, and array entries of a route builder's methods /// that may add a route. #[derive(Default)] @@ -2591,11 +2838,15 @@ fn annotation_entity_type(doc: &str, offset: u32) -> Option 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"), + ([], "translatable", false) => { + declared.translatable = value.eq_ignore_ascii_case("true") + } (["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())), + (["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())), @@ -3866,6 +4117,141 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { ) } + /// 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_base_field_methods + .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_type.clone(), + ) + }) + .collect(); + ( + site.owner.to_string(), + site.method.to_string(), + fields, + site.includes, + ) + }) + .collect() + } + + #[test] + fn base_field_methods_keep_their_fields_and_includes() { + let php = 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("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("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()))) + .map(|site| { + ( + site.id.clone(), + site.list_class.map(|class| class.to_string()), + ) + }) .collect(); assert_eq!( found, diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 3dce6caa2..76d3b3b45 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -112,6 +112,7 @@ struct ExtractionCtx<'a> { service_decorations: Vec, /// The entity types the file's entity classes declare. entity_types: Vec, + drupal_base_field_methods: Vec, /// The classes the file's Drupal plugin declarations are written on. drupal_plugin_classes: Vec, drupal_plugin_managers: Vec, @@ -627,6 +628,7 @@ fn extract_symbol_map_inner( container_values: Vec::new(), service_decorations: Vec::new(), entity_types: Vec::new(), + drupal_base_field_methods: Vec::new(), drupal_plugin_classes: Vec::new(), drupal_plugin_managers: Vec::new(), drupal_plugins: Vec::new(), @@ -870,6 +872,7 @@ fn extract_symbol_map_inner( container_values: ctx.container_values, service_decorations: ctx.service_decorations, entity_types: ctx.entity_types, + drupal_base_field_methods: ctx.drupal_base_field_methods, 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..b4827d971 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, + /// The methods of the file's classes and traits that declare Drupal + /// base fields. Empty for every file that declares none. + pub drupal_base_field_methods: Vec, /// 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, diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index 1a9c6e738..d1b491231 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -864,6 +864,76 @@ pub(crate) struct EntityTypeSite { pub content: bool, } +/// 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, +} + +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.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_type == b.target_type + }) + } +} + +/// 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_type: Option, + /// Byte range of the name: a string's contents, or the key lookup. + pub start: u32, + pub end: u32, +} + +/// 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_, +} + /// The class a Drupal plugin declaration is written on, such as a render /// element's. #[derive(Debug, Clone, PartialEq)] diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 50d71f237..bacb7a2c8 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); @@ -264,6 +265,7 @@ impl Backend { 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/types/property.rs b/src/types/property.rs index 91f18553b..58e362b29 100644 --- a/src/types/property.rs +++ b/src/types/property.rs @@ -168,19 +168,10 @@ pub enum PropertySource { /// 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. - DrupalField { - /// Where the field is declared, in no particular order. - declarations: std::sync::Arc<[FieldDeclaration]>, - }, -} - -/// A place a framework declares a field a [`PropertySource`] names. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FieldDeclaration { - pub uri: String, - /// Byte offset of the field's name in the file. - pub offset: u32, + /// 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, } /// What a property's docblock says about writing to it. @@ -221,7 +212,7 @@ impl PropertyInfo { /// 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 { .. })) + matches!(self.source, Some(PropertySource::DrupalField)) } /// Return the type hint as a string, if present. diff --git a/src/virtual_members/mod.rs b/src/virtual_members/mod.rs index 07d74d137..a04a8efee 100644 --- a/src/virtual_members/mod.rs +++ b/src/virtual_members/mod.rs @@ -341,8 +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 magic `__get()` of content -/// entity interfaces) +/// 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 diff --git a/src/virtual_members/tests.rs b/src/virtual_members/tests.rs index ef10fea21..5dc78d948 100644 --- a/src/virtual_members/tests.rs +++ b/src/virtual_members/tests.rs @@ -2112,9 +2112,7 @@ fn class_with_magic_set() -> ClassInfo { .properties .push(Arc::new(make_property("tagged", Some("string")))); class.properties.push(Arc::new(PropertyInfo { - source: Some(crate::types::PropertySource::DrupalField { - declarations: Arc::from([]), - }), + source: Some(crate::types::PropertySource::DrupalField), ..make_property("title", Some("FieldItemList")) })); class diff --git a/tests/integration/drupal_fields.rs b/tests/integration/drupal_fields.rs index 77d02078d..1309fcf1c 100644 --- a/tests/integration/drupal_fields.rs +++ b/tests/integration/drupal_fields.rs @@ -209,3 +209,685 @@ async fn fieldable_interfaces_have_no_magic_outside_drupal() { "{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'); + } + 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", + "\n */\nclass FieldItemList implements FieldItemListInterface {\n public function __get($property_name) {}\n}\n", + ), + ( + "web/core/lib/Drupal/Core/Field/EntityReferenceFieldItemList.php", + " 'nid', + 'revision' => 'vid', + '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) { + let dir = tempfile::tempdir().unwrap(); + 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}"); + } + } +} + +/// 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:?}"); + } +} + +/// Replace the whole text of the open document `uri`. +async fn edit(backend: &Backend, uri: &tower_lsp_server::gen_lsp_types::Uri, text: &str) { + use tower_lsp_server::LanguageServer; + use tower_lsp_server::gen_lsp_types::*; + backend + .did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { + text_document_identifier: TextDocumentIdentifier { uri: uri.clone() }, + version: 2, + }, + content_changes: vec![ + TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument( + TextDocumentContentChangeWholeDocument { + text: text.to_string(), + }, + ), + ], + }) + .await; +} + +const EXCERPTED: &str = r#"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:?}"); + } +} From c0c1b410a5e07a12958b41ab781aa85bbfb3d80c Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 19:37:46 +0100 Subject: [PATCH 06/11] Type the configurable fields of Drupal content entities The fields that field storage files declare are now typed properties of their entity type's class and interfaces, like base fields: $node->field_tags is an EntityReferenceFieldItemList that names Term. Field storage files count in extensions' config/install and config/optional, core's profiles included, and anywhere else in the project outside vendor, core and contrib, recipes, and tests, which covers config/sync and config split folders; a test module's file counts while it is open. A field declared by several files is one field, typed by all of them, and go-to-definition lists every file. Adding, editing, or deleting a field storage file refreshes the fields everywhere they show. Assisted-by: claude-code/claude-opus-5-5 --- docs/ARCHITECTURE.md | 2 +- examples/drupal/README.md | 6 + examples/drupal/assertions.php | 20 +- ...d.field.acme_note.acme_note.field_tags.yml | 28 ++ .../field.storage.acme_note.field_tags.yml | 20 ++ .../modules/custom/acme/src/NoteSummary.php | 10 +- src/classmap_scanner/discovery.rs | 8 +- src/classmap_scanner/discovery_tests.rs | 28 ++ src/definition/member/mod.rs | 24 +- src/definition/resolve.rs | 5 +- src/drupal/base_fields.rs | 208 ++++++++--- src/drupal/base_fields_tests.rs | 50 ++- src/drupal/config.rs | 2 +- src/drupal/field_storage.rs | 256 ++++++++++++++ src/drupal/fields.rs | 25 +- src/drupal/mod.rs | 1 + src/mem_audit.rs | 9 + src/resource_navigation.rs | 9 + src/symbol_map/extraction/mod.rs | 1 + src/symbol_map/map.rs | 3 + src/symbol_map/string_keys.rs | 25 ++ tests/integration/drupal_fields.rs | 334 +++++++++++++++++- 22 files changed, 984 insertions(+), 90 deletions(-) create mode 100644 examples/drupal/config/sync/field.field.acme_note.acme_note.field_tags.yml create mode 100644 examples/drupal/config/sync/field.storage.acme_note.field_tags.yml create mode 100644 src/drupal/field_storage.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bc17ac5ae..724cd1a19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -909,7 +909,7 @@ 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 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, and the Backend reads the index when it loads `FieldableEntityInterface` (a class-load hook). An entity class's fields show on interfaces that do not depend on the entity class, so a change to them evicts `FieldableEntityInterface` and everything under it. +- **`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, and the Backend reads the index when it loads `FieldableEntityInterface` (a class-load hook). An entity class's fields show on interfaces that do not depend on the entity class, so a change to them, or to a field storage file, evicts `FieldableEntityInterface` and everything under it. ### Laravel Class Patches diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 6b442b851..d7b6b918c 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -51,6 +51,12 @@ Run `composer install` here first. Composer puts core and contrib into `$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`. - 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 526bfdc2d..de3af1f68 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -44,6 +44,7 @@ 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; @@ -71,6 +72,7 @@ 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; @@ -106,10 +108,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 and user -// modules' 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", 'user' => "$web/core/modules/user"] 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'; @@ -271,6 +273,18 @@ protected function getDefaultLanguageValues() 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;')); check('NoteSummary returns a string', (new ReflectionMethod(NoteSummary::class, 'summarize'))->getReturnType()?->getName() === 'string'); // Drupal upcasts a controller's parameter named after an entity type and 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/NoteSummary.php b/examples/drupal/web/modules/custom/acme/src/NoteSummary.php index 91d16c95e..0ceeaa866 100644 --- a/examples/drupal/web/modules/custom/acme/src/NoteSummary.php +++ b/examples/drupal/web/modules/custom/acme/src/NoteSummary.php @@ -23,7 +23,15 @@ public function summarize(NoteInterface $note): string // 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; - return sprintf('%s (%d)', (string) $title->value, count($author)); + return sprintf('%s (%d, %d tags)', (string) $title->value, count($author), count($tags)); } } 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/definition/member/mod.rs b/src/definition/member/mod.rs index b07d6202d..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. @@ -321,14 +331,6 @@ impl Backend { return Some(location); } - // A Drupal entity field goes to where its entity type declares it. - if lookup.access_hint == MemberAccessHint::PropertyAccess - && let Some(location) = - self.drupal_field_location(lookup_class, &effective_name, lookup.class_loader) - { - return Some(location); - } - let is_model = extends_eloquent_model(lookup_class, lookup.class_loader); if is_model && let Some(location) = 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/drupal/base_fields.rs b/src/drupal/base_fields.rs index 648adf3df..5678e761f 100644 --- a/src/drupal/base_fields.rs +++ b/src/drupal/base_fields.rs @@ -13,8 +13,9 @@ //! //! 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, and read into a -//! [`FieldIndex`] on first use. +//! 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; @@ -27,7 +28,7 @@ use crate::atom::Atom; use crate::class_lookup::{Fqn, ResolvedClassLoader}; use crate::php_type::PhpType; use crate::symbol_map::{ - BaseFieldMethodSite, BaseFieldName, EntityTypeSite, MethodScope, SymbolMap, + BaseFieldMethodSite, BaseFieldName, EntityTypeSite, FieldStorageSite, MethodScope, SymbolMap, }; use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH}; @@ -45,20 +46,46 @@ pub(crate) struct FieldSources { /// The entity types the file declares, by ID. entity_types: Vec<(String, EntityTypeSite)>, methods: 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. - fn of(uri: &str, map: &SymbolMap) -> Option { - if map.entity_types.is_empty() && map.drupal_base_field_methods.is_empty() { + /// 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 declares = (!map.entity_types.is_empty() || !map.drupal_base_field_methods.is_empty()) + && declarations(); + let storage = map + .drupal_field_storage + .as_deref() + .filter(|_| storage()) + .cloned(); + if !declares && storage.is_none() { return None; } Some(Self { uri: Arc::from(uri), - entity_types: super::entity_type_declarations(map) - .map(|(id, site)| (id.to_string(), site.clone())) - .collect(), - methods: map.drupal_base_field_methods.clone(), + entity_types: if declares { + super::entity_type_declarations(map) + .map(|(id, site)| (id.to_string(), site.clone())) + .collect() + } else { + Vec::new() + }, + methods: if declares { + map.drupal_base_field_methods.clone() + } else { + Vec::new() + }, + storage, }) } @@ -82,6 +109,10 @@ impl FieldSources { .iter() .zip(&other.methods) .all(|(a, b)| a.declares_as(b)) + && match (&self.storage, &other.storage) { + (Some(a), Some(b)) => a.declares_as(b), + (a, b) => a.is_none() && b.is_none(), + } } } @@ -218,7 +249,7 @@ impl<'a> BaseFieldReader<'a> { let creation = Creation { field_type: &field.field_type, target_type: field.target_type.as_deref(), - declaration: Some(FieldDeclaration { + declaration: Some(FieldDeclaration::BaseField { uri: Arc::clone(uri), owner: site.owner, method: site.method, @@ -301,21 +332,27 @@ impl Backend { /// 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)) - .filter(|_| { - // 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())) - }); + 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; @@ -349,9 +386,10 @@ impl Backend { let mut cache = self.resolved_class_cache.write(); crate::virtual_members::evict_fqn(&mut cache, FIELDABLE_ENTITY); } - // A cached receiver resolution records no dependency on the files - // the fields are read from. + // A cached receiver resolution, or member completion, records no + // dependency on the files the fields are read from. self.clear_resolved_member_files(); + self.member_completion_cache.lock().clear(); } /// The fields of the project's content entity types, read on first use. @@ -372,7 +410,11 @@ impl Backend { let loader = self.resolved_class_loader_with(&[]); 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(( @@ -404,6 +446,26 @@ impl Backend { &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)| { @@ -443,53 +505,85 @@ impl Backend { index } - /// Where the field `name` of `receiver` is declared: the first of the - /// declarations the entity types it may hold give, when the property - /// `name` of `receiver` is that field. - pub(crate) fn drupal_field_location( + /// 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<'_>, - ) -> Option { + ) -> Vec { if !self.is_drupal_project() { - return None; + 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.writes_through_magic_set()) - { - return None; + if !resolved.properties.iter().any(|property| { + property.name == name + && property.source == Some(crate::types::PropertySource::DrupalField) + }) { + return Vec::new(); } - let index = self.drupal_field_index()?; - let field = index + 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)?; - let declaration = field.declarations.first()?; - let (start, end) = { + .find(|field| field.name == name) + else { + return Vec::new(); + }; + self.field_locations(&field) + } + + /// 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(); - let site = sources - .get(&*declaration.uri)? - .methods - .iter() - .find(|site| { - site.owner == declaration.owner && site.method == declaration.method - })?; - let statement = site.fields.get(declaration.statement)?; - (statement.start, statement.end) + 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 uri = &*declaration.uri; - let content = self.get_file_content_arc(uri)?; + let content = self.get_file_content_arc(&uri)?; let lines = crate::text_position::LineIndex::new(&content); Some(Location { - uri: crate::util::parse_url(uri)?, + uri: crate::util::parse_url(&uri)?, range: Range { start: lines.position(start as usize), end: lines.position(end as usize), diff --git a/src/drupal/base_fields_tests.rs b/src/drupal/base_fields_tests.rs index 3c12bdc73..423e2b63f 100644 --- a/src/drupal/base_fields_tests.rs +++ b/src/drupal/base_fields_tests.rs @@ -29,7 +29,7 @@ fn sources(content: &str) -> FieldSources { // Compared across backends, so the URI must not differ. FieldSources { uri: std::sync::Arc::from("file:///note.php"), - ..FieldSources::of(&uri, &map).expect("the file declares fields") + ..FieldSources::of(&uri, &map, || true, || true).expect("the file declares fields") } } @@ -54,3 +54,51 @@ fn a_renamed_field_or_a_changed_key_declares_other_fields() { 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_field_storage: crate::drupal::field_storage::read_field_storage(content) + .map(Box::new), + ..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}"); + } +} + +/// 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_field_storage: crate::drupal::field_storage::read_field_storage(STORAGE) + .map(Box::new), + ..Default::default() + }; + assert!(FieldSources::of("file:///f.yml", &map, || true, || false).is_none()); +} 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_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 index 59d8c5715..becea326b 100644 --- a/src/drupal/fields.rs +++ b/src/drupal/fields.rs @@ -12,7 +12,8 @@ //! 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`]), +//! 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`. @@ -194,16 +195,20 @@ pub(crate) struct Field { pub declarations: Vec, } -/// A statement that declares a field: the one at `statement` among those -/// the base field method `method` of `owner` in the file at `uri` creates -/// fields with. Where it is written is looked up when asked, since an -/// edit above it moves it without changing the fields. +/// 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) struct FieldDeclaration { - pub uri: Arc, - pub owner: Atom, - pub method: Atom, - pub statement: usize, +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. diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 9de0464e0..7a83cc2e2 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -21,6 +21,7 @@ pub(crate) mod computed_hooks; pub(crate) mod config; pub(crate) mod config_keys; pub(crate) mod elements; +pub(crate) mod field_storage; pub(crate) mod fields; pub(crate) mod hooks; pub(crate) mod libraries; diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 4e276fcae..3348c370d 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1443,6 +1443,15 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { ); } } + if let Some(storage) = &sm.drupal_field_storage { + sym.add( + size_of::() + + 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/resource_navigation.rs b/src/resource_navigation.rs index 99704baac..5f2cfd472 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -456,9 +456,18 @@ 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_field_storage = (self.is_drupal_project() + && crate::drupal::field_storage::is_field_storage_uri(uri)) + .then(|| crate::drupal::field_storage::read_field_storage(content)) + .flatten() + .map(Box::new); + SymbolMap { spans, member_access_indices, + drupal_field_storage, container_values, service_decorations, route_imports, diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 76d3b3b45..51d931e02 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -873,6 +873,7 @@ fn extract_symbol_map_inner( service_decorations: ctx.service_decorations, entity_types: ctx.entity_types, drupal_base_field_methods: ctx.drupal_base_field_methods, + drupal_field_storage: None, 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 b4827d971..c743914ce 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -320,6 +320,9 @@ pub(crate) struct SymbolMap { /// The methods of the file's classes and traits that declare Drupal /// base fields. Empty for every file that declares none. pub drupal_base_field_methods: Vec, + /// The configurable field the file declares, when it is a Drupal field + /// storage file. Boxed, since nearly every file declares none. + pub drupal_field_storage: 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, diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index d1b491231..42ba0b044 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -908,6 +908,31 @@ pub(crate) struct BaseFieldSite { pub end: u32, } +/// 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 { diff --git a/tests/integration/drupal_fields.rs b/tests/integration/drupal_fields.rs index 1309fcf1c..c0e79b536 100644 --- a/tests/integration/drupal_fields.rs +++ b/tests/integration/drupal_fields.rs @@ -459,7 +459,11 @@ const PROBE_PATH: &str = "web/modules/custom/acme/src/Probe.php"; /// A Drupal site with [`CORE`] on disk and the custom entity type, `files` /// open, indexed. async fn site(files: &[(&str, &str)]) -> (Backend, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); + 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"), @@ -891,3 +895,331 @@ async fn an_entity_file_opened_before_indexing_ends_gives_its_fields() { 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:?}"); +} From 54432d004175643783a1e9bd623b88c57ea75c7a Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 20:19:04 +0100 Subject: [PATCH 07/11] Read the property definitions of Drupal field item classes The symbol map now keeps what a field item class's propertyDefinitions() declares: each property it adds to the array it returns, with the data type its definition creates, the properties it unsets, and whether it takes its parent's. Assisted-by: claude-code/claude-opus-5-5 --- src/mem_audit.rs | 17 ++ src/symbol_map/extraction/class_like.rs | 1 + src/symbol_map/extraction/drupal.rs | 215 ++++++++++++++++++++++-- src/symbol_map/extraction/mod.rs | 3 + src/symbol_map/map.rs | 4 + src/symbol_map/string_keys.rs | 24 +++ 6 files changed, 254 insertions(+), 10 deletions(-) diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 3348c370d..815f089a2 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1443,6 +1443,23 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { ); } } + sym.add( + sm.drupal_item_property_methods.capacity() + * size_of::(), + ); + for site in &sm.drupal_item_property_methods { + sym.add( + site.properties.capacity() * size_of::() + + site.removed.capacity() * size_of::() + + site.removed.iter().map(String::capacity).sum::(), + ); + for property in &site.properties { + sym.add( + property.name.capacity() + + property.data_type.as_ref().map_or(0, String::capacity), + ); + } + } if let Some(storage) = &sm.drupal_field_storage { sym.add( size_of::() diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index ed97f887a..2ddb21a7b 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -35,6 +35,7 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC 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_item_property_methods(&class.members, class.name.span.start.offset, ctx); if let Some(ref extends) = class.extends { for ident in extends.types.iter() { diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index ac9ef8c62..4bdeb0cdc 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -9,9 +9,9 @@ use crate::symbol_map::extraction::symfony::{ }; use crate::symbol_map::{ BaseFieldInclude, BaseFieldMethodSite, BaseFieldName, BaseFieldSite, DeclaredParameter, - DrupalRouteSite, EntityTypeSite, MethodScope, ModuleGuard, PluginClassSite, - PluginDeclarationSite, PluginManagerSite, ReceiverClass, StaticPluginSite, StringKeyKind, - TypedReceiverSite, + DrupalRouteSite, EntityTypeSite, ItemPropertyMethodSite, ItemPropertySite, MethodScope, + ModuleGuard, PluginClassSite, PluginDeclarationSite, PluginManagerSite, ReceiverClass, + StaticPluginSite, StringKeyKind, TypedReceiverSite, }; use crate::virtual_members::laravel::helpers::resolved_string_literal; use mago_syntax::cst::sequence::TokenSeparatedSequence; @@ -1314,7 +1314,7 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_base_field_methods( let fields: Vec = found .keyed .into_iter() - .filter_map(|(name, value)| base_field(name, value, ctx)) + .filter_map(|keyed| base_field(keyed.key, keyed.value, ctx)) .collect(); let includes: Vec = found .calls @@ -1454,13 +1454,125 @@ fn base_field_creation( } } -/// The keyed values and the static calls of a base field method. +/// Record the properties the `propertyDefinitions()` of a class declares +/// for its field type's items (see [`crate::drupal::field_items`]). +/// `name_offset` is that of the class's name. +pub(in crate::symbol_map::extraction) fn emit_drupal_item_property_methods( + members: &Sequence<'_, ClassLikeMember<'_>>, + name_offset: u32, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal || !ctx.content.contains("propertyDefinitions") { + return; + } + let Some(method) = members.iter().find_map(|member| match member { + ClassLikeMember::Method(method) + if method + .name + .value + .eq_ignore_ascii_case(b"propertyDefinitions") => + { + Some(method) + } + _ => None, + }) else { + return; + }; + let Some(owner) = 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); + // 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")) + }); + ctx.drupal_item_property_methods + .push(ItemPropertyMethodSite { + owner, + properties, + removed, + takes_parent, + }); +} + +/// 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 keyed values, the static calls, and the unset keys of a base field +/// or property definition method. #[derive(Default)] struct BaseFieldSites<'ast, 'arena> { - /// The key and the value of each `$fields[KEY] = VALUE` and each - /// `KEY => VALUE` array entry. - keyed: Vec<(&'ast Expression<'arena>, &'ast Expression<'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>>, +} + +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; @@ -1476,7 +1588,27 @@ impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BaseFieldSites<'ast if let (AssignmentOperator::Assign(_), Expression::ArrayAccess(access)) = (&assignment.operator, assignment.lhs) { - found.keyed.push((access.index, assignment.rhs)); + found.keyed.push(Keyed { + key: access.index, + value: assignment.rhs, + assigned: Some(access.array), + }); + } + } + + 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); + } } } @@ -1509,7 +1641,11 @@ fn push_base_field_entries<'ast, 'arena>( { for element in elements { if let ArrayElement::KeyValue(entry) = element { - found.keyed.push((entry.key, entry.value)); + found.keyed.push(Keyed { + key: entry.key, + value: entry.value, + assigned: None, + }); } } } @@ -4150,6 +4286,65 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { .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_item_property_methods, + [ + ItemPropertyMethodSite { + owner: crate::atom::atom("Drupal\\acme\\Plugin\\Field\\FieldType\\PictureItem"), + 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, + }, + ItemPropertyMethodSite { + owner: crate::atom::atom("Drupal\\acme\\Plugin\\Field\\FieldType\\PlainItem"), + properties: Vec::new(), + removed: Vec::new(), + takes_parent: false, + }, + ] + ); + } + #[test] fn base_field_methods_keep_their_fields_and_includes() { let php = r#" { /// The entity types the file's entity classes declare. entity_types: Vec, drupal_base_field_methods: Vec, + drupal_item_property_methods: Vec, /// The classes the file's Drupal plugin declarations are written on. drupal_plugin_classes: Vec, drupal_plugin_managers: Vec, @@ -629,6 +630,7 @@ fn extract_symbol_map_inner( service_decorations: Vec::new(), entity_types: Vec::new(), drupal_base_field_methods: Vec::new(), + drupal_item_property_methods: Vec::new(), drupal_plugin_classes: Vec::new(), drupal_plugin_managers: Vec::new(), drupal_plugins: Vec::new(), @@ -873,6 +875,7 @@ fn extract_symbol_map_inner( service_decorations: ctx.service_decorations, entity_types: ctx.entity_types, drupal_base_field_methods: ctx.drupal_base_field_methods, + drupal_item_property_methods: ctx.drupal_item_property_methods, drupal_field_storage: None, drupal_plugin_classes: ctx.drupal_plugin_classes, drupal_plugin_managers: ctx.drupal_plugin_managers, diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index c743914ce..18b1f8dd0 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -320,6 +320,10 @@ pub(crate) struct SymbolMap { /// The methods of the file's classes and traits that declare Drupal /// base fields. Empty for every file that declares none. pub drupal_base_field_methods: Vec, + /// The `propertyDefinitions()` methods of the file's Drupal field item + /// classes. Empty for every file that declares none. + #[cfg_attr(not(any(test, feature = "mem-audit")), expect(dead_code))] + pub drupal_item_property_methods: Vec, /// The configurable field the file declares, when it is a Drupal field /// storage file. Boxed, since nearly every file declares none. pub drupal_field_storage: Option>, diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index 42ba0b044..b55a82e18 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -959,6 +959,30 @@ pub(crate) enum MethodScope { Self_, } +/// A Drupal field item class's `propertyDefinitions()`, which declares the +/// properties of its field type's items. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ItemPropertyMethodSite { + /// The class that declares the method. + pub owner: crate::atom::Atom, + /// 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, +} + +/// A property an [`ItemPropertyMethodSite`] 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 /// element's. #[derive(Debug, Clone, PartialEq)] From 2269b704707fd8de15c5706313e4d35d6b5af895 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 20:19:34 +0100 Subject: [PATCH 08/11] Type the properties of Drupal field items A field item class now has the properties its propertyDefinitions() declares, with those it takes from its parent's and without those it unsets, and a field reads them on its first item, as Drupal's item lists do: $node->body->format and $node->field_tags->target_id complete and resolve. A reference field's entity property is its target class, nullable since an empty field gives null, or any entity when the field names no target, on the list and on its items alike, and referencedEntities() gives the target class too. A language property is a nullable language, and every stored value stays mixed. Writing a property keeps its type. Editing an item class's propertyDefinitions() refreshes its properties on the item, the classes that extend it, and the fields that hold it. Assisted-by: claude-code/claude-opus-5-5 --- docs/ARCHITECTURE.md | 3 +- examples/drupal/README.md | 6 + examples/drupal/assertions.php | 13 + .../modules/custom/acme/src/NoteSummary.php | 12 +- src/drupal/base_fields.rs | 423 ++++++++-- src/drupal/base_fields_tests.rs | 189 ++++- src/drupal/field_items.rs | 313 +++++++ src/drupal/fields.rs | 56 +- src/drupal/mod.rs | 6 + src/hover/member.rs | 4 +- src/mem_audit.rs | 107 ++- src/parser/ast_publish.rs | 16 + src/references/member_scope.rs | 5 +- src/rename/prepare.rs | 10 + src/resource_navigation.rs | 11 +- src/symbol_map/extraction/class_like.rs | 2 +- src/symbol_map/extraction/drupal.rs | 478 +++++++++-- src/symbol_map/extraction/mod.rs | 13 +- src/symbol_map/map.rs | 37 +- src/symbol_map/string_keys.rs | 60 +- src/symfony/mod.rs | 1 + src/type_engine/type_ops/resolution.rs | 23 +- src/types/property.rs | 10 +- src/virtual_members/cache.rs | 8 + src/virtual_members/mod.rs | 3 +- src/virtual_members/resolve.rs | 39 +- src/virtual_members/tests.rs | 4 +- tests/integration/drupal_fields.rs | 765 +++++++++++++++++- 28 files changed, 2405 insertions(+), 212 deletions(-) create mode 100644 src/drupal/field_items.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 724cd1a19..63c6c6ae6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -909,7 +909,8 @@ 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, and the Backend reads the index when it loads `FieldableEntityInterface` (a class-load hook). An entity class's fields show on interfaces that do not depend on the entity class, so a change to them, or to a field storage file, evicts `FieldableEntityInterface` and everything under it. +- **`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/examples/drupal/README.md b/examples/drupal/README.md index d7b6b918c..d7ca5a27c 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -57,6 +57,12 @@ Run `composer install` here first. Composer puts core and contrib into 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`. - 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 de3af1f68..158fcbc6f 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -285,6 +285,19 @@ protected function getDefaultLanguageValues() 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'); // Drupal upcasts a controller's parameter named after an entity type and diff --git a/examples/drupal/web/modules/custom/acme/src/NoteSummary.php b/examples/drupal/web/modules/custom/acme/src/NoteSummary.php index 0ceeaa866..3731edfa3 100644 --- a/examples/drupal/web/modules/custom/acme/src/NoteSummary.php +++ b/examples/drupal/web/modules/custom/acme/src/NoteSummary.php @@ -31,7 +31,17 @@ public function summarize(NoteInterface $note): string // 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)', (string) $title->value, count($author), count($tags)); + return sprintf('%s (%d, %d tags, first %s, by %s)', (string) $titleText, count($author), count($tags), (string) $firstTag?->label(), (string) $authorAccount?->label()); } } diff --git a/src/drupal/base_fields.rs b/src/drupal/base_fields.rs index 5678e761f..43f45bc56 100644 --- a/src/drupal/base_fields.rs +++ b/src/drupal/base_fields.rs @@ -28,7 +28,8 @@ use crate::atom::Atom; use crate::class_lookup::{Fqn, ResolvedClassLoader}; use crate::php_type::PhpType; use crate::symbol_map::{ - BaseFieldMethodSite, BaseFieldName, EntityTypeSite, FieldStorageSite, MethodScope, SymbolMap, + BaseFieldMethodSite, BaseFieldName, DefaultTarget, EntityTypeSite, FieldItemSite, + FieldStorageSite, FieldTarget, MethodScope, SymbolMap, }; use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH}; @@ -46,6 +47,8 @@ pub(crate) struct FieldSources { /// 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, @@ -61,11 +64,14 @@ impl FieldSources { declarations: impl FnOnce() -> bool, storage: impl FnOnce() -> bool, ) -> Option { - let declares = (!map.entity_types.is_empty() || !map.drupal_base_field_methods.is_empty()) + 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 = map - .drupal_field_storage - .as_deref() + let storage = fields + .and_then(|fields| fields.storage.as_ref()) .filter(|_| storage()) .cloned(); if !declares && storage.is_none() { @@ -80,10 +86,13 @@ impl FieldSources { } else { Vec::new() }, - methods: if declares { - map.drupal_base_field_methods.clone() - } 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, }) @@ -109,6 +118,7 @@ impl FieldSources { .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(), @@ -124,6 +134,8 @@ pub(crate) type FieldSourceMap = BTreeMap>; 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> { @@ -157,6 +169,7 @@ impl<'a> EntityKeys<'a> { Self { keys, revision_metadata, + bundle_entity_type: site.bundle_entity_type.as_deref(), } } @@ -215,7 +228,7 @@ impl<'a> BaseFieldReader<'a> { keys: &EntityKeys<'a>, path: &mut Vec<(Atom, String)>, ) -> Created<'a> { - let Some((owner, uri, site)) = self.find(start, method) else { + let Some((owner, using, uri, site)) = self.find(start, method) else { return Vec::new(); }; let step = (owner, method.to_ascii_lowercase()); @@ -226,10 +239,12 @@ impl<'a> BaseFieldReader<'a> { 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(owner), - MethodScope::Parent => self.load(owner).and_then(|class| class.parent_class), + MethodScope::Self_ => Some(using), + MethodScope::Parent => self.load(using).and_then(|class| class.parent_class), }; let Some(from) = from else { continue; @@ -240,15 +255,31 @@ impl<'a> BaseFieldReader<'a> { } } } + 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: field.target_type.as_deref(), + 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, @@ -269,48 +300,19 @@ impl<'a> BaseFieldReader<'a> { } /// The class or trait that declares the base field method `method` for - /// `start`, as PHP finds it: the class's own, then its traits', then - /// its parent's. + /// `start`, and the class that has it. fn find( &self, start: Atom, method: &str, - ) -> Option<(Atom, &'a Arc, &'a BaseFieldMethodSite)> { + ) -> Option<(Atom, Atom, &'a Arc, &'a BaseFieldMethodSite)> { let method = method.to_ascii_lowercase(); - let mut current = Some(start); - let mut depth = 0; - while let Some(class) = current - && depth < MAX_INHERITANCE_DEPTH - { - depth += 1; - if let Some(found) = self.declared_in(class, &method, 0) { - return Some(found); - } - current = self.load(class).and_then(|info| info.parent_class); - } - None - } - - /// The base field method `method` of `class` or of a trait it uses. - fn declared_in( - &self, - class: Atom, - method: &str, - depth: u32, - ) -> Option<(Atom, &'a Arc, &'a BaseFieldMethodSite)> { - if let Some((uri, site)) = self - .methods - .get(&(class.to_ascii_lowercase(), method.to_string())) - { - return Some((class, uri, site)); - } - if depth >= MAX_INHERITANCE_DEPTH { - return None; - } - self.load(class)? - .used_traits - .iter() - .find_map(|used| self.declared_in(*used, method, depth + 1)) + 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> { @@ -318,6 +320,44 @@ impl<'a> BaseFieldReader<'a> { } } +/// 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) { @@ -361,6 +401,17 @@ impl Backend { (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 { @@ -375,20 +426,98 @@ impl Backend { 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. - pub(crate) fn invalidate_drupal_fields(&self) { - self.drupal.fields.invalidate(); + 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); + 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, or member completion, records no - // dependency on the files the fields are read from. - self.clear_resolved_member_files(); + // 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(); } @@ -407,7 +536,13 @@ impl Backend { // Copied out, since loading a class may publish a file. let sources: Vec> = self.drupal.field_sources.read().values().cloned().collect(); - let loader = self.resolved_class_loader_with(&[]); + 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(); @@ -436,7 +571,17 @@ impl Backend { }; 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, @@ -477,7 +622,9 @@ impl Backend { field_types.entry(creation.field_type).or_insert_with(|| { self.drupal_field_type_classes(creation.field_type) }); - field_type(classes, creation.target_type, &declared) + field_type(classes, creation.target_type, &declared, |item| { + default_target(&index.field_items, item, &loader) + }) }) .collect(), ); @@ -502,9 +649,32 @@ impl Backend { 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(); + } + } + /// 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. @@ -541,6 +711,59 @@ impl Backend { 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(); @@ -592,6 +815,32 @@ impl Backend { } } +/// 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. @@ -622,31 +871,41 @@ fn add_manager_fields<'a>(created: &mut Created<'a>, site: &EntityTypeSite, keys } /// 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. +/// 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 mut targets: Vec = Vec::new(); - if let Some(target_type) = target_type { + 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.clone())); + args.push(PhpType::union(targets)); } PhpType::generic_atom( list.unwrap_or_else(|| crate::atom::atom(FIELD_ITEM_LIST)), @@ -657,6 +916,44 @@ fn field_type( ) } +/// 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 index 423e2b63f..cff40de74 100644 --- a/src/drupal/base_fields_tests.rs +++ b/src/drupal/base_fields_tests.rs @@ -50,6 +50,10 @@ fn a_renamed_field_or_a_changed_key_declares_other_fields() { 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}"); } @@ -58,8 +62,11 @@ fn a_renamed_field_or_a_changed_key_declares_other_fields() { /// What a field storage file holding `content` gives the field index. fn storage_sources(content: &str) -> FieldSources { let map = crate::symbol_map::SymbolMap { - drupal_field_storage: crate::drupal::field_storage::read_field_storage(content) - .map(Box::new), + drupal_fields: crate::symbol_map::DrupalFieldSites { + storage: crate::drupal::field_storage::read_field_storage(content), + ..Default::default() + } + .boxed(), ..Default::default() }; FieldSources::of( @@ -92,13 +99,187 @@ fn a_storage_edited_around_its_field_declares_the_same_field() { } } +/// 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_field_storage: crate::drupal::field_storage::read_field_storage(STORAGE) - .map(Box::new), + 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/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/fields.rs b/src/drupal/fields.rs index becea326b..33cf92c56 100644 --- a/src/drupal/fields.rs +++ b/src/drupal/fields.rs @@ -26,6 +26,7 @@ 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, @@ -84,6 +85,10 @@ pub(crate) struct FieldSlot { /// 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! { @@ -139,6 +144,7 @@ impl FieldSlot { return Some(index); } let generation = self.generation.load(Ordering::Acquire); + self.read_classes.lock().clear(); let index = { let _reading = ReadingFields::enter(); Arc::new(read()) @@ -147,6 +153,7 @@ impl FieldSlot { 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); } } @@ -167,6 +174,43 @@ impl FieldSlot { .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)); @@ -219,10 +263,18 @@ pub(crate) struct EntityTypeFields { pub fields: Vec, } -/// The fields of every content entity type the project declares. +/// 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 { @@ -348,7 +400,7 @@ impl VirtualMemberProvider for FieldableEntityProvider { } /// The field index the `Backend` reads into `cache`. -fn field_index(cache: &ResolvedClassCache) -> Option> { +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() diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 7a83cc2e2..9dcbc0e76 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -21,6 +21,7 @@ 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; @@ -108,6 +109,11 @@ pub(crate) struct DrupalState { 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 { diff --git a/src/hover/member.rs b/src/hover/member.rs index 53044a821..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 | PropertySource::DrupalField => Vec::new(), + PropertySource::DocblockTag + | PropertySource::DrupalField + | PropertySource::DrupalItemProperty => Vec::new(), } } diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 815f089a2..6a5f4b51d 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -483,7 +483,8 @@ fn property_source(src: &PropertySource) -> Sz { PropertySource::Pivot | PropertySource::DocblockTag | PropertySource::PromotedParameter - | PropertySource::DrupalField => {} + | PropertySource::DrupalField + | PropertySource::DrupalItemProperty => {} } z } @@ -1424,51 +1425,91 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { .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::(), ); - sym.add( - sm.drupal_base_field_methods.capacity() - * size_of::(), - ); - for site in &sm.drupal_base_field_methods { + if let Some(fields) = &sm.drupal_fields { + sym.add(size_of::()); sym.add( - site.fields.capacity() * size_of::() - + site.includes.capacity() - * size_of::(), + fields.base_field_methods.capacity() + * size_of::(), ); - for field in &site.fields { + for site in &fields.base_field_methods { sym.add( - field.field_type.capacity() - + field.target_type.as_ref().map_or(0, String::capacity), + 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( - sm.drupal_item_property_methods.capacity() - * size_of::(), - ); - for site in &sm.drupal_item_property_methods { sym.add( - site.properties.capacity() * size_of::() - + site.removed.capacity() * size_of::() - + site.removed.iter().map(String::capacity).sum::(), + fields.field_items.capacity() * size_of::(), ); - for property in &site.properties { + 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( - property.name.capacity() - + property.data_type.as_ref().map_or(0, String::capacity), + storage.entity_type.capacity() + + storage.field_name.capacity() + + storage.field_type.capacity() + + storage.target_type.as_ref().map_or(0, String::capacity), ); } } - if let Some(storage) = &sm.drupal_field_storage { - sym.add( - size_of::() - + 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/references/member_scope.rs b/src/references/member_scope.rs index a6913876b..4c1b36352 100644 --- a/src/references/member_scope.rs +++ b/src/references/member_scope.rs @@ -583,7 +583,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 5f2cfd472..eecf61180 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -458,16 +458,21 @@ 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_field_storage = (self.is_drupal_project() + 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(Box::new); + .map(|storage| { + Box::new(crate::symbol_map::DrupalFieldSites { + storage: Some(storage), + ..Default::default() + }) + }); SymbolMap { spans, member_access_indices, - drupal_field_storage, + 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 2ddb21a7b..9d75e9bdf 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -35,7 +35,7 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC 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_item_property_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() { diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index 4bdeb0cdc..cead95eff 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -9,9 +9,9 @@ use crate::symbol_map::extraction::symfony::{ }; use crate::symbol_map::{ BaseFieldInclude, BaseFieldMethodSite, BaseFieldName, BaseFieldSite, DeclaredParameter, - DrupalRouteSite, EntityTypeSite, ItemPropertyMethodSite, ItemPropertySite, MethodScope, - ModuleGuard, PluginClassSite, PluginDeclarationSite, PluginManagerSite, ReceiverClass, - StaticPluginSite, StringKeyKind, TypedReceiverSite, + 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; @@ -85,6 +85,10 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( 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, @@ -144,6 +148,7 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( revision_metadata_keys: declared.revision_metadata_keys, translatable: declared.translatable, content: declared.content, + bundle_entity_type: declared.bundle_entity_type, }); } } @@ -160,6 +165,7 @@ struct DeclaredEntityType { revision_metadata_keys: Vec<(String, String)>, translatable: bool, content: bool, + bundle_entity_type: Option, } impl DeclaredEntityType { @@ -174,6 +180,7 @@ impl DeclaredEntityType { revision_metadata_keys: Vec::new(), translatable: false, content: kind.default_storage == Some(CONTENT_STORAGE), + bundle_entity_type: None, } } } @@ -1313,8 +1320,26 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_base_field_methods( mago_syntax::walker::Walker::walk_method(&BaseFieldWalker, method, &mut found); let fields: Vec = found .keyed - .into_iter() - .filter_map(|keyed| base_field(keyed.key, keyed.value, ctx)) + .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 @@ -1340,6 +1365,7 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_base_field_methods( method: crate::atom::atom_bytes(method.name.value), fields, includes, + removed, }); } } @@ -1350,10 +1376,38 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_base_field_methods( fn base_field( name: &Expression<'_>, value: &Expression<'_>, + found: &BaseFieldSites<'_, '_>, ctx: &ExtractionCtx<'_>, ) -> Option { - let (field_type, target_type) = base_field_creation(value, ctx)?; - let (name, start, end) = match name { + 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; @@ -1370,24 +1424,17 @@ fn base_field( return None; }; let span = name_span(call); - (name, span.0, span.1) + 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, - ) + )) } - }; - Some(BaseFieldSite { - name, - field_type, - target_type, - start, - end, - }) + } } /// The byte range of a key lookup call. @@ -1401,8 +1448,9 @@ fn name_span(call: &MethodCall<'_>) -> (u32, u32) { /// of the chain names. fn base_field_creation( value: &Expression<'_>, + found: &BaseFieldSites<'_, '_>, ctx: &ExtractionCtx<'_>, -) -> Option<(String, Option)> { +) -> Option<(String, Option)> { let mut target_type = None; let mut current = value; loop { @@ -1421,8 +1469,7 @@ fn base_field_creation( && resolved_string_literal(setting.value, ctx.content) .is_some_and(|(setting, _, _)| setting == "target_type") { - target_type = resolved_string_literal(target.value, ctx.content) - .map(|(target, _, _)| target.to_string()); + target_type = field_target(target.value, found, ctx); } current = call.object; } @@ -1454,36 +1501,51 @@ fn base_field_creation( } } -/// Record the properties the `propertyDefinitions()` of a class declares -/// for its field type's items (see [`crate::drupal::field_items`]). +/// 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_item_property_methods( +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") { + if !ctx.drupal + || !(ctx.content.contains("propertyDefinitions") + || ctx.content.contains("defaultStorageSettings")) + { return; } - let Some(method) = members.iter().find_map(|member| match member { - ClassLikeMember::Method(method) - if method - .name - .value - .eq_ignore_ascii_case(b"propertyDefinitions") => - { - Some(method) - } - _ => None, - }) else { - 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. @@ -1495,7 +1557,10 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_item_property_methods( _ => None, }) .collect(); - let is_returned = |array: &Expression<'_>| matches!(array, Expression::Variable(Variable::Direct(variable)) if returned.contains(&variable.name)); + 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()) }; @@ -1521,13 +1586,73 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_item_property_methods( && matches!(&call.method, ClassLikeMemberSelector::Identifier(name) if name.value.eq_ignore_ascii_case(b"propertyDefinitions")) }); - ctx.drupal_item_property_methods - .push(ItemPropertyMethodSite { - owner, - properties, - removed, - takes_parent, - }); + 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 @@ -1554,6 +1679,29 @@ fn created_data_type(value: &Expression<'_>, ctx: &ExtractionCtx<'_>) -> Option< } } +/// 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)] @@ -1565,6 +1713,11 @@ struct BaseFieldSites<'ast, 'arena> { 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> { @@ -1585,6 +1738,12 @@ impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BaseFieldSites<'ast 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) { @@ -1596,6 +1755,28 @@ impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BaseFieldSites<'ast } } + 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>, @@ -2977,6 +3158,9 @@ fn annotation_entity_type(doc: &str, offset: u32) -> Option ([], "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()); } @@ -4255,13 +4439,15 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { /// A base field's name as written, the text the name spans, its field /// type, and its target. - type ReadField = (BaseFieldName, String, String, Option); + 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_base_field_methods + .drupal_fields + .map(|fields| fields.base_field_methods) + .unwrap_or_default() .into_iter() .map(|site| { let fields = site @@ -4272,7 +4458,7 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { field.name.clone(), php[field.start as usize..field.end as usize].to_string(), field.field_type.clone(), - field.target_type.clone(), + field.target.clone(), ) }) .collect(); @@ -4323,28 +4509,194 @@ class PlainItem { data_type: data_type.map(str::to_string), }; assert_eq!( - drupal_index_map(php).drupal_item_property_methods, + drupal_index_map(php) + .drupal_fields + .map(|fields| fields.field_items) + .unwrap_or_default(), [ - ItemPropertyMethodSite { + FieldItemSite { owner: crate::atom::atom("Drupal\\acme\\Plugin\\Field\\FieldType\\PictureItem"), - 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, + 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, }, - ItemPropertyMethodSite { + FieldItemSite { owner: crate::atom::atom("Drupal\\acme\\Plugin\\Field\\FieldType\\PlainItem"), - properties: Vec::new(), - removed: Vec::new(), - takes_parent: false, + 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#"getKey('bundle')".to_string(), "entity_reference".to_string(), - Some("acme_note_type".to_string()) + Some(FieldTarget::EntityType("acme_note_type".to_string())) ), ( BaseFieldName::RevisionMetadataKey("revision_user".to_string()), @@ -4430,7 +4782,7 @@ class Page extends Note { key("owner"), "$entity_type->getKey('owner')".to_string(), "entity_reference".to_string(), - Some("user".to_string()) + Some(FieldTarget::EntityType("user".to_string())) )], vec![], ), diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 4b686c0cd..bc494c3e9 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -113,7 +113,7 @@ struct ExtractionCtx<'a> { /// The entity types the file's entity classes declare. entity_types: Vec, drupal_base_field_methods: Vec, - drupal_item_property_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, @@ -630,7 +630,7 @@ fn extract_symbol_map_inner( service_decorations: Vec::new(), entity_types: Vec::new(), drupal_base_field_methods: Vec::new(), - drupal_item_property_methods: Vec::new(), + drupal_field_items: Vec::new(), drupal_plugin_classes: Vec::new(), drupal_plugin_managers: Vec::new(), drupal_plugins: Vec::new(), @@ -874,9 +874,12 @@ fn extract_symbol_map_inner( container_values: ctx.container_values, service_decorations: ctx.service_decorations, entity_types: ctx.entity_types, - drupal_base_field_methods: ctx.drupal_base_field_methods, - drupal_item_property_methods: ctx.drupal_item_property_methods, - drupal_field_storage: None, + 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 18b1f8dd0..caa183a25 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -317,16 +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, - /// The methods of the file's classes and traits that declare Drupal - /// base fields. Empty for every file that declares none. - pub drupal_base_field_methods: Vec, - /// The `propertyDefinitions()` methods of the file's Drupal field item - /// classes. Empty for every file that declares none. - #[cfg_attr(not(any(test, feature = "mem-audit")), expect(dead_code))] - pub drupal_item_property_methods: Vec, - /// The configurable field the file declares, when it is a Drupal field - /// storage file. Boxed, since nearly every file declares none. - pub drupal_field_storage: Option>, + /// 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, @@ -433,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 b55a82e18..67201b776 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -862,6 +862,9 @@ pub(crate) struct EntityTypeSite { 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 @@ -880,6 +883,8 @@ pub(crate) struct BaseFieldMethodSite { /// 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 { @@ -888,9 +893,13 @@ impl BaseFieldMethodSite { 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_type == b.target_type + a.name == b.name + && a.field_type == b.field_type + && a.target == b.target + && a.bundle_condition == b.bundle_condition }) } } @@ -902,12 +911,25 @@ pub(crate) struct BaseFieldSite { /// The field type `create()` names. pub field_type: String, /// The entity type a `setSetting('target_type', …)` on it names. - pub target_type: Option, + 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)] @@ -959,12 +981,22 @@ pub(crate) enum MethodScope { Self_, } -/// A Drupal field item class's `propertyDefinitions()`, which declares the -/// properties of its field type's items. +/// What a Drupal field item class declares about its field type's items +/// in `propertyDefinitions()` and `defaultStorageSettings()`. #[derive(Debug, Clone, PartialEq)] -pub(crate) struct ItemPropertyMethodSite { - /// The class that declares the method. +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'])`. @@ -974,7 +1006,21 @@ pub(crate) struct ItemPropertyMethodSite { pub takes_parent: bool, } -/// A property an [`ItemPropertyMethodSite`] declares. +/// 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, diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index bacb7a2c8..5180bda17 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -259,6 +259,7 @@ 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); 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/types/property.rs b/src/types/property.rs index 58e362b29..adb36f64c 100644 --- a/src/types/property.rs +++ b/src/types/property.rs @@ -172,6 +172,11 @@ pub enum PropertySource { /// 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. @@ -212,7 +217,10 @@ impl PropertyInfo { /// 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)) + matches!( + self.source, + Some(PropertySource::DrupalField | PropertySource::DrupalItemProperty) + ) } /// Return the type hint as a string, if present. diff --git a/src/virtual_members/cache.rs b/src/virtual_members/cache.rs index 23703118b..840bbd19a 100644 --- a/src/virtual_members/cache.rs +++ b/src/virtual_members/cache.rs @@ -334,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 diff --git a/src/virtual_members/mod.rs b/src/virtual_members/mod.rs index a04a8efee..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; @@ -365,6 +365,7 @@ pub fn default_providers(is_laravel: bool, is_drupal: bool) -> Vec, 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(); @@ -617,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); @@ -672,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 diff --git a/src/virtual_members/tests.rs b/src/virtual_members/tests.rs index 5dc78d948..9dd8d2a2f 100644 --- a/src/virtual_members/tests.rs +++ b/src/virtual_members/tests.rs @@ -1160,8 +1160,8 @@ fn default_providers_omits_laravel_when_not_laravel() { fn default_providers_adds_drupal_only_in_drupal() { assert_eq!( default_providers(false, true).len(), - 2, - "a Drupal project should register the PHPDoc and fieldable entity providers" + 3, + "a Drupal project should register the PHPDoc, fieldable entity, and field item providers" ); } diff --git a/tests/integration/drupal_fields.rs b/tests/integration/drupal_fields.rs index c0e79b536..4120cbd86 100644 --- a/tests/integration/drupal_fields.rs +++ b/tests/integration/drupal_fields.rs @@ -256,6 +256,15 @@ abstract class ContentEntityBase implements ContentEntityInterface { 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; } } @@ -286,15 +295,69 @@ trait EntityOwnerTrait { ), ( "web/core/lib/Drupal/Core/Field/FieldItemListInterface.php", - "\n */\nclass FieldItemList implements FieldItemListInterface {\n public function __get($property_name) {}\n}\n", + r#" + * @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", - "setComputed(TRUE); + return $properties; + } +} +"#, ), ( "web/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/StringItem.php", - " \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', @@ -519,6 +638,90 @@ async fn an_entity_type_has_its_own_inherited_and_trait_fields() { } } +/// 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() { @@ -758,6 +961,105 @@ async fn editing_base_fields_refreshes_the_class_and_its_interfaces() { } } +/// 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", + " '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:?}"); +} From 29521f0b4604d9e6aad8a34db80f5f52d68f699d Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 21:31:10 +0100 Subject: [PATCH 09/11] Keep the field types of Drupal fields Assisted-by: claude-code/claude-opus-5-5 --- src/drupal/base_fields.rs | 8 ++++++ src/drupal/fields.rs | 8 ++++++ src/drupal/fields_tests.rs | 52 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/src/drupal/base_fields.rs b/src/drupal/base_fields.rs index 43f45bc56..79d2f0216 100644 --- a/src/drupal/base_fields.rs +++ b/src/drupal/base_fields.rs @@ -628,6 +628,13 @@ impl Backend { }) .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 @@ -639,6 +646,7 @@ impl Backend { Field { name, ty, + field_types, declarations, } }) diff --git a/src/drupal/fields.rs b/src/drupal/fields.rs index 33cf92c56..826cd93d0 100644 --- a/src/drupal/fields.rs +++ b/src/drupal/fields.rs @@ -234,6 +234,9 @@ pub(crate) struct Field { /// 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, @@ -330,6 +333,11 @@ 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()); diff --git a/src/drupal/fields_tests.rs b/src/drupal/fields_tests.rs index 0166d0382..9b545a216 100644 --- a/src/drupal/fields_tests.rs +++ b/src/drupal/fields_tests.rs @@ -248,3 +248,55 @@ fn a_global_interface_named_like_cores_is_not_fieldable() { .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}" + ); +} From 36cb37a7a1088d9b8c31fc274dbae604fd032a91 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 21:54:02 +0100 Subject: [PATCH 10/11] Navigate Drupal field names in code A field name passed to a fieldable entity's get(), set(), hasField() and getFieldDefinition() completes, goes to its declaration, shows its field type on hover, and finds its references, the property reads included. get() with a known field name is typed like reading the field as a property. Assisted-by: claude-code/claude-opus-5-5 --- benches/drupal_completion.rs | 42 +- examples/drupal/README.md | 6 + examples/drupal/assertions.php | 13 + .../modules/custom/acme/src/NoteSummary.php | 17 + src/blade/typed_receiver.rs | 19 + src/completion/laravel_string_keys/mod.rs | 8 +- src/completion/symfony_string_keys.rs | 71 ++- src/diagnostics/laravel_string_keys.rs | 4 +- src/drupal/base_fields.rs | 150 +++++++ src/drupal/fields.rs | 44 +- src/drupal/types.rs | 37 +- src/hover/laravel.rs | 4 + src/reference_index.rs | 5 + src/references/dispatch.rs | 32 +- src/references/member_scope.rs | 45 ++ src/symbol_map/extraction/drupal.rs | 147 ++++++ .../extraction/expressions/calls.rs | 1 + src/symbol_map/string_keys.rs | 26 +- .../call_resolution/return_types/call_arms.rs | 29 ++ .../variable/rhs_resolution/drupal_calls.rs | 34 ++ .../variable/rhs_resolution/method_calls.rs | 8 + src/virtual_members/laravel/string_keys.rs | 8 +- tests/integration/drupal_fields.rs | 419 +++++++++++++++++- 23 files changed, 1141 insertions(+), 28 deletions(-) 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/examples/drupal/README.md b/examples/drupal/README.md index d7ca5a27c..3ba584f3d 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -63,6 +63,12 @@ Run `composer install` here first. Composer puts core and contrib into `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 158fcbc6f..0fedd31d5 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -34,6 +34,7 @@ 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; @@ -300,6 +301,18 @@ protected function getDefaultLanguageValues() 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/web/modules/custom/acme/src/NoteSummary.php b/examples/drupal/web/modules/custom/acme/src/NoteSummary.php index 3731edfa3..b07341e69 100644 --- a/examples/drupal/web/modules/custom/acme/src/NoteSummary.php +++ b/examples/drupal/web/modules/custom/acme/src/NoteSummary.php @@ -44,4 +44,21 @@ public function summarize(NoteInterface $note): string 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/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/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/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 index 79d2f0216..225cf25ca 100644 --- a/src/drupal/base_fields.rs +++ b/src/drupal/base_fields.rs @@ -683,6 +683,156 @@ impl Backend { } } + /// 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. diff --git a/src/drupal/fields.rs b/src/drupal/fields.rs index 826cd93d0..0068a394d 100644 --- a/src/drupal/fields.rs +++ b/src/drupal/fields.rs @@ -36,6 +36,48 @@ use crate::virtual_members::{ResolvedClassCache, VirtualMemberProvider, VirtualM /// 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 { @@ -329,7 +371,7 @@ impl FieldIndex { } /// Make `field` the same field as it is and as `other` is. -fn merge_field(field: &mut Field, other: &Field) { +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()]); } diff --git a/src/drupal/types.rs b/src/drupal/types.rs index f25012229..57220629d 100644 --- a/src/drupal/types.rs +++ b/src/drupal/types.rs @@ -1,6 +1,7 @@ //! The types Drupal's own declarations give: a service `\Drupal::service()` //! looks up, the storage `getStorage()` gives for an entity type ID, the -//! entities that storage loads, and the IDs an entity query finds. +//! entities that storage loads, the IDs an entity query finds, and the +//! field a fieldable entity's `get()` reads. //! //! Shared by the two places the type engine resolves a call: an assignment //! (`$x = …`) and a chain (`…->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/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 4c1b36352..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. /// diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index cead95eff..efd32d54b 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -2838,6 +2838,44 @@ fn push_config_key_span( }); } +/// 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<'_>, +) { + if !ctx.drupal { + return; + } + let Some(parameter) = crate::drupal::fields::field_name_call(member_name) else { + return; + }; + let Some(name) = argument_at(argument_list, 0, parameter.as_bytes()) else { + return; + }; + // 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, @@ -3817,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] 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/string_keys.rs b/src/symbol_map/string_keys.rs index 67201b776..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, } } @@ -1613,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. @@ -1781,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), @@ -1888,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/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/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/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/tests/integration/drupal_fields.rs b/tests/integration/drupal_fields.rs index 4120cbd86..91d4ecaa7 100644 --- a/tests/integration/drupal_fields.rs +++ b/tests/integration/drupal_fields.rs @@ -223,7 +223,18 @@ const CORE: &[(&str, &str)] = &[ ), ( "web/core/lib/Drupal/Core/Entity/FieldableEntityInterface.php", - " 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}"); + } +} From 196dbacb75687d7422e95788a07065ca744e367d Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 10 Nov 2026 21:59:43 +0100 Subject: [PATCH 11/11] Changelog and roadmap for Drupal entity fields Files the follow-ups: fields added by hooks, warnings for unknown field names, and field names in entity queries. Assisted-by: claude-code/claude-opus-5-5 --- docs/CHANGELOG.md | 1 + docs/todo.md | 4 +- docs/todo/drupal.md | 97 ++++++++++++++++++++++++++++++--------------- 3 files changed, 69 insertions(+), 33 deletions(-) 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.