From 81868060a0960f4b044e2274ce42e7989cdd58dd Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Fri, 6 Nov 2026 17:57:26 +0100 Subject: [PATCH 1/6] Drupal render element names A `#type` in a render array completes from the elements the site's `#[RenderElement]` and `#[FormElement]` classes declare, or the annotations older code uses, core's included. It goes to the element's class, hovers it, and a name no element declares is reported. Assisted-by: claude-code/claude-opus-5-5 --- benches/drupal_completion.rs | 32 +- examples/drupal/README.md | 12 +- examples/drupal/assertions.php | 28 ++ .../src/Controller/GreetingController.php | 21 ++ .../custom/acme/src/Element/GreetingCard.php | 27 ++ src/completion/handler/string_arguments.rs | 11 + src/completion/laravel_string_keys/mod.rs | 7 +- src/diagnostics/laravel_string_keys.rs | 43 ++- src/drupal/elements.rs | 167 ++++++++++ src/drupal/mod.rs | 1 + src/hover/laravel.rs | 3 + src/mem_audit.rs | 4 + src/symbol_map/extraction/class_like.rs | 1 + src/symbol_map/extraction/drupal.rs | 315 +++++++++++++++++- .../extraction/expressions/assignment.rs | 1 + src/symbol_map/extraction/expressions/mod.rs | 2 + src/symbol_map/extraction/mod.rs | 10 + src/symbol_map/map.rs | 3 + src/symbol_map/string_keys.rs | 19 +- src/virtual_members/laravel/string_keys.rs | 4 +- tests/integration/drupal_render_elements.rs | 225 +++++++++++++ tests/integration/main.rs | 1 + 22 files changed, 922 insertions(+), 15 deletions(-) create mode 100644 examples/drupal/web/modules/custom/acme/src/Element/GreetingCard.php create mode 100644 src/drupal/elements.rs create mode 100644 tests/integration/drupal_render_elements.rs diff --git a/benches/drupal_completion.rs b/benches/drupal_completion.rs index 45657c9c1..e3afd5998 100644 --- a/benches/drupal_completion.rs +++ b/benches/drupal_completion.rs @@ -8,15 +8,17 @@ use tower_lsp_server::gen_lsp_types::*; /// Custom modules in the generated site, each declaring `SERVICES` /// services, `ROUTES` routes, `ENTITY_TYPES` entity types, `PERMISSIONS` -/// permissions, and `CONFIGS` configuration objects, with their default -/// configuration and schema, and documenting `HOOKS` hooks, and -/// implementing the hooks the module before it documents. +/// permissions, `CONFIGS` configuration objects, with their default +/// configuration and schema, and `ELEMENTS` render elements, and +/// documenting `HOOKS` hooks, and implementing the hooks the module before +/// it documents. const MODULES: usize = 30; const SERVICES: usize = 10; const ROUTES: usize = 10; const ENTITY_TYPES: usize = 3; const PERMISSIONS: usize = 10; const CONFIGS: usize = 10; +const ELEMENTS: usize = 3; const HOOKS: usize = 10; const COMPOSER: &str = r#"{ @@ -44,6 +46,10 @@ const STUBS: &[(&str, &str)] = &[ "web/core/lib/Drupal/Core/Entity/Attribute/ContentEntityType.php", "modules->invokeAll('mod1_'); $this->account->hasPermission('administer mod1 '); \Drupal::config('mod1.'); + $build = ['#type' => 'mod1_']; } } "#; @@ -103,7 +110,8 @@ fn rt() -> tokio::runtime::Runtime { } /// The files of a site with many custom modules: their services, routes, -/// entity types, documented hooks, and hook implementations. +/// entity types, render elements, documented hooks, and hook +/// implementations. fn generate_site() -> Vec<(String, String)> { let mut files = Vec::new(); @@ -163,6 +171,15 @@ fn generate_site() -> Vec<(String, String)> { )); } + for e in 0..ELEMENTS { + files.push(( + format!("{dir}/src/Element/Element{e}.php"), + format!( + " 'mod1_'", + "mod1_element2", + ), ( "hook_name_invoke_all", &consumer, diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 9b1297c21..cae853100 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -3,8 +3,8 @@ A standalone editor playground for PHPantom on Drupal 11. It is a `drupal/recommended-project` site with one custom module, `acme`, and one theme, `acme_theme`, declaring a service, a route, a content entity type, -hooks, a theme hook, permissions, and configuration the way a real Drupal -module declares them. +a render element, hooks, a theme hook, permissions, and configuration the +way a real Drupal module declares them. Run `composer install` here first. Composer puts core and contrib into `web/`, which is git-ignored apart from the custom module and theme. @@ -105,6 +105,14 @@ Run `composer install` here first. Composer puts core and contrib into hover `$languageManager`: core declares `language_manager` as `LanguageManager`, and the language module's `LanguageServiceProvider` swaps in `ConfigurableLanguageManager`, so it is either class. +- Render element names in `#type` navigate to the element class that + declares them. In `card()` in + `web/modules/custom/acme/src/Controller/GreetingController.php`, go to the + definition of `'acme_greeting_card'`, which lands on + `src/Element/GreetingCard.php`, or hover `'details'` to see core's + `Details` class. Complete a name inside `'#type' => ''` or + `$build['more']['#type'] = ''`; core's elements are offered too. A name + no element class declares is reported. ## What the Symfony support already reads diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 095d9cca3..b5177a78c 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -15,6 +15,7 @@ use Drupal\acme\Controller\GreetingController; use Drupal\acme\Controller\NoteController; +use Drupal\acme\Element\GreetingCard; use Drupal\acme\Entity\Note; use Drupal\acme\Form\SettingsForm; use Drupal\acme\Greeter; @@ -42,6 +43,10 @@ use Drupal\Core\Language\LanguageManager; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Link; +use Drupal\Core\Render\Attribute\FormElement; +use Drupal\Core\Render\Attribute\RenderElement; +use Drupal\Core\Render\Element\Details; +use Drupal\Core\Render\Element\RenderElementBase; use Drupal\Core\Url; use Drupal\language\ConfigurableLanguageManager; use Drupal\language\LanguageServiceProvider; @@ -282,6 +287,29 @@ protected function getDefaultLanguageValues() check("core documents hook_form_alter() with a docblock acme_form_alter()'s hover shows", (bool) preg_match('~/\*\*\n \* Perform alterations before a form is rendered\.\n(?: \*[^\n]*\n)* \*/\nfunction hook_form_alter\(~', (string) file_get_contents("$web/core/lib/Drupal/Core/Form/form.api.php"))); check("core's FormBuilder::prepareForm() fires the form alter hooks", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Form/FormBuilder.php"), '$this->moduleHandler->alter($hooks, $form, $form_state, $form_id);')); +// ─── Render elements ──────────────────────────────────────────────────────── + +$card = new ReflectionClass(GreetingCard::class); +check('GreetingCard declares the acme_greeting_card element by attribute', (attributeArguments($card, RenderElement::class)[0] ?? null) === 'acme_greeting_card'); +check('GreetingCard is a render element', $card->isSubclassOf(RenderElementBase::class)); +check("core's Details declares the details element", (attributeArguments(new ReflectionClass(Details::class), RenderElement::class)[0] ?? null) === 'details'); + +// Every element the controller names is one the module or core declares. +$declaredElements = ['acme_greeting_card']; +foreach (glob("$web/core/lib/Drupal/Core/Render/Element/*.php") ?: [] as $file) { + $class = 'Drupal\\Core\\Render\\Element\\' . basename($file, '.php'); + if (!class_exists($class)) { + continue; + } + $element = new ReflectionClass($class); + foreach ([RenderElement::class, FormElement::class] as $attribute) { + $declaredElements[] = attributeArguments($element, $attribute)[0] ?? null; + } +} +preg_match_all("/'#type'(?:\\] =| =>) '([^']+)'/", $controllerSource, $namedElements); +check('GreetingController names the custom and a core element in a literal and an assignment', $namedElements[1] === ['acme_greeting_card', 'details']); +check('every element GreetingController names is declared', array_diff($namedElements[1], $declaredElements) === []); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; diff --git a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php index 2d84da338..76c05179c 100644 --- a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php +++ b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php @@ -58,4 +58,25 @@ public function worldLink(): Link return Link::createFromRoute($this->t('Greet the world'), 'acme.greeting', $url->getRouteParameters()); } + + /** + * The greeting for `$name` in a card, with more about it folded away. + * + * @return array + */ + public function card(string $name): array + { + // Try: go to the definition of 'acme_greeting_card', which lands on + // the GreetingCard class, or hover 'details' to see core's element + // class. Complete an element name inside '#type' => '', core's + // elements included. + $build = [ + '#type' => 'acme_greeting_card', + 'greeting' => $this->greet($name), + ]; + $build['more']['#type'] = 'details'; + $build['more']['#title'] = $this->t('More'); + + return $build; + } } diff --git a/examples/drupal/web/modules/custom/acme/src/Element/GreetingCard.php b/examples/drupal/web/modules/custom/acme/src/Element/GreetingCard.php new file mode 100644 index 000000000..c4ff22fdd --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/Element/GreetingCard.php @@ -0,0 +1,27 @@ + + */ + public function getInfo(): array + { + return [ + '#theme_wrappers' => ['container'], + '#attributes' => ['class' => ['acme-greeting-card']], + ]; + } +} diff --git a/src/completion/handler/string_arguments.rs b/src/completion/handler/string_arguments.rs index 6dd0c32a6..50cbb0a3f 100644 --- a/src/completion/handler/string_arguments.rs +++ b/src/completion/handler/string_arguments.rs @@ -138,6 +138,17 @@ impl Backend { return Some(response); } + // ── Drupal render elements ────────────────────────────── + // `'#type' => '|'` and `$form['name']['#type'] = '|'`. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = + self.try_drupal_render_element_completion(uri, content, position, code) + { + return Some(response); + } + // ── Symfony form options ──────────────────────────────── // `$builder->add('email', EmailType::class, ['|' => …])`, wherever // the form component is used. diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index b52452662..e2c8ad4b1 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -44,7 +44,9 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { StringKeyKind::GateAbility => CompletionItemKind::Method, StringKeyKind::Env => CompletionItemKind::Constant, StringKeyKind::ServiceId => CompletionItemKind::Reference, - StringKeyKind::DrupalEntityType => CompletionItemKind::Class, + StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRenderElement => { + CompletionItemKind::Class + } StringKeyKind::ContainerParameter => CompletionItemKind::Property, StringKeyKind::SymfonyConfigKey => CompletionItemKind::Field, StringKeyKind::SymfonyEvent | StringKeyKind::WordPressHook | StringKeyKind::DrupalHook => { @@ -196,7 +198,8 @@ impl Backend { | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRoute | StringKeyKind::DrupalPermission - | StringKeyKind::DrupalConfigName => self.symfony_declared_names(*kind).into(), + | StringKeyKind::DrupalConfigName + | StringKeyKind::DrupalRenderElement => self.symfony_declared_names(*kind).into(), StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => { self.twig_callable_names(*kind).into() } diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index 64ea605e9..21d67e748 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -48,6 +48,7 @@ enum CheckedStringKind { YiiRoute, YiiRouteArgument, DrupalRoute, + DrupalRenderElement, } impl CheckedStringKind { @@ -78,6 +79,7 @@ impl CheckedStringKind { StringKeyKind::YiiRoute => Self::YiiRoute, StringKeyKind::YiiRouteArgument => Self::YiiRouteArgument, StringKeyKind::DrupalRoute => Self::DrupalRoute, + StringKeyKind::DrupalRenderElement => Self::DrupalRenderElement, // A section or stack name is judged against the templates that // render the one it is written in, which the Blade pass has and // this one does not. And anything at all can be bound at @@ -188,6 +190,8 @@ struct DeclaredKeys<'a> { /// The `Class::method` and `Class` names Drupal gives attribute routes, /// sorted. drupal_route_aliases: Vec, + /// The Drupal render element names the project declares, sorted. + drupal_render_elements: Vec, } impl<'a> DeclaredKeys<'a> { @@ -223,6 +227,8 @@ impl<'a> DeclaredKeys<'a> { let has_messenger_bus = names(|kind| kind == CheckedStringKind::MessengerBus); let has_yii_route = names(|kind| kind == CheckedStringKind::YiiRoute); let has_drupal_route = names(|kind| kind == CheckedStringKind::DrupalRoute); + let has_drupal_render_element = + names(|kind| kind == CheckedStringKind::DrupalRenderElement); // A package with no routes of its own, whose names are registered // by the host application, cannot be judged: the valid set is @@ -395,6 +401,15 @@ impl<'a> DeclaredKeys<'a> { (Vec::new(), Vec::new(), Vec::new()) }; + // Core's and contributed modules' elements are only known once + // the workspace index has read the folders `.gitignore` leaves out. + let drupal_render_elements = if has_drupal_render_element { + backend.ensure_workspace_index_ready_for_request(); + backend.symfony_declared_names(crate::symbol_map::StringKeyKind::DrupalRenderElement) + } else { + Vec::new() + }; + Self { backend, routes, @@ -421,6 +436,7 @@ impl<'a> DeclaredKeys<'a> { drupal_routes, drupal_route_patterns, drupal_route_aliases, + drupal_render_elements, } } @@ -504,6 +520,7 @@ impl<'a> DeclaredKeys<'a> { CheckedStringKind::YiiRoute => self.judge_yii_route(key), CheckedStringKind::YiiRouteArgument => self.judge_yii_route_argument(key), CheckedStringKind::DrupalRoute => self.judge_drupal_route(key), + CheckedStringKind::DrupalRenderElement => self.judge_drupal_render_element(key), } } @@ -576,6 +593,20 @@ impl<'a> DeclaredKeys<'a> { Some((false, VERDICT.0, VERDICT.1)) } + /// Every render element is a class with an element attribute or + /// annotation, so the names they declare are all of them. A project + /// that declares none was not read at all. + fn judge_drupal_render_element(&self, key: &str) -> Verdict { + if self.drupal_render_elements.is_empty() { + return None; + } + let valid = self + .drupal_render_elements + .binary_search_by(|name| name.as_str().cmp(key)) + .is_ok(); + Some((valid, "render element", "invalid_drupal_render_element")) + } + /// No package Yii installs declares routes of its own, so the /// project's routes are all of them. A project that declares none was /// not read at all. @@ -906,9 +937,15 @@ impl Backend { { return None; } - // Test code passes invented route names to mocks, and an - // `*.api.php` file's examples name invented routes too. - if *kind == crate::symbol_map::StringKeyKind::DrupalRoute && is_drupal_test_code { + // Test code passes invented route names to mocks and uses + // its test modules' elements, and an `*.api.php` file's + // examples name invented ones too. + if matches!( + kind, + crate::symbol_map::StringKeyKind::DrupalRoute + | crate::symbol_map::StringKeyKind::DrupalRenderElement + ) && is_drupal_test_code + { return None; } Some(( diff --git a/src/drupal/elements.rs b/src/drupal/elements.rs new file mode 100644 index 000000000..82c80da3e --- /dev/null +++ b/src/drupal/elements.rs @@ -0,0 +1,167 @@ +//! Drupal's render elements: the names a `#[RenderElement]` or +//! `#[FormElement]` class (or the annotation older code uses) declares, +//! read by the `#type` of a render array. + +use tower_lsp_server::gen_lsp_types::{CompletionResponse, Location, Position}; + +use crate::Backend; +use crate::atom::Atom; +use crate::completion::source::code_context::CodeContext; +use crate::symbol_map::{StringKeyKind, SymbolKind}; + +/// The `#`-prefixed key of a render array whose value is the string that +/// starts after `code_before`, the code before its opening quote: +/// `'#type' => '…'` in an array literal, or `$a['#type'] = '…'`. +pub(crate) fn render_array_value_key(code_before: &str) -> Option<&str> { + value_key(code_before).filter(|key| key.starts_with('#')) +} + +/// The literal key whose value starts after `code_before`: the key of a +/// `'key' =>` entry, or the last offset of a `$a['key'] =` assignment. +pub(crate) fn value_key(code_before: &str) -> Option<&str> { + arrow_key(code_before).or_else(|| { + let target = assignment_target(code_before)?; + trailing_literal(target.trim_end().strip_suffix(']')?) + }) +} + +/// The literal key of the `'key' =>` entry `before` ends with. +pub(crate) fn arrow_key(before: &str) -> Option<&str> { + trailing_literal(before.trim_end().strip_suffix("=>")?) +} + +/// The code `before`, which ends with an assignment's `=`, assigns to: the +/// code before a plain `=`, not a comparison or a compound assignment. +pub(crate) fn assignment_target(before: &str) -> Option<&str> { + let target = before.trim_end().strip_suffix('=')?; + (!target.ends_with([ + '=', '!', '<', '>', '.', '+', '-', '*', '/', '%', '&', '|', '^', '?', ':', + ])) + .then_some(target) +} + +/// The contents of the string literal `text` ends with. +pub(crate) fn trailing_literal(text: &str) -> Option<&str> { + let text = text.trim_end(); + let quote = text + .chars() + .next_back() + .filter(|c| matches!(c, '\'' | '"'))?; + let inner = &text[..text.len() - 1]; + Some(&inner[inner.rfind(quote)? + 1..]) +} + +impl Backend { + /// The classes that declare the plugin `id` as a key of `kind`, in a + /// stable order. + pub(crate) fn drupal_plugin_classes(&self, kind: StringKeyKind, id: &str) -> Vec { + let mut found: Vec = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + for site in &map.drupal_plugin_classes { + let at = map.spans.partition_point(|span| span.start < site.start); + let declares_id = map.spans[at..] + .iter() + .take_while(|span| span.start == site.start) + .any(|span| { + matches!( + &span.kind, + SymbolKind::StringKey { kind: declared, key, .. } + if *declared == kind && key == id + ) + }); + if declares_id && !found.contains(&site.class) { + found.push(site.class); + } + } + }); + found + } + + /// Where the render element `name` is declared: the name of each class + /// that declares it, else the ID its declaration writes. + pub(crate) fn drupal_render_element_definitions(&self, name: &str) -> Vec { + let kind = StringKeyKind::DrupalRenderElement; + let classes: Vec = self + .drupal_plugin_classes(kind, name) + .iter() + .filter_map(|class| self.class_declaration_location(class)) + .collect(); + if classes.is_empty() { + self.symfony_declaration_locations(kind, name) + } else { + classes + } + } + + /// The hover detail of the render element `name`: the classes that + /// declare it and the file of the first. + pub(crate) fn drupal_render_element_detail(&self, name: &str) -> String { + let kind = StringKeyKind::DrupalRenderElement; + let classes = self.drupal_plugin_classes(kind, name); + if classes.is_empty() { + return "Not declared by any element class in the project".to_string(); + } + let mut detail = classes + .iter() + .map(|class| format!("Class `{class}`")) + .collect::>() + .join("\n\n"); + if let Some(path) = self + .symfony_declaration_locations(kind, name) + .first() + .and_then(|location| self.workspace_relative_path(location.uri.as_str())) + { + detail.push_str(&format!("\n\nDeclared in `{path}`")); + } + detail + } + + /// Complete a render element name in the `#type` of a render array. + pub(crate) fn try_drupal_render_element_completion( + &self, + uri: &str, + content: &str, + position: Position, + code: &CodeContext<'_>, + ) -> Option { + let (quote, _) = code.open_string?; + if render_array_value_key(code.code_before)? != "#type" { + return None; + } + let cursor = crate::text_position::position_to_offset(content, position) as usize; + self.symfony_key_response( + uri, + StringKeyKind::DrupalRenderElement, + content, + quote + 1, + cursor, + position, + ) + } +} + +#[cfg(test)] +mod tests { + use super::render_array_value_key; + + #[test] + fn the_key_before_a_value_is_read_from_entries_and_assignments() { + assert_eq!(render_array_value_key("return ['#type' =>"), Some("#type")); + assert_eq!(render_array_value_key("[\"#type\"=>"), Some("#type")); + assert_eq!( + render_array_value_key("$form['name']['#type'] ="), + Some("#type") + ); + assert_eq!( + render_array_value_key("$form['name'][ \"#type\" ]="), + Some("#type") + ); + assert_eq!(render_array_value_key("['type' =>"), None); + assert_eq!(render_array_value_key("$form['#type'] =="), None); + assert_eq!(render_array_value_key("$form['#type'] .="), None); + assert_eq!(render_array_value_key("$form['#type'] !="), None); + assert_eq!(render_array_value_key("$form['#type'] ??="), None); + assert_eq!(render_array_value_key("$type ="), None); + assert_eq!(render_array_value_key("foo("), None); + } +} diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 8462b429f..fb4c8bc11 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -19,6 +19,7 @@ use parking_lot::RwLock; pub(crate) mod computed_hooks; pub(crate) mod config; pub(crate) mod config_keys; +pub(crate) mod elements; pub(crate) mod hooks; pub(crate) mod links; pub(crate) mod permissions; diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index 39746c125..b68577cd4 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -449,6 +449,9 @@ impl Backend { ("View", detail) } StringKeyKind::WordPressHook => ("Hook", self.wordpress_hook_detail(key)), + StringKeyKind::DrupalRenderElement => { + ("Render element", self.drupal_render_element_detail(key)) + } }; Some(make_hover(format!( diff --git a/src/mem_audit.rs b/src/mem_audit.rs index cccd61b46..71fc0c6fb 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1405,6 +1405,10 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { sm.container_values.capacity() * size_of::(), ); sym.add(sm.entity_types.capacity() * size_of::()); + sym.add( + sm.drupal_plugin_classes.capacity() + * size_of::(), + ); sym.add(sm.drupal_route_patterns.capacity() * size_of::()); sym.add(sm.drupal_route_aliases.capacity() * size_of::()); sym.add(sm.drupal_hooks.capacity() * size_of::()); diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index a73e642e4..6480ad2cc 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -24,6 +24,7 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC emit_twig_global_declarations(class, ctx); emit_yii_route_argument_spans(class, ctx); emit_drupal_entity_type_spans(class, ctx); + emit_drupal_render_element_spans(class, ctx); emit_drupal_route_attribute_spans(class, ctx); emit_drupal_hook_attribute_spans(class, ctx); emit_drupal_computed_hook_sites(&class.members, class.name.span.start.offset, ctx); diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index dc9350b2f..1e9d70cba 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -7,10 +7,11 @@ use crate::symbol_map::extraction::symfony::{ RouteDeclaration, RouteName, is_route_attribute, push_route_spans, route_declarations, }; use crate::symbol_map::{ - DeclaredParameter, DrupalRouteSite, EntityTypeSite, ReceiverClass, StringKeyKind, - TypedReceiverSite, + DeclaredParameter, DrupalRouteSite, EntityTypeSite, PluginClassSite, ReceiverClass, + StringKeyKind, TypedReceiverSite, }; use crate::virtual_members::laravel::helpers::resolved_string_literal; +use mago_syntax::cst::sequence::TokenSeparatedSequence; const CONTENT_STORAGE: &str = "Drupal\\Core\\Entity\\Sql\\SqlContentEntityStorage"; const CONFIG_STORAGE: &str = "Drupal\\Core\\Config\\Entity\\ConfigEntityStorage"; @@ -132,6 +133,173 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( } } +/// A Drupal plugin attribute and the annotation older code writes instead. +struct PluginMarker { + attribute: &'static str, + /// The annotation's opening, matched by short name, the way Drupal's + /// annotation discovery reads it. + annotation: &'static str, +} + +const RENDER_ELEMENT_MARKERS: [PluginMarker; 2] = [ + PluginMarker { + attribute: "Drupal\\Core\\Render\\Attribute\\RenderElement", + annotation: "@RenderElement(", + }, + PluginMarker { + attribute: "Drupal\\Core\\Render\\Attribute\\FormElement", + annotation: "@FormElement(", + }, +]; + +/// Record the render element `class` declares: a span over its ID, and +/// the class beside it. +pub(in crate::symbol_map::extraction) fn emit_drupal_render_element_spans( + class: &Class<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + emit_drupal_plugin_spans( + class, + &RENDER_ELEMENT_MARKERS, + StringKeyKind::DrupalRenderElement, + ctx, + ); +} + +/// Record the plugin ID `class` declares by one of the `markers`, as a key +/// of `kind`, with the class it is written on. An attribute wins over an +/// annotation, as in Drupal's discovery. +fn emit_drupal_plugin_spans( + class: &Class<'_>, + markers: &[PluginMarker], + kind: StringKeyKind, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let mut found = Vec::new(); + for marker in markers { + for attribute in attributes_named(&class.attribute_lists, marker.attribute, ctx) { + let id = attribute_arguments(attribute, &["id"]) + .into_iter() + .find(|(parameter, _)| *parameter == "id") + .and_then(|(_, value)| resolved_string_literal(value, ctx.content)); + if let Some((key, start, end)) = id { + found.push((key.to_string(), start as u32, end as u32)); + } + } + } + if found.is_empty() + && let Some((doc, offset)) = get_docblock_text_with_offset(ctx.trivias, ctx.content, class) + && let Some((start, end)) = markers.iter().find_map(|marker| { + let at = doc.find(marker.annotation)?; + annotation_plugin_id(doc, at + marker.annotation.len()) + }) + { + found.push(( + doc[start..end].to_string(), + offset + start as u32, + offset + end as u32, + )); + } + found.retain(|(key, _, _)| !key.is_empty()); + if found.is_empty() { + return; + } + let Some(fqn) = ctx + .resolved_name_at(class.name.span.start.offset) + .map(|fqn| crate::atom::atom(fqn.trim_start_matches('\\'))) + else { + return; + }; + for (key, start, end) in found { + ctx.spans.push(SymbolSpan { + start, + end, + kind: SymbolKind::StringKey { + kind, + key, + is_write: true, + is_optional: false, + }, + }); + ctx.drupal_plugin_classes + .push(PluginClassSite { start, class: fqn }); + } +} + +/// The range of the ID a plugin annotation whose arguments start at `from` +/// in `doc` declares: its first argument when that is a string +/// (`@RenderElement("details")`), else its `id = "…"`. +fn annotation_plugin_id(doc: &str, from: usize) -> Option<(usize, usize)> { + let rest = doc.get(from..)?; + let first = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '*'); + if let Some(quoted) = first.strip_prefix('"') { + let start = doc.len() - quoted.len(); + return Some((start, start + quoted.find('"')?)); + } + annotation_entries(doc, from) + .into_iter() + .find(|entry| entry.path.is_empty() && entry.key == "id") + .map(|entry| (entry.value_start, entry.value_end)) +} + +/// Record the render element name a `'#type' => 'x'` entry of an array +/// literal reads. +pub(in crate::symbol_map::extraction) fn emit_drupal_render_element_reads( + elements: &TokenSeparatedSequence<'_, ArrayElement<'_>>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal_type_keys { + return; + } + for element in elements.iter() { + if let ArrayElement::KeyValue(entry) = element { + push_render_element_read(entry.key, entry.value, ctx); + } + } +} + +/// Record the render element name a `$form['x']['#type'] = 'x'` +/// assignment reads. +pub(in crate::symbol_map::extraction) fn emit_drupal_render_element_assignment( + assign: &Assignment<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal_type_keys || !assign.operator.is_assign() { + return; + } + if let Expression::ArrayAccess(access) = assign.lhs { + push_render_element_read(access.index, assign.rhs, ctx); + } +} + +/// Record `value` as the render element name it reads when `key` is the +/// literal `#type` and `value` a string literal. +fn push_render_element_read( + key: &Expression<'_>, + value: &Expression<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !resolved_string_literal(key, ctx.content).is_some_and(|(key, _, _)| key == "#type") { + return; + } + let Some((name, start, end)) = resolved_string_literal(value, ctx.content) else { + return; + }; + ctx.spans.push(SymbolSpan { + start: start as u32, + end: end as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRenderElement, + key: name.to_string(), + is_write: false, + is_optional: false, + }, + }); +} + /// Record the routes the `#[Route]` attributes of a controller declare, the /// way Drupal's attribute route discovery reads them. /// @@ -2819,4 +2987,147 @@ class NodeRouteProvider extends DefaultHtmlRouteProvider { fn a_doc_comment_without_an_entity_type_declares_none() { assert!(annotation_entity_type("/** @see EntityType(id = \"x\") */", 0).is_none()); } + + /// The symbol map of `php` in a Drupal project, or in another one. + fn map_of(php: &str, drupal: bool) -> crate::symbol_map::SymbolMap { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: drupal, + drupal, + twig: false, + doctrine: false, + }, + None, + ) + } + + /// The render elements `php` declares, each with the ID's text and its + /// class. + fn elements(php: &str) -> Vec<(String, String, String)> { + let map = map_of(php, true); + map.drupal_plugin_classes + .iter() + .filter_map(|site| { + let span = map.spans.iter().find(|span| { + span.start == site.start + && matches!( + span.kind, + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRenderElement, + is_write: true, + .. + } + ) + })?; + let SymbolKind::StringKey { key, .. } = &span.kind else { + return None; + }; + Some(( + key.clone(), + php[span.start as usize..span.end as usize].to_string(), + site.class.to_string(), + )) + }) + .collect() + } + + /// The render element names `php` reads, in source order. + fn element_reads(php: &str, drupal: bool) -> Vec { + map_of(php, drupal) + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRenderElement, + key, + is_write: false, + .. + } => { + assert_eq!(&php[span.start as usize..span.end as usize], key); + Some(key.clone()) + } + _ => None, + }) + .collect() + } + + #[test] + fn render_and_form_element_attributes_declare_their_ids() { + let php = " 'details',\n 'inner' => array('#type' => 'container', '#title' => 'not_an_element'),\n '#theme' => 'not_an_element',\n 'computed' => ['#type' => $type],\n ];\n}\n"; + assert_eq!( + element_reads(php, true), + ["textfield", "form", "details", "container"] + ); + assert!(element_reads(php, false).is_empty()); + } } diff --git a/src/symbol_map/extraction/expressions/assignment.rs b/src/symbol_map/extraction/expressions/assignment.rs index 41501e6b7..c25210ff9 100644 --- a/src/symbol_map/extraction/expressions/assignment.rs +++ b/src/symbol_map/extraction/expressions/assignment.rs @@ -14,6 +14,7 @@ pub(super) fn extract_assignment_expr<'a>( note_route_configurator_assignment(assign, ctx); note_own_receiver_assignment(assign, ctx); crate::symbol_map::extraction::doctrine_dql::note_builder_assignment(assign, ctx); + emit_drupal_render_element_assignment(assign, ctx); // The definition only becomes visible *after* the entire // assignment expression — the RHS still sees the previous diff --git a/src/symbol_map/extraction/expressions/mod.rs b/src/symbol_map/extraction/expressions/mod.rs index 77b22cdb9..79c6310ce 100644 --- a/src/symbol_map/extraction/expressions/mod.rs +++ b/src/symbol_map/extraction/expressions/mod.rs @@ -243,11 +243,13 @@ pub(super) fn extract_from_expression<'a>( Expression::Array(array) => { try_emit_array_callable_span(&array.elements, ctx.content, &mut ctx.spans); emit_drupal_config_target_entry_spans(&array.elements, ctx); + emit_drupal_render_element_reads(&array.elements, ctx); extract_from_array_elements(&array.elements, ctx, scope_start); } Expression::LegacyArray(array) => { try_emit_array_callable_span(&array.elements, ctx.content, &mut ctx.spans); emit_drupal_config_target_entry_spans(&array.elements, ctx); + emit_drupal_render_element_reads(&array.elements, ctx); extract_from_array_elements(&array.elements, ctx, scope_start); } Expression::List(list) => { diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 1c1cb9fea..4fcb82abf 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -104,6 +104,12 @@ struct ExtractionCtx<'a> { container_values: Vec, /// The entity types the file's entity classes declare. entity_types: Vec, + /// The classes the file's Drupal plugin declarations are written on. + drupal_plugin_classes: Vec, + /// Whether the file is a Drupal site's and says `#type`, the key a + /// render array names its element under. Spares every other file's + /// arrays and assignments the look. + drupal_type_keys: bool, drupal_route_patterns: Vec, drupal_route_aliases: Vec, /// The Drupal routes the file's `#[Route]` attributes declare. @@ -548,6 +554,8 @@ fn extract_symbol_map_inner( twig_contexts: Vec::new(), container_values: Vec::new(), entity_types: Vec::new(), + drupal_plugin_classes: Vec::new(), + drupal_type_keys: drupal && memchr::memmem::find(content.as_bytes(), b"#type").is_some(), drupal_route_patterns: Vec::new(), drupal_route_aliases: Vec::new(), drupal_routes: Vec::new(), @@ -715,6 +723,7 @@ fn extract_symbol_map_inner( ctx.morph_column_sites.sort_by_key(|s| s.start); ctx.container_values.sort_by_key(|s| s.start); ctx.entity_types.sort_by_key(|s| s.start); + ctx.drupal_plugin_classes.sort_by_key(|s| s.start); ctx.drupal_hooks.sort_by_key(|s| s.start); ctx.drupal_hooks.dedup_by_key(|s| s.start); ctx.routes.shrink_to_fit(); @@ -758,6 +767,7 @@ fn extract_symbol_map_inner( twig_contexts: ctx.twig_contexts, container_values: ctx.container_values, entity_types: ctx.entity_types, + drupal_plugin_classes: ctx.drupal_plugin_classes, drupal_route_patterns: ctx.drupal_route_patterns, drupal_route_aliases: ctx.drupal_route_aliases, drupal_routes: ctx.drupal_routes, diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index 6010ef3e9..8988f8b2a 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -307,6 +307,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 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, /// The shapes of the Drupal route names the file's route builders give /// routes they build from parts, `*` for each part that is not /// written out (`search.view_*`). Empty for every file that builds diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index 0a81e7d58..3a26e519f 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -200,6 +200,11 @@ pub(crate) enum StringKeyKind { /// object's `get()`, `set()`, and `clear()`, the part after the `:` of /// a `#config_target`, and a `ConfigTarget`'s property path. DrupalConfigKey, + /// A Drupal render element name: declared by the ID of a class's + /// `#[RenderElement]` or `#[FormElement]` attribute, or of the + /// annotation older code uses, and read by the `#type` of a render + /// array. + DrupalRenderElement, } impl StringKeyKind { @@ -253,7 +258,8 @@ impl StringKeyKind { | Self::DrupalHook | Self::DrupalPermission | Self::DrupalConfigName - | Self::DrupalConfigKey => KeyFramework::Drupal, + | Self::DrupalConfigKey + | Self::DrupalRenderElement => KeyFramework::Drupal, } } @@ -704,6 +710,17 @@ pub(crate) struct EntityTypeSite { pub storage: Option, } +/// The class a Drupal plugin declaration is written on, such as a render +/// element's. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PluginClassSite { + /// Byte offset of the plugin ID's first character, matching the + /// declaration's [`SymbolKind::StringKey`] span. + pub start: u32, + /// The class the attribute or annotation is written on. + pub class: crate::atom::Atom, +} + /// The value a container declaration gives, read by the kind of name it /// turns out to declare. #[derive(Debug, Clone, PartialEq)] diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 9283c3b45..09239df71 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -86,6 +86,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::DrupalRenderElement => backend.drupal_render_element_definitions(key), StringKeyKind::YiiView => backend.resolve_yii_view_definitions(key), StringKeyKind::TwigBlock => backend.twig_block_definitions(uri, None, key), StringKeyKind::TwigVariable => backend.twig_variable_definitions(uri, None, key), @@ -325,7 +326,8 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRoute | StringKeyKind::DrupalRouteParameter - | StringKeyKind::DrupalPermission => { + | StringKeyKind::DrupalPermission + | StringKeyKind::DrupalRenderElement => { return find_string_key_usages(kind, key, backend, snapshot, include_declaration); } // A documented pattern stands for every hook that fills it in. diff --git a/tests/integration/drupal_render_elements.rs b/tests/integration/drupal_render_elements.rs new file mode 100644 index 000000000..e4aba22e5 --- /dev/null +++ b/tests/integration/drupal_render_elements.rs @@ -0,0 +1,225 @@ +//! Drupal's render elements: the names a `#[RenderElement]` or +//! `#[FormElement]` class (or its annotation) declares, read by the `#type` +//! of a render array. + +use crate::common::{completion_response, hover_text_at, response_labels}; +use crate::drupal_services::{definitions_at, position_in, uri_for, workspace_in}; +use phpantom_lsp::Backend; +use tower_lsp_server::LanguageServer; +use tower_lsp_server::gen_lsp_types::*; + +const DETAILS_PATH: &str = "web/core/lib/Drupal/Core/Render/Element/Details.php"; + +const DETAILS: &str = " 'element_test_probe']; + return [ + '#type' => 'details', + 'empty' => ['#type' => ''], + ]; + } +} +"#; + +/// A Drupal site with core's and a test module's elements behind the +/// site's `.gitignore`, the custom elements, and `files` open, indexed the +/// way the background index reads it. +async fn site(files: &[(&str, &str)]) -> (Backend, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + (DETAILS_PATH, DETAILS), + (PROBE_PATH, PROBE), + (CARD_PATH, CARD), + (BADGE_PATH, BADGE), + ] { + 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_in(dir, files).await; + backend.ensure_workspace_indexed(); + (backend, dir) +} + +/// The labels completion offers at `inside` bytes into `needle` in `php`. +async fn labels_at( + backend: &Backend, + uri: &Uri, + php: &str, + needle: &str, + inside: usize, +) -> Vec { + let position = position_in(php, needle, inside); + response_labels( + completion_response(backend, uri, position.line, position.character, None).await, + ) +} + +/// The unknown-element messages the diagnostics of `uri` report. +fn unknown_elements(backend: &Backend, uri: &Uri, content: &str) -> Vec { + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), content, &mut diagnostics); + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(Code::String(code)) if code == "invalid_drupal_render_element" + ) + }) + .map(|diagnostic| crate::common::message_text(&diagnostic.message).to_string()) + .collect() +} + +#[tokio::test] +async fn element_names_complete_from_core_and_custom_elements() { + let (backend, dir) = site(&[(BUILDER_PATH, BUILDER)]).await; + let php = uri_for(&dir, BUILDER_PATH); + let in_literal = labels_at(&backend, &php, BUILDER, "'#type' => ''", 12).await; + for name in ["details", "acme_card", "acme_badge"] { + assert!( + in_literal.contains(&name.to_string()), + "{name}: {in_literal:?}" + ); + } + assert!( + !in_literal.contains(&"element_test_probe".to_string()), + "{in_literal:?}" + ); + let in_assignment = labels_at(&backend, &php, BUILDER, "['#type'] = 'acme_", 18).await; + assert_eq!(in_assignment, ["acme_badge", "acme_card"]); +} + +#[tokio::test] +async fn an_element_name_goes_to_its_class_and_hovers_it() { + let (backend, dir) = site(&[(BUILDER_PATH, BUILDER)]).await; + let php = uri_for(&dir, BUILDER_PATH); + for (needle, target, line) in [ + ("'acme_card'", CARD_PATH, 6), + ("'acme_badge'", BADGE_PATH, 8), + ("'details'", DETAILS_PATH, 6), + ] { + let found = definitions_at(&backend, &php, position_in(BUILDER, needle, 2)).await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!( + (&found[0].uri, found[0].range.start.line), + (&uri_for(&dir, target), line), + "{needle}" + ); + } + + let position = position_in(BUILDER, "'acme_card'", 2); + let hover = hover_text_at(&backend, &php, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("Render element"), "{hover}"); + assert!(hover.contains("Drupal\\acme\\Element\\Card"), "{hover}"); + assert!(hover.contains(CARD_PATH), "{hover}"); +} + +#[tokio::test] +async fn an_unknown_element_name_is_reported() { + let (backend, dir) = site(&[(BUILDER_PATH, BUILDER)]).await; + let php = uri_for(&dir, BUILDER_PATH); + assert_eq!( + unknown_elements(&backend, &php, BUILDER), + [ + "Unknown render element: 'acme_crad'", + "Unknown render element: 'element_test_probe'", + ] + ); + + // A test module's element counts while its file is open. + crate::common::open_php(&backend, &uri_for(&dir, PROBE_PATH), PROBE).await; + assert_eq!( + unknown_elements(&backend, &php, BUILDER), + ["Unknown render element: 'acme_crad'"] + ); + + // Test code is not judged. + let form_path = "web/core/modules/system/tests/modules/element_test/src/ProbeForm.php"; + let form = " 'element_test_missing'];\n}\n"; + let form_uri = uri_for(&dir, form_path); + crate::common::open_php(&backend, &form_uri, form).await; + assert!(unknown_elements(&backend, &form_uri, form).is_empty()); +} + +#[tokio::test] +async fn elements_are_not_read_outside_drupal() { + let backend = Backend::new_test(); + let uri = Uri::parse("file:///project/src/Builder.php").unwrap(); + crate::common::open_php(&backend, &uri, BUILDER).await; + let labels = labels_at(&backend, &uri, BUILDER, "'#type' => ''", 12).await; + assert!(!labels.contains(&"details".to_string()), "{labels:?}"); + assert!(unknown_elements(&backend, &uri, BUILDER).is_empty()); +} + +fn watched(path: &std::path::Path, kind: FileChangeType) -> DidChangeWatchedFilesParams { + DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Uri::from_file_path(path).unwrap(), + kind, + }], + } +} + +#[tokio::test] +async fn adding_editing_and_deleting_an_element_class_refreshes_the_names() { + let (backend, dir) = site(&[(BUILDER_PATH, BUILDER)]).await; + let php = uri_for(&dir, BUILDER_PATH); + let offered = || labels_at(&backend, &php, BUILDER, "'#type' => ''", 12); + let path = dir + .path() + .join("web/modules/custom/acme/src/Element/Fresh.php"); + let fresh = |id: &str| { + format!( + " Date: Fri, 6 Nov 2026 18:09:06 +0100 Subject: [PATCH 2/6] Drupal theme hooks Theme hook names in a render array's `#theme` and `#theme_wrappers` complete from the hooks the site's `hook_theme()` implementations declare, core's common elements included, and navigate to the hook's entry and to its templates: the default one and every theme's override, or for a suggestion the theme's template for it. Hover shows the declaring extension, the template, and the variables. The `#` keys of a render array whose `#theme` names a hook complete and navigate as the hook's variables. Templates are found in folders the site's `.gitignore` hides. Assisted-by: claude-code/claude-opus-5-5 --- benches/drupal_completion.rs | 31 +- examples/drupal/README.md | 10 + examples/drupal/assertions.php | 5 + .../src/Controller/GreetingController.php | 21 + .../acme-greeting--compact.html.twig | 11 + .../templates/acme-greeting.html.twig | 12 + src/classmap_scanner/discovery.rs | 5 + src/classmap_scanner/discovery_tests.rs | 28 + src/completion/drupal_theme_hooks.rs | 227 +++ src/completion/handler/string_arguments.rs | 11 + src/completion/laravel_string_keys/mod.rs | 12 +- src/completion/mod.rs | 1 + src/completion/symfony_string_keys.rs | 2 +- src/diagnostics/laravel_string_keys.rs | 7 +- src/drupal/hooks.rs | 29 + src/drupal/mod.rs | 21 + src/drupal/theme_hooks.rs | 1498 +++++++++++++++++ src/hover/laravel.rs | 11 + src/indexing/init.rs | 1 + src/indexing/watch.rs | 3 +- src/symbol_map/extraction/drupal.rs | 159 ++ .../extraction/expressions/assignment.rs | 1 + src/symbol_map/extraction/expressions/mod.rs | 2 + src/symbol_map/extraction/mod.rs | 4 + src/symbol_map/string_keys.rs | 14 +- src/symfony/mod.rs | 2 + src/virtual_members/laravel/string_keys.rs | 9 +- tests/integration/drupal_theme_hooks.rs | 571 +++++++ tests/integration/main.rs | 1 + 29 files changed, 2694 insertions(+), 15 deletions(-) create mode 100644 examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting--compact.html.twig create mode 100644 examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting.html.twig create mode 100644 src/completion/drupal_theme_hooks.rs create mode 100644 src/drupal/theme_hooks.rs create mode 100644 tests/integration/drupal_theme_hooks.rs diff --git a/benches/drupal_completion.rs b/benches/drupal_completion.rs index e3afd5998..c1e48c79c 100644 --- a/benches/drupal_completion.rs +++ b/benches/drupal_completion.rs @@ -9,9 +9,9 @@ use tower_lsp_server::gen_lsp_types::*; /// Custom modules in the generated site, each declaring `SERVICES` /// services, `ROUTES` routes, `ENTITY_TYPES` entity types, `PERMISSIONS` /// permissions, `CONFIGS` configuration objects, with their default -/// configuration and schema, and `ELEMENTS` render elements, and -/// documenting `HOOKS` hooks, and implementing the hooks the module before -/// it documents. +/// configuration and schema, `ELEMENTS` render elements, and `THEME_HOOKS` +/// theme hooks, documenting `HOOKS` hooks, and implementing the hooks the +/// module before it documents. const MODULES: usize = 30; const SERVICES: usize = 10; const ROUTES: usize = 10; @@ -20,6 +20,7 @@ const PERMISSIONS: usize = 10; const CONFIGS: usize = 10; const ELEMENTS: usize = 3; const HOOKS: usize = 10; +const THEME_HOOKS: usize = 5; const COMPOSER: &str = r#"{ "require": { "drupal/core-recommended": "^11" }, @@ -93,6 +94,7 @@ final class Consumer $this->account->hasPermission('administer mod1 '); \Drupal::config('mod1.'); $build = ['#type' => 'mod1_']; + $themed = ['#theme' => 'mod1_']; } } "#; @@ -110,8 +112,8 @@ fn rt() -> tokio::runtime::Runtime { } /// The files of a site with many custom modules: their services, routes, -/// entity types, render elements, documented hooks, and hook -/// implementations. +/// entity types, render elements, documented hooks, hook implementations, +/// and theme hooks. fn generate_site() -> Vec<(String, String)> { let mut files = Vec::new(); @@ -189,6 +191,18 @@ fn generate_site() -> Vec<(String, String)> { } files.push((format!("{dir}/mod{m}.api.php"), api)); + let mut theme = format!( + " ['variables' => ['label' => NULL, 'items' => []]]," + ); + } + theme.push_str(" ];\n }\n}\n"); + files.push((format!("{dir}/src/Hook/ThemeHooks.php"), theme)); + if m > 0 { let previous = m - 1; let mut module = String::from(" 'mod1_'", + "mod1_widget4", + ), ( "hook_implementation", &module, diff --git a/examples/drupal/README.md b/examples/drupal/README.md index cae853100..8d0b02d08 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -100,6 +100,16 @@ Run `composer install` here first. Composer puts core and contrib into definition of `'notify_email'`, which lands on its line in `acme.schema.yml`. Hover `$settings`, whose type carries the name it was read by. +- Theme hooks navigate between the render arrays that name them, their + `hook_theme()` entry, and their templates, core's included. In + `GreetingController::greet()`, go to the definition of `'acme_greeting'`, + which lists its key in `src/Hook/AcmeHooks.php`, the module's + `templates/acme-greeting.html.twig`, and `acme_theme`'s override, or hover + it to see its template and variables. Complete a `#theme` value, core's + `item_list` included, or a key after `'#'` to see the hook's variables, and + go to the definition of `'#name'`. In `compactGreet()`, + `'acme_greeting__compact'` is a suggestion: it leads to `acme_greeting` and + to `acme_theme`'s `acme-greeting--compact.html.twig`. - A module's service provider may build a service from a class of its own. In `acme_french_site_name()` in `web/modules/custom/acme/acme.module`, hover `$languageManager`: core declares `language_manager` as diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index b5177a78c..a8705906f 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -273,6 +273,11 @@ protected function getDefaultLanguageValues() check('acme_greeting declares the variables the controller passes', array_keys($themeHooks['acme_greeting']['variables'] ?? []) === ['name', 'greeting']); check('acme_greeting has the template its name implies', is_file("$module/templates/acme-greeting.html.twig")); check('acme_theme preprocesses acme_greeting', function_exists('acme_theme_preprocess_acme_greeting')); +check('acme_theme overrides the acme_greeting template', is_file("$theme/templates/acme-greeting.html.twig")); +check('GreetingController::greet() renders acme_greeting with its variables', str_contains((string) file_get_contents("$module/src/Controller/GreetingController.php"), "'#theme' => 'acme_greeting',\n '#name' => \$name,\n '#greeting' =>")); +check('GreetingController::compactGreet() renders the acme_greeting__compact suggestion', str_contains((string) file_get_contents("$module/src/Controller/GreetingController.php"), "'#theme' => 'acme_greeting__compact'") && str_starts_with('acme_greeting__compact', 'acme_greeting__') && !isset($themeHooks['acme_greeting__compact'])); +check('acme_theme draws the suggestion with acme-greeting--compact.html.twig', is_file("$theme/templates/acme-greeting--compact.html.twig")); +check("core's ThemeCommonElements declares item_list, drawn by system's item-list.html.twig", str_contains((string) file_get_contents("$web/core/lib/Drupal/Core/Theme/ThemeCommonElements.php"), " 'item_list' => [\n 'variables' => [\n 'items' => [],") && is_file("$web/core/modules/system/templates/item-list.html.twig")); // The module documents, invokes, and implements its own hook, and // implements core's form alter hooks. diff --git a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php index 76c05179c..d7197f9ab 100644 --- a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php +++ b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php @@ -28,6 +28,10 @@ public function greet(string $name): array { // Try: go to the definition of `$this->greeter->greet()`, or complete // after `$this->` to see the helpers ControllerBase inherits from core. + // Go to the definition of 'acme_greeting', which lists its key in + // AcmeHooks::theme(), its template, and acme_theme's override, or of + // '#name', which lands on the variable the hook declares. Complete a + // key after '#' to see the hook's variables. return [ '#theme' => 'acme_greeting', '#name' => $name, @@ -35,6 +39,23 @@ public function greet(string $name): array ]; } + /** + * The greeting in the compact form acme_theme draws it in. + * + * @return array + */ + public function compactGreet(string $name): array + { + // Try: hover 'acme_greeting__compact', a suggestion of acme_greeting, + // or go to its definition, which lists acme_greeting's key and + // template and acme_theme's acme-greeting--compact.html.twig. + return [ + '#theme' => 'acme_greeting__compact', + '#name' => $name, + '#greeting' => $this->greeter->greet($name), + ]; + } + /** * Sends the visitor to the greeting for the world. */ diff --git a/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting--compact.html.twig b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting--compact.html.twig new file mode 100644 index 000000000..4b5d717f7 --- /dev/null +++ b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting--compact.html.twig @@ -0,0 +1,11 @@ +{# +/** + * @file + * Acme Theme's template for the acme_greeting__compact suggestion. + * + * Available variables: + * - greeting: The full greeting text. + * - attributes: HTML attributes for the wrapper. + */ +#} +{{ greeting }} diff --git a/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting.html.twig b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting.html.twig new file mode 100644 index 000000000..37629706d --- /dev/null +++ b/examples/drupal/web/themes/custom/acme_theme/templates/acme-greeting.html.twig @@ -0,0 +1,12 @@ +{# +/** + * @file + * Acme Theme's override of the acme_greeting template. + * + * Available variables: + * - name: The name being greeted. + * - greeting: The full greeting text. + * - attributes: HTML attributes for the wrapper. + */ +#} +{{ greeting }}

diff --git a/src/classmap_scanner/discovery.rs b/src/classmap_scanner/discovery.rs index d838cc55d..3c61999f0 100644 --- a/src/classmap_scanner/discovery.rs +++ b/src/classmap_scanner/discovery.rs @@ -694,6 +694,8 @@ pub fn scan_drupal_directories( declaration_files.config_schemas.push(path.to_path_buf()); } else if crate::drupal::is_declaration_file(path, web_root) { declaration_files.declarations.push(path.to_path_buf()); + } else if crate::drupal::is_template_file(path, web_root) { + declaration_files.templates.push(path.to_path_buf()); } } } @@ -720,6 +722,9 @@ pub struct DrupalDeclarationFiles { pub default_config: Vec, /// The configuration schema files. pub config_schemas: Vec, + /// The Twig templates theme hooks are drawn with (see + /// `drupal::is_template_file`), recorded without being read. + pub templates: Vec, } /// The extensions besides `.php` that Drupal treats as PHP source. diff --git a/src/classmap_scanner/discovery_tests.rs b/src/classmap_scanner/discovery_tests.rs index 8e4d8c154..ecb5d7d5c 100644 --- a/src/classmap_scanner/discovery_tests.rs +++ b/src/classmap_scanner/discovery_tests.rs @@ -823,3 +823,31 @@ fn scan_drupal_directories_collects_declaration_files_outside_tests() { ] ); } + +#[test] +fn scan_drupal_directories_records_templates_outside_tests() { + let dir = tempfile::tempdir().unwrap(); + let web_root = dir.path(); + for path in [ + "core/modules/system/templates/item-list.html.twig", + "themes/contrib/olivo/templates/content/node.html.twig", + "core/modules/system/tests/themes/fixture/templates/item-list.html.twig", + "modules/custom/acme/templates/acme.txt.twig", + ] { + let full = web_root.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, "{{ x }}\n").unwrap(); + } + + let (_, files) = scan_drupal_directories(web_root, &IndexFilters::empty(), None); + let mut templates = files.templates; + templates.sort(); + assert_eq!( + templates, + vec![ + web_root.join("core/modules/system/templates/item-list.html.twig"), + web_root.join("themes/contrib/olivo/templates/content/node.html.twig"), + ] + ); + assert!(files.declarations.is_empty()); +} diff --git a/src/completion/drupal_theme_hooks.rs b/src/completion/drupal_theme_hooks.rs new file mode 100644 index 000000000..642dc3b43 --- /dev/null +++ b/src/completion/drupal_theme_hooks.rs @@ -0,0 +1,227 @@ +//! Completion for the theme hooks a Drupal render array names. +//! +//! `'#theme' => '|'`, `$build['#theme'] = '|'`, and the names of a `#theme` +//! or `#theme_wrappers` list are completed from the theme hooks the site +//! declares (see [`crate::drupal::theme_hooks`]). A key of an array whose +//! own `#theme` names a hook is completed from the hook's variables, as +//! `#name`. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::completion::source::code_context::CodeContext; +use crate::symbol_map::StringKeyKind; +use crate::text_position::{offset_to_position, position_to_offset}; + +/// The render array keys whose value names theme hooks. +const HOOK_KEYS: [&str; 2] = ["#theme", "#theme_wrappers"]; + +impl Backend { + /// Complete a theme hook name, or a variable of the hook a render + /// array's `#theme` names. `None` when the cursor is on neither. + pub(crate) fn try_drupal_theme_hook_completion( + &self, + content: &str, + position: Position, + code: &CodeContext<'_>, + ) -> Option { + let (quote, _) = code.open_string?; + let cursor = position_to_offset(content, position) as usize; + let prefix = content.get(quote + 1..cursor)?; + let in_list = matches!(code.last_code_byte(), Some(b'[' | b',')) + && code + .open_brackets + .last() + .is_some_and(|bracket| bracket.byte == b'['); + let names_hook = names_theme_hook(code.code_before) + || (in_list + && code + .open_brackets + .last() + .is_some_and(|bracket| names_theme_hook(&content[..bracket.code_before]))); + if names_hook { + return self.symfony_names_response( + StringKeyKind::DrupalThemeHook, + self.drupal_theme_hook_names(), + content, + quote + 1, + cursor, + position, + ); + } + if !in_list { + return None; + } + let open = code.open_brackets.last()?.offset; + let hook = array_theme_hook(content, open)?; + let edit_range = Range { + start: offset_to_position(content, quote + 1), + end: position, + }; + let items: Vec = self + .drupal_theme_hook_variable_names(&hook) + .into_iter() + .filter(|name| starts_with_ignoring_case(name, prefix)) + .enumerate() + .map(|(at, name)| CompletionItem { + label: name.clone(), + kind: Some(CompletionItemKind::Field), + detail: Some(format!("Theme hook `{hook}`")), + sort_text: Some(format!("{at:05}")), + filter_text: Some(name.clone()), + text_edit: Some(CompletionItemTextEdit::TextEdit(TextEdit { + range: edit_range, + new_text: name, + })), + ..Default::default() + }) + .collect(); + (!items.is_empty()).then_some(CompletionResponse::CompletionItemList(items)) + } +} + +fn starts_with_ignoring_case(name: &str, prefix: &str) -> bool { + name.as_bytes() + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix.as_bytes())) +} + +/// Whether the code `before` a value ends with `'#theme' =>`, or +/// `['#theme'] =`, or the same with `#theme_wrappers`. +fn names_theme_hook(before: &str) -> bool { + crate::drupal::elements::value_key(before).is_some_and(|key| HOOK_KEYS.contains(&key)) +} + +/// The hook the array literal opened at `open` names in a `'#theme' => +/// 'name'` entry of its own, before or after the cursor. +fn array_theme_hook(content: &str, open: usize) -> Option { + enum Token<'a> { + Literal(&'a str), + Arrow, + Other, + } + let bytes = content.as_bytes(); + let line_end = + |from: usize| memchr::memchr(b'\n', &bytes[from..]).map_or(bytes.len(), |end| from + end); + let mut depth = 0usize; + let mut tokens: Vec> = Vec::new(); + let mut at = open; + while at < bytes.len() { + match bytes[at] { + quote @ (b'\'' | b'"') => { + let mut end = at + 1; + while end < bytes.len() && bytes[end] != quote { + end += if bytes[end] == b'\\' { 2 } else { 1 }; + } + if depth == 1 { + tokens.push(Token::Literal(content.get(at + 1..end.min(bytes.len()))?)); + } + at = end + 1; + continue; + } + // A comment, which may hold a quote of its own. + b'/' if bytes.get(at + 1) == Some(&b'*') => { + at = memchr::memmem::find(&bytes[at + 2..], b"*/") + .map_or(bytes.len(), |end| at + 2 + end + 2); + continue; + } + b'/' if bytes.get(at + 1) == Some(&b'/') => { + at = line_end(at); + continue; + } + b'#' if bytes.get(at + 1) != Some(&b'[') => { + at = line_end(at); + continue; + } + b'[' | b'(' | b'{' => { + depth += 1; + if depth > 1 { + tokens.push(Token::Other); + } + } + b']' | b')' | b'}' => { + depth -= 1; + if depth == 0 { + break; + } + } + b'=' if depth == 1 && bytes.get(at + 1) == Some(&b'>') => { + tokens.push(Token::Arrow); + at += 2; + continue; + } + b';' if depth == 1 => break, + byte if depth == 1 && !byte.is_ascii_whitespace() => tokens.push(Token::Other), + _ => {} + } + at += 1; + } + tokens + .windows(4) + .find_map(|window| match window { + [ + Token::Literal("#theme"), + Token::Arrow, + Token::Literal(name), + next, + ] if !matches!(next, Token::Arrow) => Some(name.to_string()), + _ => None, + }) + .or_else(|| match tokens.as_slice() { + [ + .., + Token::Literal("#theme"), + Token::Arrow, + Token::Literal(name), + ] => Some(name.to_string()), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_value_after_a_theme_key_names_a_hook() { + assert!(names_theme_hook("['#theme' => ")); + assert!(names_theme_hook("[\"#theme_wrappers\"=>")); + assert!(names_theme_hook("$build['box']['#theme'] = ")); + assert!(!names_theme_hook("$build['#theme'] == ")); + assert!(!names_theme_hook("['#type' => ")); + } + + #[test] + fn the_arrays_own_theme_entry_names_its_hook() { + let php = "$x = ['#', '#theme' => 'acme_greeting', 'list' => ['#theme' => 'item_list']];"; + let open = php.find('[').unwrap(); + assert_eq!( + array_theme_hook(php, open).as_deref(), + Some("acme_greeting") + ); + let php = "$x = ['#name' => 1, '#theme' => 'acme_greeting'];"; + assert_eq!( + array_theme_hook(php, php.find('[').unwrap()).as_deref(), + Some("acme_greeting") + ); + let php = "$x = ['#theme' => ['a', 'b'], '#'];"; + assert_eq!(array_theme_hook(php, php.find('[').unwrap()), None); + } + + #[test] + fn a_quote_in_a_comment_does_not_hide_the_theme_entry() { + for php in [ + "$x = [\n // Don't cache.\n '#theme' => 'acme_greeting',\n '#na',\n];", + "$x = [\n # Don't cache.\n '#theme' => 'acme_greeting',\n '#na',\n];", + "$x = [\n /* Don't \"cache\". */\n '#theme' => 'acme_greeting',\n '#na',\n];", + "$x = [\n '#theme' => 'acme_greeting', // Don't cache.\n '#na',\n];", + "$x = [\n '#theme' => 'acme_greeting',\n #[Attribute] 'x' => fn() => 1,\n '#na',\n];", + ] { + assert_eq!( + array_theme_hook(php, php.find('[').unwrap()).as_deref(), + Some("acme_greeting"), + "{php}" + ); + } + } +} diff --git a/src/completion/handler/string_arguments.rs b/src/completion/handler/string_arguments.rs index 50cbb0a3f..f44d78f4a 100644 --- a/src/completion/handler/string_arguments.rs +++ b/src/completion/handler/string_arguments.rs @@ -149,6 +149,17 @@ impl Backend { return Some(response); } + // ── Drupal theme hooks ────────────────────────────────── + // `'#theme' => '|'`, a `#theme` or `#theme_wrappers` list, and the + // `#` keys of a render array whose `#theme` names a hook. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = self.try_drupal_theme_hook_completion(content, position, code) + { + return Some(response); + } + // ── Symfony form options ──────────────────────────────── // `$builder->add('email', EmailType::class, ['|' => …])`, wherever // the form component is used. diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index e2c8ad4b1..e6ae72ea6 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -54,15 +54,16 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { } StringKeyKind::MessengerBus => CompletionItemKind::Reference, StringKeyKind::SerializerGroup => CompletionItemKind::EnumMember, - StringKeyKind::YiiRouteArgument | StringKeyKind::DrupalRouteParameter => { - CompletionItemKind::Field - } + StringKeyKind::YiiRouteArgument + | StringKeyKind::DrupalRouteParameter + | StringKeyKind::DrupalThemeHookVariable => CompletionItemKind::Field, StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => CompletionItemKind::Function, StringKeyKind::TwigVariable => CompletionItemKind::Variable, StringKeyKind::Route | StringKeyKind::SymfonyRoute | StringKeyKind::YiiRoute | StringKeyKind::DrupalRoute + | StringKeyKind::DrupalThemeHook | StringKeyKind::Command | StringKeyKind::Section | StringKeyKind::Stack @@ -206,11 +207,13 @@ impl Backend { // Only the documentation, not the invocations the type engine // would have to confirm in every file that fires a hook. StringKeyKind::DrupalHook => self.drupal_documented_hooks().into(), + StringKeyKind::DrupalThemeHook => self.drupal_theme_hook_names().into(), // A translation key is completed in the domain it is looked up // in, a configuration key under the key it is nested in, which // only the Symfony completion knows, and a route argument from // the routes of the handler it is written in, and a Drupal route - // parameter from the route its call names. A hook name is + // parameter from the route its call names, and a theme hook's + // variable from the `#theme` of its render array. A hook name is // completed with its patterns as snippets, which only the // WordPress completion builds. StringKeyKind::SymfonyTranslation @@ -219,6 +222,7 @@ impl Backend { | StringKeyKind::YiiRouteArgument | StringKeyKind::DrupalRouteParameter | StringKeyKind::DrupalConfigKey + | StringKeyKind::DrupalThemeHookVariable | StringKeyKind::Section | StringKeyKind::Stack | StringKeyKind::TwigBlock diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 1e8287ec9..9315cc4f4 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -83,6 +83,7 @@ mod drupal_config; pub(crate) mod drupal_hooks; mod drupal_permissions; pub(crate) mod drupal_route_params; +mod drupal_theme_hooks; pub(crate) mod eloquent_model_attributes; pub(crate) mod eloquent_string; pub(crate) mod handler; diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index ce508aad1..8abbd36f7 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -518,7 +518,7 @@ impl Backend { /// The completion response offering those of `names` that start with /// what is typed between `prefix_start` and `cursor`. - fn symfony_names_response( + pub(crate) fn symfony_names_response( &self, kind: StringKeyKind, names: Vec, diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index 21d67e748..85dd465cb 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -102,7 +102,10 @@ impl CheckedStringKind { // nothing, which is no error either. A module can declare // permissions in PHP, through a permissions file's // `permission_callbacks`. Configuration is created at runtime - // too, by the configuration a site exports and by code. + // too, by the configuration a site exports and by code. Views + // and other modules declare theme hooks in loops, which are not + // read, and a render array may pass anything besides a hook's + // variables. StringKeyKind::WordPressHook | StringKeyKind::DrupalEntityType | StringKeyKind::DrupalRouteParameter @@ -110,6 +113,8 @@ impl CheckedStringKind { | StringKeyKind::DrupalPermission | StringKeyKind::DrupalConfigName | StringKeyKind::DrupalConfigKey + | StringKeyKind::DrupalThemeHook + | StringKeyKind::DrupalThemeHookVariable | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack diff --git a/src/drupal/hooks.rs b/src/drupal/hooks.rs index db79ac9e0..68c73c009 100644 --- a/src/drupal/hooks.rs +++ b/src/drupal/hooks.rs @@ -108,6 +108,19 @@ impl ExtensionIndex { .map(|(_, is_theme)| *is_theme) } + /// The directory of the extension `name`, when the walk found it. + pub(crate) fn dir_of(&self, name: &str) -> Option<&Path> { + self.by_dir + .iter() + .find(|(_, (known, _))| known == name) + .map(|(dir, _)| dir.as_path()) + } + + /// Whether the file at `path` belongs to a theme. + pub(crate) fn owner_is_theme(&self, path: &Path) -> bool { + self.owner(path).is_some_and(|(_, _, is_theme)| is_theme) + } + /// The extension a file belongs to: the one of its nearest ancestor /// directory holding an `.info.yml`, with that directory. fn owner<'a>(&'a self, path: &'a Path) -> Option<(&'a Path, &'a str, bool)> { @@ -591,6 +604,22 @@ impl Backend { found } + /// Every implementation of the hook `key` Drupal collects: its file, + /// where the hook is named there, and its extension. + pub(crate) fn drupal_hook_implementations(&self, key: &str) -> Vec<(String, u32, String)> { + self.hook_occurrences(key) + .into_iter() + .filter_map(|occurrence| match occurrence.role { + Occurrence::Implementation { + extension, + collected: true, + .. + } => Some((occurrence.uri, occurrence.start, extension.to_string())), + _ => None, + }) + .collect() + } + /// Whether the type engine confirms `site` of the file at `uri`, read /// in isolation from whatever pass is running, as the declarations of /// another file are. diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index fb4c8bc11..7d937ba8d 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -25,6 +25,7 @@ pub(crate) mod links; pub(crate) mod permissions; pub(crate) mod routing; pub(crate) mod service_providers; +pub(crate) mod theme_hooks; pub(crate) mod types; pub(crate) mod upcasting; @@ -50,6 +51,14 @@ pub(crate) struct DrupalState { extensions: RwLock, /// What the hook documentation says, until a declaring file changes. hook_index: RwLock>>, + /// The paths of the Twig templates of every web root, outside test + /// modules, which the workspace walk does not reach in core and + /// contributed extensions, by the hook name their file name spells + /// (see [`theme_hooks::template_key`]). + templates: RwLock>>, + /// The theme hooks the site declares, until a file they come from + /// changes. + theme_hook_index: RwLock>>, /// The services each service provider's `alter()` gives another class, /// keyed by the provider's URI; see [`service_providers`]. service_replacements: RwLock>>, @@ -258,6 +267,16 @@ pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool { && !is_unloaded(path, web_root) } +/// Whether `path` is a Twig template a theme hook may be drawn with: an +/// `*.html.twig` outside test modules. +pub(crate) fn is_template_file(path: &Path, web_root: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(theme_hooks::TEMPLATE_SUFFIX)) + && !is_test_fixture(path, web_root) + && !is_unloaded(path, web_root) +} + /// Whether `name` has the shape of the route Views builds for a display of /// a view: `view..`, neither ID holding a dot. pub(crate) fn is_views_route_name(name: &str) -> bool { @@ -321,6 +340,8 @@ impl Backend { self.drupal.web_roots.write().clear(); self.drupal.declaration_files.write().clear(); self.drupal.hook_php_files.write().clear(); + self.drupal.templates.write().clear(); + *self.drupal.theme_hook_index.write() = None; *self.drupal.extensions.write() = hooks::ExtensionIndex::default(); self.drupal.service_replacements.write().clear(); self.drupal.default_config_files.write().clear(); diff --git a/src/drupal/theme_hooks.rs b/src/drupal/theme_hooks.rs new file mode 100644 index 000000000..934c6b4d1 --- /dev/null +++ b/src/drupal/theme_hooks.rs @@ -0,0 +1,1498 @@ +//! Drupal's theme hooks: the names `hook_theme()` implementations declare, +//! their variables, and the Twig templates that draw them. +//! +//! Every key of the array an implementation of the hook `theme` returns +//! declares a theme hook (see [`crate::drupal::hooks`] for how the +//! implementations are found). The array is read when it is a literal, a +//! local variable assigned one or built key by key, or an `array_merge()` or +//! `+` of those; core's `ThemeCommonElements::commonElements()`, which +//! system's implementation merges in, is read as part of the implementation +//! that calls it, and so is `drupal_common_theme()`, which it merged in +//! before Drupal 11.1. Arrays built in a loop or by other calls are not +//! read. The +//! implementations are read the first time a theme hook is asked for. An +//! edit to a file that implements the hook reads that file again; a change +//! to core's common hooks, or a file changed on disk, reads them all. +//! +//! A hook's template is `