From 748183ab631625d7837e50907341028d270b2470 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Mon, 2 Nov 2026 12:31:42 +0100 Subject: [PATCH 01/18] Read Drupal route names from routing files and the calls that take one A route declared by a module's, core's, or a contributed extension's routing file completes, jumps to its declaration, and lists its references in Url::fromRoute(), new Url(), Link::createFromRoute(), a form state's setRedirect(), a controller's or form's redirect(), and the URL generator, access manager, and route provider methods that take a route name. Test modules' routing files count only while open. Assisted-by: claude-code/claude-opus-5-5 --- src/completion/laravel_string_keys/mod.rs | 4 +- src/completion/symfony_string_keys.rs | 25 ++- src/diagnostics/laravel_string_keys.rs | 1 + src/drupal/mod.rs | 83 +++++-- src/drupal/tests.rs | 16 ++ src/hover/laravel.rs | 5 +- src/resource_navigation.rs | 17 +- src/symbol_map/extraction/drupal.rs | 52 ++++- .../extraction/expressions/calls.rs | 7 + src/symbol_map/extraction/symfony.rs | 10 +- src/symbol_map/string_keys.rs | 42 +++- src/symfony/mod.rs | 4 +- src/symfony/routes.rs | 94 +++++++- src/virtual_members/laravel/string_keys.rs | 6 +- tests/integration/drupal_routes.rs | 209 ++++++++++++++++++ tests/integration/main.rs | 1 + 16 files changed, 520 insertions(+), 56 deletions(-) create mode 100644 tests/integration/drupal_routes.rs diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index ab5a86159..e40c4a2c5 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -52,6 +52,7 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { StringKeyKind::Route | StringKeyKind::SymfonyRoute | StringKeyKind::YiiRoute + | StringKeyKind::DrupalRoute | StringKeyKind::Command | StringKeyKind::Section | StringKeyKind::Stack @@ -183,7 +184,8 @@ impl Backend { | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute - | StringKeyKind::DrupalEntityType => self.symfony_declared_names(*kind).into(), + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute => self.symfony_declared_names(*kind).into(), StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => { self.twig_callable_names(*kind).into() } diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index 057b981d9..d5809a8a7 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -218,6 +218,9 @@ impl Backend { if call.arg_index == 0 && is_bus_name_stamp(&class) { return Some(StringKeyKind::MessengerBus); } + if call.arg_index == 0 && crate::drupal::is_url_class(&class) { + return Some(StringKeyKind::DrupalRoute); + } is_translatable_message(&class) } else { let function = resolve_function_name(ctx, written, offset); @@ -235,17 +238,21 @@ impl Backend { }; }; if call.is_static { - // `\Drupal::service('|')`, `\Drupal::hasService('|')`, and - // `\Drupal::entityQuery('|')`. - if call.arg_index != 0 || !self.is_drupal_project() { - return None; - } + // `\Drupal::service('|')`, `\Drupal::hasService('|')`, + // `\Drupal::entityQuery('|')`, `Url::fromRoute('|')`, and + // `Link::createFromRoute('', '|')`. let class = ctx.resolve_name_at(subject, cursor as u32); - if crate::drupal::is_service_lookup(&class, &call.method_name) { - return Some(StringKeyKind::ServiceId); + if call.arg_index == 0 && self.is_drupal_project() { + if crate::drupal::is_service_lookup(&class, &call.method_name) { + return Some(StringKeyKind::ServiceId); + } + if crate::drupal::is_entity_query(&class, &call.method_name) { + return Some(StringKeyKind::DrupalEntityType); + } } - return crate::drupal::is_entity_query(&class, &call.method_name) - .then_some(StringKeyKind::DrupalEntityType); + return (crate::drupal::static_route_argument(&class, &call.method_name) + == Some(call.arg_index)) + .then_some(StringKeyKind::DrupalRoute); } self.receiver_call_key( subject, diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index fd5c907da..9ba044b6c 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -95,6 +95,7 @@ impl CheckedStringKind { // an entity type in PHP, through `hook_entity_type_build()`. StringKeyKind::WordPressHook | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 2679106db..5f1dac38b 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -7,8 +7,8 @@ //! `sites/*/services.yml`. Those are read by the same scan as Symfony's //! configuration (see [`crate::symfony::resources`]), so service IDs and //! container parameters work alike in both. Nothing else Symfony declares -//! (routes, translations, events) is read here: Drupal declares those its -//! own way. +//! (translations, events) is read here, and routes are read from Drupal's +//! own routing files: Drupal declares those its own way. use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; @@ -32,13 +32,29 @@ pub(crate) struct DrupalState { declaration_files: RwLock>, } -/// Whether the file name at the end of `path` is one Drupal loads services -/// from: `services.yml`, or one ending in `.services.yml` other than -/// `default.services.yml`, the template a site's `services.yml` is copied -/// from. -fn is_services_file_name(path: &str) -> bool { +/// A kind of file Drupal reads declarations from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeclarationFile { + /// `services.yml`, or a file ending in `.services.yml` other than + /// `default.services.yml`, the template a site's `services.yml` is + /// copied from. + Services, + /// `.routing.yml`. + Routing, +} + +/// The kind of declaration file the file name at the end of `path` is, if +/// any. +fn declaration_file_kind(path: &str) -> Option { let name = path.rsplit('/').next().unwrap_or(path); - name == "services.yml" || (name.ends_with(".services.yml") && name != "default.services.yml") + if name == "services.yml" || (name.ends_with(".services.yml") && name != "default.services.yml") + { + Some(DeclarationFile::Services) + } else if name.ends_with(".routing.yml") { + Some(DeclarationFile::Routing) + } else { + None + } } /// The folders of a web root whose YAML Drupal never loads as it stands: @@ -81,11 +97,36 @@ pub(crate) fn is_entity_query(class: &str, method: &str) -> bool { || method.eq_ignore_ascii_case("entityQueryAggregate")) } +/// The argument a static call of `method` on `class`, a resolved class +/// name, takes a route name in: the first of `Url::fromRoute()`, the second +/// of `Link::createFromRoute()`. +pub(crate) fn static_route_argument(class: &str, method: &str) -> Option { + let class = class.strip_prefix('\\').unwrap_or(class); + if class.eq_ignore_ascii_case("Drupal\\Core\\Url") && method.eq_ignore_ascii_case("fromRoute") { + Some(0) + } else if class.eq_ignore_ascii_case("Drupal\\Core\\Link") + && method.eq_ignore_ascii_case("createFromRoute") + { + Some(1) + } else { + None + } +} + +/// Whether `class`, a resolved class name, is `Drupal\Core\Url`, whose +/// constructor takes a route name first. +pub(crate) fn is_url_class(class: &str) -> bool { + class + .strip_prefix('\\') + .unwrap_or(class) + .eq_ignore_ascii_case("Drupal\\Core\\Url") +} + /// Whether `path` is a file Drupal reads declarations from. pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) - .is_some_and(is_services_file_name) + .is_some_and(|name| declaration_file_kind(name).is_some()) && !is_test_fixture(path, web_root) && !is_unloaded(path, web_root) } @@ -224,23 +265,25 @@ impl Backend { /// Other YAML files in a Drupal project have a `services:` key with /// another meaning, or none at all. pub(crate) fn is_drupal_services_uri(&self, uri: &str) -> bool { - if !self.is_drupal_project() || !is_services_file_name(uri) { - return false; + self.drupal_declaration_file(uri) == Some(DeclarationFile::Services) + } + + /// The kind of declaration file `uri` is in a Drupal project: one + /// inside one of its web roots, outside test modules unless it is open. + pub(crate) fn drupal_declaration_file(&self, uri: &str) -> Option { + if !self.is_drupal_project() { + return None; } - let Some(path) = crate::util::uri_to_path(uri) else { - return false; - }; - let Some((in_fixture, unloaded)) = self + let kind = declaration_file_kind(uri)?; + let path = crate::util::uri_to_path(uri)?; + let (in_fixture, unloaded) = self .drupal .web_roots .read() .iter() .find(|root| path.starts_with(root)) - .map(|root| (is_test_fixture(&path, root), is_unloaded(&path, root))) - else { - return false; - }; - !unloaded && (!in_fixture || self.open_files.read().contains_key(uri)) + .map(|root| (is_test_fixture(&path, root), is_unloaded(&path, root)))?; + (!unloaded && (!in_fixture || self.open_files.read().contains_key(uri))).then_some(kind) } } diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index 937a12e91..72dd92fc4 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -67,6 +67,22 @@ fn services_files_in_the_web_root_declare_services_and_parameters() { assert_eq!(classes, vec!["Drupal\\acme\\Greeter".to_string()]); } +#[test] +fn a_routing_file_under_a_config_directory_is_drupals_in_a_symfony_site() { + // Core's `config` module puts a `config` segment on the way to its + // routing file, which a Symfony site reads as container configuration. + let (backend, dir) = drupal_backend(&[]); + backend.set_is_symfony(true); + backend.update_resource_symbol_index( + &uri(dir.path(), "web/core/modules/config/config.routing.yml"), + "config.sync:\n path: '/admin/config/development/configuration'\n", + ); + assert_eq!( + backend.symfony_declared_names(StringKeyKind::DrupalRoute), + vec!["config.sync".to_string()] + ); +} + #[test] fn other_yaml_is_not_read_as_services() { let (backend, _dir) = drupal_backend(&[ diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index ad3e89e4d..b5f44f21d 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -291,12 +291,13 @@ impl Backend { | StringKeyKind::SymfonyRoute | StringKeyKind::SymfonyTranslation | StringKeyKind::TranslationDomain - | StringKeyKind::DrupalEntityType => { + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute => { let label = match kind { StringKeyKind::ServiceId => "Service", StringKeyKind::DrupalEntityType => "Entity type", StringKeyKind::ContainerParameter => "Parameter", - StringKeyKind::SymfonyRoute => "Route", + StringKeyKind::SymfonyRoute | StringKeyKind::DrupalRoute => "Route", StringKeyKind::SymfonyTranslation => "Translation", _ => "Translation domain", }; diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index 859d6fbcc..f4436d762 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -278,11 +278,18 @@ impl Backend { // A Drupal declaration file is Drupal's, also in a Drupal site that // requires Symfony's container and has a `config` directory on the // way to it, as core's `config` module does. - if self.is_drupal_services_uri(uri) { - let (container_spans, values) = - crate::symfony::resources::container_declarations(uri, content); - spans = container_spans; - container_values = values; + if let Some(file) = self.drupal_declaration_file(uri) { + match file { + crate::drupal::DeclarationFile::Services => { + let (container_spans, values) = + crate::symfony::resources::container_declarations(uri, content); + spans = container_spans; + container_values = values; + } + crate::drupal::DeclarationFile::Routing => { + spans = crate::symfony::routes::drupal_routing_spans(content); + } + } } else if is_symfony && crate::symfony::resources::is_container_config_uri(uri) { declares_twig_loader = crate::symfony::tags::tags_twig_loader(content); // A bus is declared by a key under `config/packages/`, which is diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index 16ee49651..6251aa02f 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -1,5 +1,6 @@ //! The entity types a Drupal entity class declares, by attribute or by the -//! annotation older code uses (see [`crate::drupal`]). +//! annotation older code uses, and the route names Drupal's static and +//! constructor calls read (see [`crate::drupal`]). use super::*; use crate::symbol_map::{EntityTypeSite, StringKeyKind}; @@ -100,6 +101,55 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( } } +/// Record the route name `new Url('…')` reads. +pub(in crate::symbol_map::extraction) fn emit_drupal_url_spans( + class: &Expression<'_>, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Expression::Identifier(identifier) = class else { + return; + }; + let resolved = ctx + .resolved_name_at(identifier.span().start.offset) + .unwrap_or_else(|| bytes_to_str(identifier.value())); + if crate::drupal::is_url_class(resolved) { + emit_drupal_route_span(0, argument_list, ctx); + } +} + +/// Record the route name a call reads in its positional argument `index`, +/// or in the `route_name:` argument that names it. +pub(in crate::symbol_map::extraction) fn emit_drupal_route_span( + index: usize, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + let route = + argument_list + .arguments + .iter() + .enumerate() + .find_map(|(at, argument)| match argument { + Argument::Positional(positional) if at == index => Some(positional.value), + Argument::Named(named) if named.name.value == b"route_name" => Some(named.value), + _ => None, + }); + if let Some(route) = route { + push_laravel_string_span( + StringKeyKind::DrupalRoute, + false, + false, + route, + ctx.content, + &mut ctx.spans, + ); + } +} + /// The class a `handlers` array gives the handler `name`, as a class /// constant or a string. fn handler_class(handlers: &Expression<'_>, name: &str, ctx: &ExtractionCtx<'_>) -> Option { diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index 6d6ac6783..766671950 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -44,6 +44,7 @@ pub(super) fn extract_instantiation_expr<'a>( } emit_symfony_instantiation_spans(inst.class, args, ctx); emit_twig_callable_instantiation(inst.class, args, ctx); + emit_drupal_url_spans(inst.class, args, ctx); if !class_text.is_empty() { emit_call_site( format!("new {}", class_text), @@ -615,6 +616,12 @@ fn extract_static_method_call<'a>( &mut ctx.spans, ); } + if ctx.drupal + && let Some(index) = + crate::drupal::static_route_argument(semantic_subject, &member_name) + { + emit_drupal_route_span(index, &static_call.argument_list, ctx); + } } extract_from_arguments(&static_call.argument_list.arguments, ctx, scope_start); } diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index 469a9430e..b2fb440e0 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -75,7 +75,7 @@ pub(crate) fn symfony_receiver_method( // turned away on a single byte. if !matches!( member_name.as_bytes().first().map(u8::to_ascii_lowercase), - Some(b'g' | b'h' | b's' | b'a' | b'd' | b'r' | b't' | b'p' | b'f') + Some(b'g' | b'h' | b's' | b'a' | b'd' | b'r' | b't' | b'p' | b'f' | b'c') ) { return None; } @@ -102,7 +102,13 @@ pub(crate) fn symfony_receiver_method( 0 if is("generateUrl") || is("redirectToRoute") => { Some((ReceiverClass::ControllerRouting, false)) } - 0 if is("generate") || is("generateAbsolute") => Some((ReceiverClass::UrlGenerator, false)), + 0 if is("generate") || is("generateAbsolute") || is("generateFromRoute") => { + Some((ReceiverClass::UrlGenerator, false)) + } + 0 if is("setRedirect") => Some((ReceiverClass::FormRedirect, false)), + 0 if is("redirect") => Some((ReceiverClass::RouteRedirect, false)), + 0 if is("checkNamedRoute") => Some((ReceiverClass::AccessCheck, false)), + 0 if is("getRouteByName") => Some((ReceiverClass::RouteLookup, false)), 3 if is("generateFromCurrent") => Some((ReceiverClass::UrlGenerator, false)), 0 if is("add") => Some((ReceiverClass::RoutingConfigurator, true)), 0 if is("getParameter") || is("hasParameter") => { diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index c990ffa45..e7eca05ea 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -160,6 +160,12 @@ pub(crate) enum StringKeyKind { /// the entity type manager's `getStorage()` and the other methods that /// take one. DrupalEntityType, + /// A Drupal route name: declared by a top-level key of a routing file, + /// and read by `Url::fromRoute()`, `Link::createFromRoute()`, a form + /// state's `setRedirect()`, a controller's or form's `redirect()`, and + /// the URL generator, access manager, and route provider methods that + /// take one. + DrupalRoute, } impl StringKeyKind { @@ -206,7 +212,7 @@ impl StringKeyKind { | Self::TwigVariable => KeyFramework::Twig, Self::YiiView | Self::YiiRoute | Self::YiiRouteArgument => KeyFramework::Yii, Self::WordPressHook => KeyFramework::WordPress, - Self::DrupalEntityType => KeyFramework::Drupal, + Self::DrupalEntityType | Self::DrupalRoute => KeyFramework::Drupal, } } @@ -728,6 +734,13 @@ const YII_LEGACY_VIEW_RENDERER: &str = "Yiisoft\\Yii\\View\\ViewRenderer"; const YII_VIEW: &str = "Yiisoft\\View\\ViewInterface"; pub(crate) const DRUPAL_ENTITY_TYPE_MANAGER: &str = "Drupal\\Core\\Entity\\EntityTypeManagerInterface"; +const DRUPAL_URL_GENERATOR: &str = "Drupal\\Core\\Routing\\UrlGeneratorInterface"; +const DRUPAL_FORM_STATE: &str = "Drupal\\Core\\Form\\FormStateInterface"; +const DRUPAL_CONTROLLER_BASE: &str = "Drupal\\Core\\Controller\\ControllerBase"; +const DRUPAL_FORM_BASE: &str = "Drupal\\Core\\Form\\FormBase"; +const DRUPAL_ENTITY_CONTROLLER: &str = "Drupal\\Core\\Entity\\Controller\\EntityController"; +const DRUPAL_ACCESS_MANAGER: &str = "Drupal\\Core\\Access\\AccessManagerInterface"; +const DRUPAL_ROUTE_PROVIDER: &str = "Drupal\\Core\\Routing\\RouteProviderInterface"; /// The namespace of the PHP configurators that build Symfony's service /// container. @@ -784,8 +797,9 @@ pub(crate) enum ReceiverClass { /// the shortcuts a Symfony controller inherits. ControllerRouting, /// A URL generator, the router included, whose `generate()` takes a - /// route name: Symfony's, and Yii's, whose `generateAbsolute()` takes - /// one too and whose `generateFromCurrent()` takes a fallback. + /// route name: Symfony's, Drupal's, whose `generateFromRoute()` takes + /// one too, and Yii's, whose `generateAbsolute()` takes one too and + /// whose `generateFromCurrent()` takes a fallback. UrlGenerator, /// A PHP routing configurator, a collection of one, or the route one /// of them just added, whose `add()` declares a route. @@ -822,6 +836,17 @@ pub(crate) enum ReceiverClass { /// both Symfony's container builder and Drupal's entity type manager /// have: the one takes a service ID, the other an entity type ID. DefinitionLookup, + /// A Drupal form state, whose `setRedirect()` takes a route name. + FormRedirect, + /// What has Drupal's `redirect()` shortcut, which takes a route name: a + /// controller or a form. + RouteRedirect, + /// Drupal's access manager, whose `checkNamedRoute()` takes a route + /// name. + AccessCheck, + /// Drupal's route provider, whose `getRouteByName()` takes a route + /// name. + RouteLookup, } impl ReceiverClass { @@ -870,7 +895,10 @@ impl ReceiverClass { Self::ConfiguratorArgument => &[(SYMFONY_ABSTRACT_CONFIGURATOR, ContainerParameter)], Self::ContainerBuilder => &[(SYMFONY_CONTAINER_BUILDER, ServiceId)], Self::ControllerRouting => &[(SYMFONY_ABSTRACT_CONTROLLER, SymfonyRoute)], + // Drupal's generator extends Symfony's, so it has to be asked + // about first. Self::UrlGenerator => &[ + (DRUPAL_URL_GENERATOR, StringKeyKind::DrupalRoute), (SYMFONY_URL_GENERATOR, SymfonyRoute), (YII_URL_GENERATOR, YiiRoute), ], @@ -907,6 +935,14 @@ impl ReceiverClass { (SYMFONY_CONTAINER_BUILDER, ServiceId), (DRUPAL_ENTITY_TYPE_MANAGER, StringKeyKind::DrupalEntityType), ], + Self::FormRedirect => &[(DRUPAL_FORM_STATE, StringKeyKind::DrupalRoute)], + Self::RouteRedirect => &[ + (DRUPAL_CONTROLLER_BASE, StringKeyKind::DrupalRoute), + (DRUPAL_FORM_BASE, StringKeyKind::DrupalRoute), + (DRUPAL_ENTITY_CONTROLLER, StringKeyKind::DrupalRoute), + ], + Self::AccessCheck => &[(DRUPAL_ACCESS_MANAGER, StringKeyKind::DrupalRoute)], + Self::RouteLookup => &[(DRUPAL_ROUTE_PROVIDER, StringKeyKind::DrupalRoute)], } } diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 380e78c00..9419576c0 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -125,8 +125,8 @@ pub(crate) struct SymfonyState { /// Whether `map` declares a service ID, a container parameter, a route, a /// translation key, a template, a Twig filter or function, a configuration -/// key, an event listener, a Messenger bus, or a Drupal entity type, -/// outright or through a configurator call still to be +/// key, an event listener, a Messenger bus, a Drupal entity type, or a +/// Drupal route, outright or through a configurator call still to be /// confirmed, or registers a Messenger handler, or imports routes, or /// declares a Twig loader. /// diff --git a/src/symfony/routes.rs b/src/symfony/routes.rs index df2f3c09a..71eb45470 100644 --- a/src/symfony/routes.rs +++ b/src/symfony/routes.rs @@ -56,7 +56,9 @@ pub(crate) fn routing_declarations( let mut spans = Vec::new(); let mut imports = Vec::new(); match super::config_format(uri) { - Some(super::ConfigFormat::Yaml) => scan_yaml(content, &mut spans, &mut imports), + Some(super::ConfigFormat::Yaml) => { + scan_yaml(content, SYMFONY_ROUTES, &mut spans, &mut imports); + } Some(super::ConfigFormat::Xml) => scan_xml(content, &mut spans, &mut imports), None => {} } @@ -64,12 +66,48 @@ pub(crate) fn routing_declarations( (spans, imports) } +/// The route-name spans of a Drupal routing file, sorted by offset. +pub(crate) fn drupal_routing_spans(content: &str) -> Vec { + let mut spans = Vec::new(); + let mut imports = Vec::new(); + scan_yaml(content, DRUPAL_ROUTES, &mut spans, &mut imports); + spans.sort_by_key(|span| (span.start, span.end)); + spans +} + +/// The kind of key a routing file declares, what a name of that kind may +/// look like, and whether the file can import other routes. +#[derive(Clone, Copy)] +struct RouteNaming { + kind: StringKeyKind, + is_name: fn(&str) -> bool, + /// Whether a `resource:` imports routes: true for Symfony's routing + /// files, false for Drupal's, which have no imports. + reads_imports: bool, +} + +const SYMFONY_ROUTES: RouteNaming = RouteNaming { + kind: StringKeyKind::SymfonyRoute, + is_name: is_route_name, + reads_imports: true, +}; + +const DRUPAL_ROUTES: RouteNaming = RouteNaming { + kind: StringKeyKind::DrupalRoute, + is_name: is_drupal_route_name, + reads_imports: false, +}; + fn route_span(key: &str, start: usize, is_write: bool) -> SymbolSpan { + route_span_of(StringKeyKind::SymfonyRoute, key, start, is_write) +} + +fn route_span_of(kind: StringKeyKind, key: &str, start: usize, is_write: bool) -> SymbolSpan { SymbolSpan { start: start as u32, end: (start + key.len()) as u32, kind: SymbolKind::StringKey { - kind: StringKeyKind::SymfonyRoute, + kind, key: key.to_string(), is_write, is_optional: false, @@ -86,6 +124,17 @@ fn is_route_name(name: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) } +/// Whether `name` can be a Drupal route name. Drupal's own routes include +/// `` and ``, and a route attribute's alias is a class name +/// (`Drupal\acme\Controller\Foo::bar`), so only what YAML or PHP could not +/// hold in a plain name is ruled out. +pub(crate) fn is_drupal_route_name(name: &str) -> bool { + !name.is_empty() + && !name + .bytes() + .any(|byte| byte.is_ascii_whitespace() || matches!(byte, b'\'' | b'"' | b'{' | b'}')) +} + // ─── YAML ─────────────────────────────────────────────────────────────────── /// One `key: value` line of a routing YAML file. @@ -128,7 +177,12 @@ fn yaml_lines(content: &str) -> Vec> { /// The route declarations of a routing YAML file: every top-level key, and /// every key under a top-level `when@env:`, whose mapping has a `path:` or /// an `alias:` rather than a `resource:`, which imports other routes. -fn scan_yaml(content: &str, spans: &mut Vec, imports: &mut Vec) { +fn scan_yaml( + content: &str, + naming: RouteNaming, + spans: &mut Vec, + imports: &mut Vec, +) { let lines = yaml_lines(content); // The indentation of the entries of the `when@env:` section the scan is // in, from its first one. @@ -148,7 +202,7 @@ fn scan_yaml(content: &str, spans: &mut Vec, imports: &mut Vec &str { fn scan_yaml_route( entry: &YamlLine<'_>, rest: &[YamlLine<'_>], + naming: RouteNaming, spans: &mut Vec, imports: &mut Vec, ) { - if !is_route_name(entry.key) { + if !(naming.is_name)(entry.key) { return; } let mut is_route = false; @@ -226,7 +281,7 @@ fn scan_yaml_route( } } - if let Some(mut import) = import { + if let Some(mut import) = import.filter(|_| naming.reads_imports) { import.loader = loader .filter(|loader| !loader.is_empty()) .map(str::to_string); @@ -239,14 +294,14 @@ fn scan_yaml_route( let trimmed = value.trim_start(); let leading = value.len() - trimmed.len(); let (name, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); - is_route_name(name).then(|| (name, value_start + leading + quote)) + (naming.is_name)(name).then(|| (name, value_start + leading + quote)) }); if !(is_route || alias.is_some()) { return; } - spans.push(route_span(entry.key, entry.key_start, true)); + spans.push(route_span_of(naming.kind, entry.key, entry.key_start, true)); if let Some((name, start)) = alias { - spans.push(route_span(name, start, false)); + spans.push(route_span_of(naming.kind, name, start, false)); } } @@ -436,6 +491,27 @@ mod tests { ); } + #[test] + fn a_drupal_entry_with_a_resource_and_a_path_is_a_route() { + let yaml = "acme.export:\n path: '/acme/export'\n resource: 'not an import'\n"; + assert_eq!( + routes(&drupal_routing_spans(yaml), yaml), + vec![("acme.export".to_string(), true)] + ); + } + + #[test] + fn drupal_routing_files_declare_their_keys_with_a_path() { + let yaml = "'':\n path: '/'\nacme.greeting:\n path: '/acme/greeting/{name}'\n defaults:\n _controller: '\\Drupal\\acme\\Controller\\GreetingController::greet'\n\nroute_callbacks:\n - '\\Drupal\\acme\\Routing\\Routes::routes'\n"; + assert_eq!( + routes(&drupal_routing_spans(yaml), yaml), + vec![ + ("".to_string(), true), + ("acme.greeting".to_string(), true), + ] + ); + } + #[test] fn xml_declares_routes_and_reads_aliases() { let xml = r#" diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index d914dd22c..0f89daa0d 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -78,7 +78,8 @@ pub(crate) fn resolve_laravel_string_key( | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute - | StringKeyKind::DrupalEntityType => backend.symfony_declaration_locations(*kind, key), + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute => backend.symfony_declaration_locations(*kind, 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), @@ -314,7 +315,8 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::SymfonyEvent | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute - | StringKeyKind::DrupalEntityType => { + | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRoute => { return find_string_key_usages(kind, key, backend, snapshot, include_declaration); } // The catalogues named after a domain declare it, and the paths of diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs new file mode 100644 index 000000000..e1a77f34f --- /dev/null +++ b/tests/integration/drupal_routes.rs @@ -0,0 +1,209 @@ +//! Drupal's route names: the keys a routing file declares, read by the +//! calls that build a URL or a redirect to a route. + +use crate::common::{completion_response, hover_text_at, response_labels}; +use crate::drupal_services::{ + definitions_at, position_in, references_at, uri_for, workspace, workspace_in, +}; + +/// Just enough of core's routing API, where Composer would put core. +const ROUTING_STUBS: &[(&str, &str)] = &[ + ( + "web/core/lib/Drupal/Core/Url.php", + "setRedirect('acme.greeting'); + $this->redirect('acme.greeting'); + $this->urls->generateFromRoute('acme.greeting'); + $this->access->checkNamedRoute('acme.greeting'); + $this->routes->getRouteByName('acme.greeting'); + $other->setRedirect('acme.greeting'); + } +} + +final class Redirect { + public function setRedirect(string $path): void {} +} +"#; + +const CONSUMER_PATH: &str = "web/modules/custom/acme/src/Controller/GreetingController.php"; + +fn files<'a>(extra: &[(&'a str, &'a str)]) -> Vec<(&'a str, &'a str)> { + ROUTING_STUBS + .iter() + .copied() + .chain(extra.iter().copied()) + .collect() +} + +#[tokio::test] +async fn a_route_name_goes_to_its_routing_file_and_lists_its_readers() { + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (CONSUMER_PATH, CONSUMER), + ])) + .await; + let php = uri_for(&dir, CONSUMER_PATH); + let yaml = uri_for(&dir, ROUTING_PATH); + + let found = definitions_at( + &backend, + &php, + position_in(CONSUMER, "fromRoute('acme.greeting')", 12), + ) + .await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!((&found[0].uri, found[0].range.start.line), (&yaml, 0)); + + let found = references_at(&backend, &yaml, position_in(ROUTING, "acme.greeting", 2)).await; + // Every reader but the redirect entity's own `setRedirect()`. + let mut expected: Vec<_> = (19..=26).map(|line| (php.clone(), line)).collect(); + expected.push((yaml, 0)); + expected.sort(); + assert_eq!(found, expected); + + let position = position_in(CONSUMER, "getRouteByName('acme.greeting')", 18); + let hover = hover_text_at(&backend, &php, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("Route"), "{hover}"); +} + +/// Core sits in a folder the project's `.gitignore` leaves out, and its +/// test modules declare routes no real site has. +#[tokio::test] +async fn core_routes_complete_and_test_modules_do_not() { + let consumer = CONSUMER + .replace("Url::fromRoute('acme.greeting')", "Url::fromRoute('')") + .replace("'Hello', 'acme.greeting'", "'Hello', ''") + .replace("setRedirect('acme.greeting')", "setRedirect('')"); + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ( + "web/core/modules/system/system.routing.yml", + "'':\n path: '/'\nsystem.admin_content:\n path: '/admin/content'\n", + ), + ( + "web/core/modules/system/tests/modules/router_test/router_test.routing.yml", + "router_test.1:\n path: '/router_test/test1'\n", + ), + ] { + 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(&[(ROUTING_PATH, ROUTING), (CONSUMER_PATH, consumer.as_str())]), + ) + .await; + let php = uri_for(&dir, CONSUMER_PATH); + // Finding references indexes the workspace, core's routing files + // included. + references_at( + &backend, + &php, + position_in(&consumer, "new Url('acme.greeting')", 10), + ) + .await; + + for (needle, inside) in [ + ("fromRoute('')", 11), + ("'Hello', ''", 10), + ("$form_state->setRedirect('')", 26), + ] { + let position = position_in(&consumer, needle, inside); + let labels = response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ); + for declared in ["acme.greeting", "", "system.admin_content"] { + assert!( + labels.contains(&declared.to_string()), + "{needle}: {labels:?}" + ); + } + assert!(!labels.contains(&"router_test.1".to_string()), "{labels:?}"); + assert!( + !labels.contains(&"route_callbacks".to_string()), + "{labels:?}" + ); + } +} + +/// `route_name:` names a route like the positional argument does. +#[tokio::test] +async fn a_named_route_argument_is_read() { + let consumer = r#" Date: Mon, 2 Nov 2026 12:35:18 +0100 Subject: [PATCH 02/18] Read the route names Drupal's links files place links by The route_name and base_route of a menu, task, action, or contextual link, and the routes an action appears_on, navigate to the route, show up in its references, and complete from the declared routes. A parent link or task is not read as a route. Assisted-by: claude-code/claude-opus-5-5 --- src/completion/symfony_string_keys.rs | 11 +- src/drupal/links.rs | 161 ++++++++++++++++++++++++++ src/drupal/mod.rs | 24 ++-- src/resource_navigation.rs | 3 + src/symfony/resources.rs | 2 +- tests/integration/drupal_routes.rs | 80 +++++++++++++ 6 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 src/drupal/links.rs diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index d5809a8a7..e3cbb75ba 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -424,8 +424,15 @@ impl Backend { return (!items.is_empty()) .then_some(CompletionResponse::CompletionItemList(items)); } - } else if self.is_drupal_services_uri(uri) { - resource_key_at(content, cursor)? + } else if let Some(file) = self.drupal_declaration_file(uri) { + match file { + crate::drupal::DeclarationFile::Services => resource_key_at(content, cursor)?, + crate::drupal::DeclarationFile::Links => ( + StringKeyKind::DrupalRoute, + crate::drupal::links::links_route_name_start(content, cursor)?, + ), + crate::drupal::DeclarationFile::Routing => return None, + } } else if !is_symfony { return None; } else if crate::symfony::resources::is_container_config_uri(uri) { diff --git a/src/drupal/links.rs b/src/drupal/links.rs new file mode 100644 index 000000000..309448a57 --- /dev/null +++ b/src/drupal/links.rs @@ -0,0 +1,161 @@ +//! The route names a Drupal links file reads. +//! +//! A menu, task, action, or contextual links file declares one link per +//! top-level key, and places it by route: the link's `route_name`, the +//! `base_route` a task is grouped under, and the routes an action +//! `appears_on`. A `parent` or `parent_id` names another link, not a +//! route. + +use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; +use crate::symfony::routes::is_drupal_route_name; + +/// The keys of a link whose value is a route name. +fn names_route(key: &str) -> bool { + matches!(key, "route_name" | "base_route") +} + +/// The route-name spans of a links file, sorted by offset. +pub(crate) fn links_route_spans(content: &str) -> Vec { + let mut spans = Vec::new(); + // The indentation of the `appears_on:` key whose list the scan is in. + let mut appears_on: Option = None; + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + if semantic.trim().is_empty() { + continue; + } + let indent = crate::framework::leading_spaces(semantic); + let text = &semantic[indent..]; + if let Some(list) = appears_on { + if indent > list + && let Some(item) = text.strip_prefix('-') + { + let item_start = line_start + indent + 1; + push_route(item, item_start, &mut spans); + continue; + } + appears_on = None; + } + if indent == 0 { + continue; + } + let Some(colon) = text.find(':') else { + continue; + }; + let key = text[..colon].trim_end(); + let value = &text[colon + 1..]; + let value_start = line_start + indent + colon + 1; + if names_route(key) { + push_route(value, value_start, &mut spans); + } else if key == "appears_on" { + match value.trim().strip_prefix('[') { + Some(_) => push_flow_routes(value, value_start, &mut spans), + None => appears_on = Some(indent), + } + } + } + spans +} + +/// Push the route `raw`, a YAML scalar starting at `start`, names. +fn push_route(raw: &str, start: usize, spans: &mut Vec) { + let trimmed = raw.trim_start(); + let leading = raw.len() - trimmed.len(); + let (name, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); + if !is_drupal_route_name(name) { + return; + } + let name_start = start + leading + quote; + spans.push(SymbolSpan { + start: name_start as u32, + end: (name_start + name.len()) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key: name.to_string(), + is_write: false, + is_optional: false, + }, + }); +} + +/// Push each route of an `[a, 'b']` flow sequence starting at `start`. +fn push_flow_routes(raw: &str, start: usize, spans: &mut Vec) { + let Some(open) = raw.find('[') else { + return; + }; + let close = raw.rfind(']').unwrap_or(raw.len()); + let mut item_start = open + 1; + for item in raw[open + 1..close.max(open + 1)].split(',') { + push_route(item, start + item_start, spans); + item_start += item.len() + 1; + } +} + +/// Where the route name the cursor is typing in a links file starts: the +/// value of a `route_name:` or `base_route:`, or an item of an +/// `appears_on:` list. +pub(crate) fn links_route_name_start(content: &str, cursor: usize) -> Option { + let before = content.get(..cursor)?; + let line_start = before.rfind('\n').map_or(0, |index| index + 1); + let line = &before[line_start..]; + let name_start = line + .bytes() + .rposition(|byte| byte.is_ascii_whitespace() || matches!(byte, b'\'' | b'"')) + .map_or(0, |index| index + 1); + let opening = line[..name_start].trim_end_matches(['\'', '"']).trim_end(); + let indent = crate::framework::leading_spaces(opening); + let opening = &opening[indent..]; + let reads_route = match opening.strip_suffix(':') { + Some(key) => indent > 0 && names_route(key), + None => opening == "-" && in_appears_on(&content[..line_start], indent), + }; + reads_route.then_some(line_start + name_start) +} + +/// Whether a list item indented by `indent`, after the text `above`, is an +/// item of an `appears_on:` list. +fn in_appears_on(above: &str, indent: usize) -> bool { + above + .lines() + .rev() + .map(crate::symfony::resources::yaml_content_before_comment) + .filter(|line| !line.trim().is_empty()) + .find(|line| crate::framework::leading_spaces(line) < indent) + .is_some_and(|line| line.trim() == "appears_on:") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn routes(content: &str) -> Vec<&str> { + links_route_spans(content) + .iter() + .map(|span| &content[span.start as usize..span.end as usize]) + .collect() + } + + #[test] + fn route_keys_and_appears_on_items_read_routes() { + let yaml = "a:\n route_name: one\n base_route: 'two'\n parent_id: no\n appears_on:\n - three\n - \"four\" # comment\n parent: no\nb:\n appears_on: [five, 'six']\nroute_name: not_a_link\n"; + assert_eq!(routes(yaml), ["one", "two", "three", "four", "five", "six"]); + } + + #[test] + fn completion_starts_in_route_values_only() { + let yaml = "a:\n route_name: on"; + assert_eq!( + links_route_name_start(yaml, yaml.len()), + Some(yaml.len() - 2) + ); + let yaml = "a:\n appears_on:\n - 'x"; + assert_eq!( + links_route_name_start(yaml, yaml.len()), + Some(yaml.len() - 1) + ); + let yaml = "a:\n parent: x"; + assert_eq!(links_route_name_start(yaml, yaml.len()), None); + let yaml = "a:\n menu_name:\n - x"; + assert_eq!(links_route_name_start(yaml, yaml.len()), None); + } +} diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 5f1dac38b..751e55d08 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -15,6 +15,7 @@ use std::sync::atomic::Ordering; use parking_lot::RwLock; +pub(crate) mod links; pub(crate) mod types; use crate::Backend; @@ -41,6 +42,8 @@ pub(crate) enum DeclarationFile { Services, /// `.routing.yml`. Routing, + /// `.links.{menu,task,action,contextual}.yml`. + Links, } /// The kind of declaration file the file name at the end of `path` is, if @@ -52,6 +55,16 @@ fn declaration_file_kind(path: &str) -> Option { Some(DeclarationFile::Services) } else if name.ends_with(".routing.yml") { Some(DeclarationFile::Routing) + } else if [ + ".links.menu.yml", + ".links.task.yml", + ".links.action.yml", + ".links.contextual.yml", + ] + .iter() + .any(|suffix| name.ends_with(suffix)) + { + Some(DeclarationFile::Links) } else { None } @@ -258,18 +271,11 @@ impl Backend { && !self.open_files.read().contains_key(uri) } - /// Whether `uri` is a file a Drupal project loads services from: a - /// services file inside one of its web roots, outside test modules - /// unless it is open. + /// The kind of declaration file `uri` is in a Drupal project: one + /// inside one of its web roots, outside test modules unless it is open. /// /// Other YAML files in a Drupal project have a `services:` key with /// another meaning, or none at all. - pub(crate) fn is_drupal_services_uri(&self, uri: &str) -> bool { - self.drupal_declaration_file(uri) == Some(DeclarationFile::Services) - } - - /// The kind of declaration file `uri` is in a Drupal project: one - /// inside one of its web roots, outside test modules unless it is open. pub(crate) fn drupal_declaration_file(&self, uri: &str) -> Option { if !self.is_drupal_project() { return None; diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index f4436d762..bab35df4a 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -289,6 +289,9 @@ impl Backend { crate::drupal::DeclarationFile::Routing => { spans = crate::symfony::routes::drupal_routing_spans(content); } + crate::drupal::DeclarationFile::Links => { + spans = crate::drupal::links::links_route_spans(content); + } } } else if is_symfony && crate::symfony::resources::is_container_config_uri(uri) { declares_twig_loader = crate::symfony::tags::tags_twig_loader(content); diff --git a/src/symfony/resources.rs b/src/symfony/resources.rs index 3e7fbbe08..b929dde57 100644 --- a/src/symfony/resources.rs +++ b/src/symfony/resources.rs @@ -318,7 +318,7 @@ pub(super) fn yaml_mapping_key(line: &str, line_start: usize) -> Option<(&str, u } /// `line` without a trailing `# comment`, leaving a `#` inside quotes. -pub(super) fn yaml_content_before_comment(line: &str) -> &str { +pub(crate) fn yaml_content_before_comment(line: &str) -> &str { let mut quote = None; for (index, byte) in line.bytes().enumerate() { match (quote, byte) { diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index e1a77f34f..b4cb43d79 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -5,6 +5,7 @@ use crate::common::{completion_response, hover_text_at, response_labels}; use crate::drupal_services::{ definitions_at, position_in, references_at, uri_for, workspace, workspace_in, }; +use tower_lsp_server::gen_lsp_types::Position; /// Just enough of core's routing API, where Composer would put core. const ROUTING_STUBS: &[(&str, &str)] = &[ @@ -141,6 +142,10 @@ async fn core_routes_complete_and_test_modules_do_not() { "web/core/modules/system/tests/modules/router_test/router_test.routing.yml", "router_test.1:\n path: '/router_test/test1'\n", ), + ( + "web/core/modules/node/node.links.action.yml", + "node.add_page:\n route_name: node.add_page\n appears_on:\n - system.admin_content\n", + ), ] { let full = dir.path().join(path); std::fs::create_dir_all(full.parent().unwrap()).unwrap(); @@ -160,6 +165,15 @@ async fn core_routes_complete_and_test_modules_do_not() { position_in(&consumer, "new Url('acme.greeting')", 10), ) .await; + let system = uri_for(&dir, "web/core/modules/system/system.routing.yml"); + let found = references_at(&backend, &system, Position::new(2, 3)).await; + assert!( + found.contains(&( + uri_for(&dir, "web/core/modules/node/node.links.action.yml"), + 3 + )), + "{found:?}" + ); for (needle, inside) in [ ("fromRoute('')", 11), @@ -207,3 +221,69 @@ final class Links { let found = references_at(&backend, &yaml, position_in(ROUTING, "acme.greeting", 2)).await; assert_eq!(found, vec![(yaml, 0), (php.clone(), 8), (php, 9)]); } + +const TASKS: &str = "acme.greeting_tab:\n route_name: acme.greeting\n base_route: 'acme.greeting'\n parent_id: acme.greeting\n title: 'Greeting'\n"; +const TASKS_PATH: &str = "web/modules/custom/acme/acme.links.task.yml"; +const ACTIONS: &str = "acme.add:\n route_name: \"acme.greeting\"\n appears_on:\n - acme.greeting\n - 'acme.greeting' # the list\n parent: acme.greeting\nacme.flow:\n route_name: acme.greeting\n appears_on: [acme.greeting, 'acme.greeting']\n"; +const ACTIONS_PATH: &str = "web/modules/custom/acme/acme.links.action.yml"; + +/// Links files place routes by name: `route_name`, `base_route`, and the +/// items of `appears_on`, but not the menu link or task a `parent` names. +#[tokio::test] +async fn links_files_read_route_names() { + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (TASKS_PATH, TASKS), + (ACTIONS_PATH, ACTIONS), + ])) + .await; + let yaml = uri_for(&dir, ROUTING_PATH); + let tasks = uri_for(&dir, TASKS_PATH); + let actions = uri_for(&dir, ACTIONS_PATH); + + let found = definitions_at(&backend, &tasks, position_in(TASKS, "acme.greeting\n", 3)).await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!((&found[0].uri, found[0].range.start.line), (&yaml, 0)); + + let found = references_at(&backend, &yaml, position_in(ROUTING, "acme.greeting", 2)).await; + let mut expected = vec![ + (yaml, 0), + (tasks.clone(), 1), + (tasks, 2), + (actions.clone(), 1), + (actions.clone(), 3), + (actions.clone(), 4), + (actions.clone(), 7), + (actions.clone(), 8), + (actions, 8), + ]; + expected.sort(); + assert_eq!(found, expected); +} + +#[tokio::test] +async fn links_files_complete_route_names() { + for (content, needle, inside) in [ + ("acme.tab:\n route_name: acme.gr\n", "acme.gr\n", 7), + ("acme.tab:\n base_route: 'acme.\n", "'acme.", 6), + ("acme.add:\n appears_on:\n - \n", "- \n", 2), + ] { + let (backend, dir) = + workspace(&files(&[(ROUTING_PATH, ROUTING), (TASKS_PATH, content)])).await; + let position = position_in(content, needle, inside); + let labels = response_labels( + completion_response( + &backend, + &uri_for(&dir, TASKS_PATH), + position.line, + position.character, + None, + ) + .await, + ); + assert!( + labels.contains(&"acme.greeting".to_string()), + "{content}: {labels:?}" + ); + } +} From e4f739beeac5c06bb16b040b7ad35e60beac084c Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Mon, 2 Nov 2026 12:39:33 +0100 Subject: [PATCH 03/18] Read Drupal route attributes and the route names Drupal's Twig reads A #[Route] on a controller under an extension's Controller namespace declares its route the way Drupal's attribute route discovery names it: the class attribute's name prefixes the method routes, an invokable class with no method routes takes the class's own, and a method route without a name declares nothing. path() and url() in a Drupal template complete and navigate route names. Assisted-by: claude-code/claude-opus-5-5 --- src/completion/symfony_string_keys.rs | 29 ++++++++- src/drupal/mod.rs | 14 ++++ src/drupal/tests.rs | 60 +++++++++++++++++ src/resource_navigation.rs | 24 ++++++- src/symbol_map/extraction/class_like.rs | 1 + src/symbol_map/extraction/drupal.rs | 70 ++++++++++++++++++++ src/symbol_map/extraction/symfony.rs | 62 +++++++++++++----- src/symfony/routes.rs | 15 +++-- tests/integration/drupal_routes.rs | 86 +++++++++++++++++++++++++ 9 files changed, 334 insertions(+), 27 deletions(-) diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index e3cbb75ba..a43a4261f 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -348,9 +348,20 @@ impl Backend { let cursor = position_to_offset(content, position) as usize; let (kind, prefix_start) = if self.is_twig_template_uri(uri) { // Template names are Twig's, and route names and translation - // keys Symfony's. + // keys Symfony's; Drupal's Twig reads route names too. if !self.is_twig_project() { - return None; + if !self.is_drupal_project() { + return None; + } + let start = crate::symfony::routes::twig_route_name_start(content, cursor)?; + return self.symfony_key_response( + uri, + StringKeyKind::DrupalRoute, + content, + start, + cursor, + position, + ); } if is_symfony && let Some((start, domain)) = @@ -382,11 +393,25 @@ impl Backend { } if let Some(start) = crate::twig::templates::twig_template_name_start(content, cursor) { (StringKeyKind::TwigTemplate, start) + } else if let Some(start) = self + .is_in_drupal_web_root(uri) + .then(|| crate::symfony::routes::twig_route_name_start(content, cursor)) + .flatten() + { + // The templates in a Drupal web root name Drupal's routes, + // also when the site requires Symfony's container. + (StringKeyKind::DrupalRoute, start) } else if let Some(start) = is_symfony .then(|| crate::symfony::routes::twig_route_name_start(content, cursor)) .flatten() { (StringKeyKind::SymfonyRoute, start) + } else if let Some(start) = self + .is_drupal_project() + .then(|| crate::symfony::routes::twig_route_name_start(content, cursor)) + .flatten() + { + (StringKeyKind::DrupalRoute, start) } else { let (start, kind) = crate::twig::callables::callable_name_start(content, cursor)?; let names = self.twig_callable_names(kind); diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 751e55d08..7a995480d 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -271,6 +271,20 @@ impl Backend { && !self.open_files.read().contains_key(uri) } + /// Whether `uri` is inside one of the web roots of the workspace's + /// Drupal projects, where Drupal's own reading of a file wins over + /// Symfony's. + pub(crate) fn is_in_drupal_web_root(&self, uri: &str) -> bool { + self.is_drupal_project() + && crate::util::uri_to_path(uri).is_some_and(|path| { + self.drupal + .web_roots + .read() + .iter() + .any(|root| path.starts_with(root)) + }) + } + /// The kind of declaration file `uri` is in a Drupal project: one /// inside one of its web roots, outside test modules unless it is open. /// diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index 72dd92fc4..d5a45b2bc 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -83,6 +83,66 @@ fn a_routing_file_under_a_config_directory_is_drupals_in_a_symfony_site() { ); } +/// The route names `content`, published as the template at `path`, reads, +/// with the kind of route each is read as. +fn template_routes( + backend: &crate::Backend, + root: &Path, + path: &str, + content: &str, +) -> Vec<(StringKeyKind, String)> { + let template = uri(root, path); + backend.update_resource_symbol_index(&template, content); + let map = backend + .symbol_map_for(&template) + .expect("the template's map"); + map.spans + .iter() + .filter_map(|span| match &span.kind { + crate::symbol_map::SymbolKind::StringKey { kind, key, .. } + if matches!( + kind, + StringKeyKind::DrupalRoute | StringKeyKind::SymfonyRoute + ) => + { + Some((*kind, key.clone())) + } + _ => None, + }) + .collect() +} + +#[test] +fn a_drupal_sites_templates_name_drupal_routes_in_a_symfony_project() { + let (backend, dir) = drupal_backend(&[]); + backend.set_is_symfony(true); + assert_eq!( + template_routes( + &backend, + dir.path(), + "web/modules/custom/acme/templates/acme.html.twig", + "{{ path('acme.greeting') }}\n", + ), + vec![(StringKeyKind::DrupalRoute, "acme.greeting".to_string())] + ); +} + +#[test] +fn a_template_outside_the_web_root_names_symfony_routes() { + // A Symfony application beside the Drupal site, in the same workspace. + let (backend, dir) = drupal_backend(&[]); + backend.set_is_symfony(true); + assert_eq!( + template_routes( + &backend, + dir.path(), + "app/templates/base.html.twig", + "{{ path('blog_index') }}\n", + ), + vec![(StringKeyKind::SymfonyRoute, "blog_index".to_string())] + ); +} + #[test] fn other_yaml_is_not_read_as_services() { let (backend, _dir) = drupal_backend(&[ diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index bab35df4a..0fc624482 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -256,6 +256,8 @@ impl Backend { let spans = if is_template { if self.is_twig_project() { self.twig_template_spans(uri, content, is_symfony) + } else if self.is_drupal_project() { + crate::symfony::routes::twig_route_spans(content, StringKeyKind::DrupalRoute) } else { Vec::new() } @@ -386,16 +388,32 @@ impl Backend { impl Backend { /// The spans of a Twig template: the names it is loaded by, the - /// templates, blocks, filters, functions, and variables it names, and in - /// a Symfony project the route names and translation keys it reads. + /// templates, blocks, filters, functions, and variables it names, the + /// route names it reads, Drupal's inside a Drupal web root and + /// Symfony's elsewhere in a Symfony project, and in a Symfony project + /// its translation keys. fn twig_template_spans(&self, uri: &str, content: &str, is_symfony: bool) -> Vec { let roots = self.twig_roots(); let names = crate::util::uri_to_path(uri) .map(|path| roots.template_names(&path)) .unwrap_or_default(); let mut spans = crate::twig::templates::template_declaration_spans(names); + // A Drupal site that requires Symfony's container is a Symfony + // project too, but the templates in its web root name Drupal's + // routes. A Symfony application beside it keeps its own. + let route_kind = if self.is_in_drupal_web_root(uri) { + Some(StringKeyKind::DrupalRoute) + } else if is_symfony { + Some(StringKeyKind::SymfonyRoute) + } else if self.is_drupal_project() { + Some(StringKeyKind::DrupalRoute) + } else { + None + }; + if let Some(kind) = route_kind { + spans.extend(crate::symfony::routes::twig_route_spans(content, kind)); + } if is_symfony { - spans.extend(crate::symfony::routes::twig_route_spans(content)); spans.extend(crate::symfony::translations::twig_translation_spans( content, )); diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index 20e6724b5..daa310a5c 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_route_attribute_spans(class, 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 6251aa02f..af4ea0f7f 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -3,6 +3,9 @@ //! constructor calls read (see [`crate::drupal`]). use super::*; +use crate::symbol_map::extraction::symfony::{ + RouteDeclaration, RouteName, push_route_spans, route_declarations, +}; use crate::symbol_map::{EntityTypeSite, StringKeyKind}; use crate::virtual_members::laravel::helpers::resolved_string_literal; @@ -101,6 +104,73 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( } } +/// Record the routes the `#[Route]` attributes of a controller declare, the +/// way Drupal's attribute route discovery reads them. +/// +/// Drupal looks for them in the classes under an extension's `Controller` +/// namespace. A route on a method is named by its `name:`, after the name +/// of the class's own first `#[Route]`; one without a name is rejected. A +/// class with no routes on its methods and an `__invoke()` is a single +/// action, and its own `#[Route]`s are its routes. +pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( + class: &Class<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal || class.modifiers.contains_abstract() { + return; + } + let is_controller = ctx + .resolved_name_at(class.name.span.start.offset) + .is_some_and(is_controller_class); + if !is_controller { + return; + } + // Drupal's discovery reads attributes only, never `@Route` annotations. + let class_routes = route_declarations(&class.attribute_lists, None, class, ctx); + let prefix = match class_routes.first().map(|route| &route.name) { + Some(RouteName::Literal(name, _, _)) => name.as_str(), + _ => "", + }; + let is_named = |route: &&RouteDeclaration| matches!(route.name, RouteName::Literal(..)); + let mut spans = Vec::new(); + let mut any_method_route = false; + let mut invokable = false; + for member in class.members.iter() { + let ClassLikeMember::Method(method) = member else { + continue; + }; + invokable |= bytes_to_str(method.name.value).eq_ignore_ascii_case("__invoke"); + let routes = route_declarations(&method.attribute_lists, None, class, ctx); + any_method_route |= !routes.is_empty(); + for route in routes.iter().filter(is_named) { + push_route_spans( + route, + prefix, + None, + StringKeyKind::DrupalRoute, + &[], + &mut spans, + ); + } + } + if !any_method_route && invokable { + for route in class_routes.iter().filter(is_named) { + push_route_spans(route, "", None, StringKeyKind::DrupalRoute, &[], &mut spans); + } + } + ctx.spans.extend(spans); +} + +/// Whether `fqn` is a class under an extension's (or core's) `Controller` +/// namespace: `Drupal\\Controller\…`. +fn is_controller_class(fqn: &str) -> bool { + let mut segments = fqn.trim_start_matches('\\').split('\\'); + segments.next() == Some("Drupal") + && segments.next().is_some() + && segments.next() == Some("Controller") + && segments.next().is_some() +} + /// Record the route name `new Url('…')` reads. pub(in crate::symbol_map::extraction) fn emit_drupal_url_spans( class: &Expression<'_>, diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index b2fb440e0..0495959f6 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -1444,7 +1444,7 @@ fn is_route_attribute(class_name: &str) -> bool { } /// A route's name, as its declaration writes it. -enum RouteName { +pub(in crate::symbol_map::extraction) enum RouteName { /// No name: Symfony derives one from the class and the method. Absent, /// A literal, with the byte range of its text. @@ -1455,8 +1455,8 @@ enum RouteName { /// One `#[Route]` attribute or `@Route` annotation, by what bears on the /// names it declares. -struct RouteDeclaration { - name: RouteName, +pub(in crate::symbol_map::extraction) struct RouteDeclaration { + pub(in crate::symbol_map::extraction) name: RouteName, /// The byte range a derived name spans: the path, or the whole /// declaration when the path is not a literal. at: (usize, usize), @@ -1538,7 +1538,7 @@ fn route_name_value( /// The routes the `#[Route]` attributes in `attribute_lists` declare, and /// the `@Route` annotations in `docblock` (its text and offset), on `class` /// or one of its methods. -fn route_declarations( +pub(in crate::symbol_map::extraction) fn route_declarations( attribute_lists: &Sequence<'_, AttributeList<'_>>, docblock: Option<(&str, u32)>, class: &Class<'_>, @@ -1918,7 +1918,12 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_route_spans( if action.count == 1 && let Some(class) = derive { - declare_route(&mut spans, format!("{class}::{method_name}"), action.at); + declare_route( + &mut spans, + StringKeyKind::SymfonyRoute, + format!("{class}::{method_name}"), + action.at, + ); } count += action.count; last_at = action.at; @@ -1942,19 +1947,34 @@ pub(in crate::symbol_map::extraction) fn emit_symfony_route_spans( && count == 1 && let Some(class) = derive { - declare_route(&mut spans, class.to_string(), last_at); - declare_route(&mut spans, format!("{class}::__invoke"), last_at); + declare_route( + &mut spans, + StringKeyKind::SymfonyRoute, + class.to_string(), + last_at, + ); + declare_route( + &mut spans, + StringKeyKind::SymfonyRoute, + format!("{class}::__invoke"), + last_at, + ); } ctx.spans.extend(spans); } -/// Declare the route `key` over the byte range `at`. -fn declare_route(spans: &mut Vec, key: String, at: (usize, usize)) { +/// Declare the route `key`, a `kind` key, over the byte range `at`. +fn declare_route( + spans: &mut Vec, + kind: StringKeyKind, + key: String, + at: (usize, usize), +) { spans.push(SymbolSpan { start: at.0 as u32, end: at.1 as u32, kind: SymbolKind::StringKey { - kind: StringKeyKind::SymfonyRoute, + kind, key, is_write: true, is_optional: false, @@ -2415,32 +2435,40 @@ fn push_action_route_spans( } else { &route.locales }; - action.unreadable |= push_route_spans(route, prefix, derived, locales, spans); + action.unreadable |= push_route_spans( + route, + prefix, + derived, + StringKeyKind::SymfonyRoute, + locales, + spans, + ); action.count += locales.len().max(1); action.at = route.at; } action } -/// The spans one route declares: its name, or the `derived` one, and its -/// aliases, each under every one of its `locales`. A localized route's own +/// The spans one route declares as `kind` keys: its name, or the `derived` +/// one, and its aliases, each under every one of its `locales`. A localized route's own /// name still generates the current locale's route; an alias's does not. /// Whether its name cannot be read. -fn push_route_spans( +pub(in crate::symbol_map::extraction) fn push_route_spans( route: &RouteDeclaration, prefix: &str, derived: Option, + kind: StringKeyKind, locales: &[String], spans: &mut Vec, ) -> bool { let localized = |spans: &mut Vec, name: &str, at: (usize, usize)| { for locale in locales { - declare_route(spans, format!("{name}.{locale}"), at); + declare_route(spans, kind, format!("{name}.{locale}"), at); } }; let declare = |spans: &mut Vec, name: String, at: (usize, usize)| { localized(spans, &name, at); - declare_route(spans, name, at); + declare_route(spans, kind, name, at); }; let unreadable = match &route.name { RouteName::Literal(name, start, end) => { @@ -2457,7 +2485,7 @@ fn push_route_spans( }; for (alias, start, end) in &route.aliases { if locales.is_empty() { - declare_route(spans, alias.clone(), (*start, *end)); + declare_route(spans, kind, alias.clone(), (*start, *end)); } else { localized(spans, alias, (*start, *end)); } diff --git a/src/symfony/routes.rs b/src/symfony/routes.rs index 71eb45470..3902bedc2 100644 --- a/src/symfony/routes.rs +++ b/src/symfony/routes.rs @@ -382,8 +382,9 @@ fn scan_xml(content: &str, spans: &mut Vec, imports: &mut Vec Vec { +/// read, as `kind` keys (Symfony's and Drupal's Twig both have the two +/// functions), sorted by offset. +pub(crate) fn twig_route_spans(content: &str, kind: StringKeyKind) -> Vec { let bytes = content.as_bytes(); let mut spans = Vec::new(); let mut cursor = 0usize; @@ -417,8 +418,12 @@ pub(crate) fn twig_route_spans(content: &str) -> Vec { break; }; let name = &content[start..start + len]; - if is_route_name(name) { - spans.push(route_span(name, start, false)); + let is_name = match kind { + StringKeyKind::DrupalRoute => is_drupal_route_name(name), + _ => is_route_name(name), + }; + if is_name { + spans.push(route_span_of(kind, name, start, false)); } cursor = start + len + 1; } @@ -574,7 +579,7 @@ mod tests { fn twig_reads_path_and_url_calls() { let twig = "{# path('commented') #}\n{{ url( \"blog_post\", {slug: post.slug}) }}\n{{ app.request.path('not_a_route') }}{{ asset_path('nor_this') }}\n"; assert_eq!( - routes(&twig_route_spans(twig), twig), + routes(&twig_route_spans(twig, StringKeyKind::SymfonyRoute), twig), vec![ ("blog_index".to_string(), false), ("blog_post".to_string(), false), diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index b4cb43d79..4db81ebb5 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -287,3 +287,89 @@ async fn links_files_complete_route_names() { ); } } + +const ROUTE_ATTRIBUTE: (&str, &str) = ( + "web/core/lib/Symfony/Routing/Route.php", + "{{ url('acme.about') }}\n{{ path('') }}\n"; +const TEMPLATE_PATH: &str = "web/modules/custom/acme/templates/acme-notes.html.twig"; + +/// Drupal reads `#[Route]` on the classes of an extension's `Controller` +/// namespace, and its Twig's `path()` and `url()` read route names. +#[tokio::test] +async fn route_attributes_declare_and_templates_read() { + let (backend, dir) = workspace(&files(&[ + ROUTE_ATTRIBUTE, + (ATTRIBUTE_PATH, ATTRIBUTE_CONTROLLER), + ( + "web/modules/custom/acme/src/Form/Elsewhere.php", + NOT_A_CONTROLLER, + ), + (TEMPLATE_PATH, TEMPLATE), + ])) + .await; + let php = uri_for(&dir, ATTRIBUTE_PATH); + let twig = uri_for(&dir, TEMPLATE_PATH); + + for (needle, line) in [("path('acme.notes.list')", 7), ("url('acme.about')", 14)] { + let found = definitions_at( + &backend, + &twig, + position_in(TEMPLATE, needle, needle.find('\'').unwrap() + 2), + ) + .await; + assert_eq!(found.len(), 1, "{needle}: {found:?}"); + assert_eq!( + (&found[0].uri, found[0].range.start.line), + (&php, line), + "{needle}" + ); + } + + let position = position_in(TEMPLATE, "path('')", 6); + let labels = response_labels( + completion_response(&backend, &twig, position.line, position.character, None).await, + ); + let mut routes: Vec<_> = labels + .iter() + .filter(|label| label.starts_with("acme.")) + .collect(); + routes.sort(); + assert_eq!(routes, ["acme.about", "acme.notes.list"]); +} From 518cfb0d9ec63bb891ea133987f79b4e56575a25 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Mon, 2 Nov 2026 12:44:26 +0100 Subject: [PATCH 04/18] Read the routes Drupal's PHP route builders add A class whose file uses Symfony's Route or RouteCollection, as route callbacks, route subscribers, and entity route providers do, adds the route that $collection->add() and $routes[...] = new Route() name. A name written out is declared there and navigable; one built from parts is kept as a pattern of the names it can take, for the unknown-route check to come. Assisted-by: claude-code/claude-opus-5-5 --- src/mem_audit.rs | 4 + src/symbol_map/extraction/class_like.rs | 1 + src/symbol_map/extraction/drupal.rs | 243 ++++++++++++++++++++++++ src/symbol_map/extraction/mod.rs | 3 + src/symbol_map/map.rs | 5 + src/symfony/mod.rs | 1 + tests/integration/drupal_routes.rs | 44 +++++ 7 files changed, 301 insertions(+) diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 189bc2e95..cffcbb6dc 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1402,6 +1402,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_route_patterns.capacity() * size_of::()); + for pattern in &sm.drupal_route_patterns { + sym.add(pattern.capacity()); + } sym.add(sm.routes.capacity() * size_of::()); for route in &sm.routes { sym.add(route.path.as_ref().map_or(0, String::capacity)); diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index daa310a5c..50328c0e4 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -25,6 +25,7 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC emit_yii_route_argument_spans(class, ctx); emit_drupal_entity_type_spans(class, ctx); emit_drupal_route_attribute_spans(class, ctx); + emit_drupal_route_builder_spans(class, 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 af4ea0f7f..f2d961967 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -171,6 +171,177 @@ fn is_controller_class(fqn: &str) -> bool { && segments.next().is_some() } +/// What a route builder names the route it adds: a name written out, or +/// the shape of one built from parts. +enum BuiltName { + Literal(String, u32, u32), + Pattern(String), +} + +/// Record the routes a route builder adds: a class whose file uses +/// Symfony's `Route` or `RouteCollection`, as the classes a routing file's +/// `route_callbacks` name, route subscribers, and entity route providers +/// do. `$collection->add(NAME, $route)` and `$routes[NAME] = new Route(…)` +/// add the route `NAME`: written out, it is declared; built from parts, its +/// shape is kept as a pattern, and a name with no part written out stands +/// for any route under the extension's own prefix. +pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( + class: &Class<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal || !ctx.content.contains("Symfony\\Component\\Routing\\Route") { + return; + } + let mut found = BuilderSites::default(); + for member in class.members.iter() { + if let ClassLikeMember::Method(method) = member { + mago_syntax::walker::Walker::walk_method(&BuilderWalker, method, &mut found); + } + } + let mut names: Vec<&Expression<'_>> = found.added; + for (name, class_expr) in found.assigned { + let Expression::Identifier(identifier) = class_expr else { + continue; + }; + let resolved = ctx + .resolved_name_at(identifier.span().start.offset) + .unwrap_or_else(|| bytes_to_str(identifier.value())); + if resolved + .trim_start_matches('\\') + .eq_ignore_ascii_case("Symfony\\Component\\Routing\\Route") + { + names.push(name); + } + } + for name in names { + match built_name(name, ctx.content) { + BuiltName::Literal(key, start, end) => ctx.spans.push(SymbolSpan { + start, + end, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key, + is_write: true, + is_optional: false, + }, + }), + BuiltName::Pattern(pattern) => { + let pattern = if pattern.bytes().all(|byte| byte == b'*') { + let Some(prefix) = ctx + .resolved_name_at(class.name.span.start.offset) + .and_then(extension_of) + else { + continue; + }; + format!("{prefix}.*") + } else { + pattern + }; + if !ctx.drupal_route_patterns.contains(&pattern) { + ctx.drupal_route_patterns.push(pattern); + } + } + } + } +} + +/// The calls and assignments of a route builder's methods that may add a +/// route. +#[derive(Default)] +struct BuilderSites<'ast, 'arena> { + /// The first argument of each `->add()` with two or more arguments. + added: Vec<&'ast Expression<'arena>>, + /// The index of each `$routes[NAME] = new Class(…)`, with the class. + assigned: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, +} + +struct BuilderWalker; + +impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BuilderSites<'ast, 'arena>> + for BuilderWalker +{ + fn walk_in_method_call( + &self, + call: &'ast MethodCall<'arena>, + found: &mut BuilderSites<'ast, 'arena>, + ) { + let ClassLikeMemberSelector::Identifier(name) = &call.method else { + return; + }; + let arguments = &call.argument_list.arguments; + if name.value.eq_ignore_ascii_case(b"add") + && arguments.len() >= 2 + && let Some(Argument::Positional(first)) = arguments.first() + { + found.added.push(first.value); + } + } + + fn walk_in_assignment( + &self, + assignment: &'ast Assignment<'arena>, + found: &mut BuilderSites<'ast, 'arena>, + ) { + if let Expression::ArrayAccess(access) = assignment.lhs + && let Expression::Instantiation(instantiation) = assignment.rhs + { + found.assigned.push((access.index, instantiation.class)); + } + } +} + +/// The name a route builder gives a route: written out, or the shape of +/// the parts it is built from. +fn built_name(expression: &Expression<'_>, content: &str) -> BuiltName { + if let Some((key, start, end)) = resolved_string_literal(expression, content) { + return BuiltName::Literal(key.to_string(), start as u32, end as u32); + } + let mut pattern = String::new(); + push_name_parts(expression, &mut pattern); + BuiltName::Pattern(pattern) +} + +/// Append the shape of `expression` to `pattern`: its text where it is a +/// string, `*` where it is anything else. +fn push_name_parts(expression: &Expression<'_>, pattern: &mut String) { + let unknown = |pattern: &mut String| { + if !pattern.ends_with('*') { + pattern.push('*'); + } + }; + match expression { + Expression::Literal(Literal::String(string)) => match string.value { + Some(value) => pattern.push_str(&String::from_utf8_lossy(value)), + None => unknown(pattern), + }, + Expression::CompositeString(CompositeString::Interpolated(string)) => { + for part in string.parts.iter() { + match part { + StringPart::Literal(literal) => match literal.value { + Some(value) => pattern.push_str(&String::from_utf8_lossy(value)), + None => pattern.push_str(&String::from_utf8_lossy(literal.raw)), + }, + _ => unknown(pattern), + } + } + } + Expression::Binary(binary) if binary.operator.is_concatenation() => { + push_name_parts(binary.lhs, pattern); + push_name_parts(binary.rhs, pattern); + } + Expression::Parenthesized(inner) => push_name_parts(inner.expression, pattern), + _ => unknown(pattern), + } +} + +/// The extension a class of `Drupal\\…` belongs to, other than +/// core's own `Drupal\Core` and `Drupal\Component`. +fn extension_of(fqn: &str) -> Option<&str> { + let mut segments = fqn.trim_start_matches('\\').split('\\'); + let extension = (segments.next() == Some("Drupal")).then(|| segments.next())??; + (!matches!(extension, "Core" | "Component") && segments.next().is_some()).then_some(extension) +} + /// Record the route name `new Url('…')` reads. pub(in crate::symbol_map::extraction) fn emit_drupal_url_spans( class: &Expression<'_>, @@ -419,6 +590,78 @@ mod tests { .collect() } + /// The route names `php`'s route builders declare, and the patterns + /// they keep. + fn built(php: &str) -> (Vec, 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)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + }, + None, + ); + let declared = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key, + is_write: true, + .. + } => Some(key.clone()), + _ => None, + }) + .collect(); + (declared, map.drupal_route_patterns.clone()) + } + + #[test] + fn route_builders_declare_names_and_keep_patterns() { + let php = r#"add('search.literal', new Route('/x')); + $collection->add("{$name}.bc", new Route('/y')); + $collection->add($name, new Route('/z')); + $routes['not_a_route'] = new \ArrayObject(); + $form->add('not_a_route_either'); + return $routes; + } +} +"#; + let (mut declared, mut patterns) = built(php); + declared.sort(); + patterns.sort(); + assert_eq!(declared, ["search.literal", "search.view"]); + assert_eq!( + patterns, + ["*.bc", "search.*", "search.help_*", "search.view_*"] + ); + } + + #[test] + fn a_file_without_symfony_routes_builds_none() { + let php = "add('acme.thing', 1); }\n}\n"; + assert_eq!(built(php), (Vec::new(), Vec::new())); + } + #[test] fn an_attribute_declares_the_id_class_and_storage() { let php = " NodeStorage::class, 'form' => ['default' => 'X']],\n)]\nclass Node {}\n"; diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 22af1f79e..f5aaef8e5 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -104,6 +104,7 @@ struct ExtractionCtx<'a> { container_values: Vec, /// The entity types the file's entity classes declare. entity_types: Vec, + drupal_route_patterns: Vec, /// Current conditional nesting depth (if/else, switch, while, for, etc.). /// Incremented when entering a conditional block, decremented when leaving. cond_nesting_depth: u16, @@ -477,6 +478,7 @@ fn extract_symbol_map_inner( twig_contexts: Vec::new(), container_values: Vec::new(), entity_types: Vec::new(), + drupal_route_patterns: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, @@ -670,6 +672,7 @@ fn extract_symbol_map_inner( twig_contexts: ctx.twig_contexts, container_values: ctx.container_values, entity_types: ctx.entity_types, + drupal_route_patterns: ctx.drupal_route_patterns, routes: ctx.routes, declares_twig_loader: ctx.declares_twig_loader, route_imports: ctx diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index 805664f9b..a076c5913 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -307,6 +307,11 @@ 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 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 + /// none. + pub drupal_route_patterns: Vec, /// The routes the file declares, in source order. Empty for every file /// that declares none. pub routes: Vec, diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 9419576c0..0d4eb7068 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -148,6 +148,7 @@ fn declares_symfony_keys(map: &SymbolMap, enabled: impl Fn(StringKeyKind) -> boo || (!map.routes.is_empty() && enabled(StringKeyKind::YiiRoute)) || (!map.route_imports.is_empty() && enabled(StringKeyKind::SymfonyRoute)) || (map.declares_twig_loader && enabled(StringKeyKind::TwigTemplate)) + || (!map.drupal_route_patterns.is_empty() && enabled(StringKeyKind::DrupalRoute)) || map.spans.iter().any(|span| { matches!( &span.kind, diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index 4db81ebb5..acd9e6f22 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -373,3 +373,47 @@ async fn route_attributes_declare_and_templates_read() { routes.sort(); assert_eq!(routes, ["acme.about", "acme.notes.list"]); } + +const BUILDER: &str = r#" Date: Mon, 2 Nov 2026 12:46:38 +0100 Subject: [PATCH 05/18] Navigate the entity types and services a Drupal route's defaults name In a routing file, the entity type of an _entity_form, _entity_view, or _entity_list and the service of a service:method _controller or _title_callback jump to their declarations and show up in their references. Assisted-by: claude-code/claude-opus-5-5 --- src/drupal/mod.rs | 1 + src/drupal/routing.rs | 91 ++++++++++++++++++++++++++++++ src/resource_navigation.rs | 1 + tests/integration/drupal_routes.rs | 67 ++++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 src/drupal/routing.rs diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 7a995480d..5237a8fef 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 links; +pub(crate) mod routing; pub(crate) mod types; use crate::Backend; diff --git a/src/drupal/routing.rs b/src/drupal/routing.rs new file mode 100644 index 000000000..641b9c1c6 --- /dev/null +++ b/src/drupal/routing.rs @@ -0,0 +1,91 @@ +//! The names a Drupal routing file's route defaults read besides route +//! names: the entity type an `_entity_form`, `_entity_list`, or +//! `_entity_view` works on, and the service a `service:method` controller +//! or title callback calls. + +use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; + +/// The entity type and service spans of a routing file, in source order. +pub(crate) fn routing_value_spans(content: &str) -> Vec { + let mut spans = Vec::new(); + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + let indent = crate::framework::leading_spaces(semantic); + if indent == 0 { + continue; + } + let text = &semantic[indent..]; + let Some(colon) = text.find(':') else { + continue; + }; + let key = text[..colon].trim_end(); + let raw = &text[colon + 1..]; + let trimmed = raw.trim_start(); + let (value, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); + let value_start = line_start + indent + colon + 1 + (raw.len() - trimmed.len()) + quote; + let found = match key { + // `node.edit` is the form `edit` of the entity type `node`, and + // `node.full` its view mode `full`. + "_entity_form" | "_entity_view" | "_entity_list" => value + .split('.') + .next() + .map(|id| (StringKeyKind::DrupalEntityType, id)), + // `acme.greeter:greet` calls `greet()` on the service + // `acme.greeter`; a `Class::method` names a class instead. + "_controller" | "_title_callback" if !value.contains("::") => value + .split_once(':') + .map(|(service, _)| (StringKeyKind::ServiceId, service)), + _ => None, + }; + let Some((kind, name)) = found.filter(|(_, name)| is_plain_name(name)) else { + continue; + }; + spans.push(SymbolSpan { + start: value_start as u32, + end: (value_start + name.len()) as u32, + kind: SymbolKind::StringKey { + kind, + key: name.to_string(), + is_write: false, + is_optional: false, + }, + }); + } + spans +} + +/// Whether `name` can be an entity type ID or a service ID. +fn is_plain_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entity_and_service_defaults_read_their_names() { + let yaml = "a:\n path: '/a'\n defaults:\n _entity_form: 'node.edit'\n _entity_view: node.full\n _entity_list: node_type\n _controller: acme.greeter:greet\n _title_callback: '\\Drupal\\acme\\Title::get'\n requirements:\n _entity_access: node.update\n"; + let found: Vec<(&str, StringKeyKind)> = routing_value_spans(yaml) + .iter() + .map(|span| { + let SymbolKind::StringKey { kind, .. } = span.kind else { + unreachable!() + }; + (&yaml[span.start as usize..span.end as usize], kind) + }) + .collect(); + assert_eq!( + found, + [ + ("node", StringKeyKind::DrupalEntityType), + ("node", StringKeyKind::DrupalEntityType), + ("node_type", StringKeyKind::DrupalEntityType), + ("acme.greeter", StringKeyKind::ServiceId), + ] + ); + } +} diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index 0fc624482..d35ffc1ec 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -290,6 +290,7 @@ impl Backend { } crate::drupal::DeclarationFile::Routing => { spans = crate::symfony::routes::drupal_routing_spans(content); + spans.extend(crate::drupal::routing::routing_value_spans(content)); } crate::drupal::DeclarationFile::Links => { spans = crate::drupal::links::links_route_spans(content); diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index acd9e6f22..e8156d9ce 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -417,3 +417,70 @@ async fn a_route_builder_declares_the_routes_it_names() { (&uri_for(&dir, BUILDER_PATH), 8) ); } + +const VALUES_ROUTING: &str = "acme.note_edit:\n path: '/note/{acme_note}/edit'\n defaults:\n _entity_form: acme_note.edit\n _controller: acme.greeter:greet\n _title_callback: '\\Drupal\\acme\\Greeter::greet'\n"; + +/// A route's defaults name the entity type of an entity form, the service +/// of a `service:method` controller, and the method of a `Class::method`. +#[tokio::test] +async fn routing_defaults_navigate_to_entity_types_services_and_methods() { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ( + "web/core/modules/acme_core/acme_core.routing.yml", + VALUES_ROUTING, + ), + ] { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let note = " Date: Mon, 2 Nov 2026 12:53:16 +0100 Subject: [PATCH 06/18] Report Drupal route names nothing declares A route name read in PHP that no routing file, route attribute, or route builder declares is reported. Names nothing can tell missing are left alone: the entity.* and view.* routes entity route providers and Views build, names matching the shape of a route a builder builds from parts, route names in test code, and every name in a project whose routing files were not read. A Class::method route alias is judged by whether the method exists. Assisted-by: claude-code/claude-opus-5-5 --- src/backend/startup.rs | 10 ++ src/diagnostics/laravel_string_keys.rs | 72 ++++++++- src/drupal/mod.rs | 60 ++++++++ src/drupal/tests.rs | 26 ++++ tests/integration/drupal_routes.rs | 198 +++++++++++++++++++++++++ 5 files changed, 365 insertions(+), 1 deletion(-) diff --git a/src/backend/startup.rs b/src/backend/startup.rs index 9ff3f2f2a..11b4d7dee 100644 --- a/src/backend/startup.rs +++ b/src/backend/startup.rs @@ -656,6 +656,16 @@ impl Backend { .await; } + // A Drupal file opened before the index finished was judged + // against the route names read so far, and the route builders + // of core and contributed extensions are only read here. + if progress_backend.is_drupal_project() { + let open: Vec = + progress_backend.open_files.read().keys().cloned().collect(); + for uri in open { + progress_backend.schedule_diagnostics(uri); + } + } progress_backend.request_diagnostic_refresh().await; // Files opened before the index finished were annotated from a diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index 9ba044b6c..cfea4c812 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -47,6 +47,7 @@ enum CheckedStringKind { YiiView, YiiRoute, YiiRouteArgument, + DrupalRoute, } impl CheckedStringKind { @@ -76,6 +77,7 @@ impl CheckedStringKind { StringKeyKind::YiiView => Self::YiiView, StringKeyKind::YiiRoute => Self::YiiRoute, StringKeyKind::YiiRouteArgument => Self::YiiRouteArgument, + StringKeyKind::DrupalRoute => Self::DrupalRoute, // 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 @@ -95,7 +97,6 @@ impl CheckedStringKind { // an entity type in PHP, through `hook_entity_type_build()`. StringKeyKind::WordPressHook | StringKeyKind::DrupalEntityType - | StringKeyKind::DrupalRoute | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack @@ -167,6 +168,11 @@ struct DeclaredKeys<'a> { messenger_buses: Vec, /// The Yii route names the project declares, sorted. yii_routes: Vec, + /// The Drupal route names the project declares, sorted. + drupal_routes: Vec, + /// The shapes of the route names Drupal's route builders give the + /// routes they build from parts (`search.view_*`). + drupal_route_patterns: Vec, } impl<'a> DeclaredKeys<'a> { @@ -201,6 +207,7 @@ 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); // A package with no routes of its own, whose names are registered // by the host application, cannot be judged: the valid set is @@ -363,6 +370,15 @@ impl<'a> DeclaredKeys<'a> { Vec::new() }; + let (drupal_routes, drupal_route_patterns) = if has_drupal_route { + ( + backend.symfony_declared_names(crate::symbol_map::StringKeyKind::DrupalRoute), + backend.drupal_route_patterns(), + ) + } else { + (Vec::new(), Vec::new()) + }; + Self { backend, routes, @@ -386,6 +402,8 @@ impl<'a> DeclaredKeys<'a> { twig_callables, messenger_buses, yii_routes, + drupal_routes, + drupal_route_patterns, } } @@ -468,6 +486,7 @@ impl<'a> DeclaredKeys<'a> { .map(|exists| (exists, "view", "invalid_yii_view")), CheckedStringKind::YiiRoute => self.judge_yii_route(key), CheckedStringKind::YiiRouteArgument => self.judge_yii_route_argument(key), + CheckedStringKind::DrupalRoute => self.judge_drupal_route(key), } } @@ -509,6 +528,51 @@ impl<'a> DeclaredKeys<'a> { Some((valid, "translation key", "invalid_symfony_translation")) } + /// A Drupal route is declared by a routing file, a route attribute, or + /// a route builder that writes its name out. Those a route builder + /// builds from parts, as core's entity route providers build + /// `entity..canonical`, and those Views builds for a display + /// (`view..`) cannot be told missing, and a project + /// whose routing files were not read declares none at all. + /// A route attribute's `Class::method` alias names a method, which has + /// to exist. + fn judge_drupal_route(&self, key: &str) -> Verdict { + const VERDICT: (&str, &str) = ("route", "invalid_drupal_route"); + if self.drupal_routes.is_empty() || crate::drupal::is_views_route_name(key) { + return None; + } + if key.contains('\\') { + let (class, method) = match key.split_once("::") { + Some((class, method)) => (class, Some(method)), + None => (key, None), + }; + let exists = self.backend.find_or_load_class(class).is_some_and(|class| { + method.is_none_or(|method| { + class + .methods + .iter() + .any(|candidate| candidate.name.eq_ignore_ascii_case(method)) + }) + }); + return Some((exists, VERDICT.0, VERDICT.1)); + } + if self + .drupal_routes + .binary_search_by(|name| name.as_str().cmp(key)) + .is_ok() + { + return Some((true, VERDICT.0, VERDICT.1)); + } + if self + .drupal_route_patterns + .iter() + .any(|pattern| crate::drupal::matches_route_pattern(pattern, key)) + { + return None; + } + Some((false, VERDICT.0, VERDICT.1)) + } + /// 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. @@ -838,6 +902,12 @@ impl Backend { { return None; } + // Test code passes invented route names to mocks. + if *kind == crate::symbol_map::StringKeyKind::DrupalRoute + && self.is_drupal_test_code(uri) + { + return None; + } Some(( CheckedStringKind::of(kind)?, key.as_str(), diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 5237a8fef..024b22f0a 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -136,6 +136,27 @@ pub(crate) fn is_url_class(class: &str) -> bool { .eq_ignore_ascii_case("Drupal\\Core\\Url") } +/// Whether the route name `name` has the shape `pattern`, in which each `*` +/// stands for any run of characters. +pub(crate) fn matches_route_pattern(pattern: &str, name: &str) -> bool { + let mut parts = pattern.split('*'); + let first = parts.next().unwrap_or_default(); + let Some(mut rest) = name.strip_prefix(first) else { + return false; + }; + let parts: Vec<&str> = parts.collect(); + let Some((last, middle)) = parts.split_last() else { + return rest.is_empty(); + }; + for part in middle { + match rest.find(part) { + Some(at) => rest = &rest[at + part.len()..], + None => return false, + } + } + rest.len() >= last.len() && rest.ends_with(last) +} + /// Whether `path` is a file Drupal reads declarations from. pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool { path.file_name() @@ -145,6 +166,17 @@ pub(crate) fn is_declaration_file(path: &Path, web_root: &Path) -> bool { && !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 { + let mut parts = name.split('.'); + parts.next() == Some("view") + && matches!( + (parts.next(), parts.next(), parts.next()), + (Some(view), Some(display), None) if !view.is_empty() && !display.is_empty() + ) +} + /// Whether `path` lies under a `tests` folder of the web root: it belongs to /// a test module, which no real site enables. Roughly half of core's /// declaration files are. @@ -255,6 +287,34 @@ impl Backend { found } + /// The shapes of the route names the project's route builders give the + /// routes they build from parts, sorted. + pub(crate) fn drupal_route_patterns(&self) -> Vec { + let mut patterns = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + patterns.extend(map.drupal_route_patterns.iter().cloned()); + }); + patterns.sort_unstable(); + patterns.dedup(); + patterns + } + + /// Whether `uri` is test code of a Drupal project: a file under a + /// `tests` folder of one of its web roots. + pub(crate) fn is_drupal_test_code(&self, uri: &str) -> bool { + if !self.is_drupal_project() { + return false; + } + let Some(path) = crate::util::uri_to_path(uri) else { + return false; + }; + self.drupal + .web_roots + .read() + .iter() + .any(|root| path.starts_with(root) && is_test_fixture(&path, root)) + } + /// Whether `uri` belongs to a test module of a Drupal project and is not /// open: what it declares is left out, since no real site enables it. pub(crate) fn is_closed_drupal_test_fixture(&self, uri: &str) -> bool { diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index d5a45b2bc..f1ddc411c 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -321,3 +321,29 @@ fn a_namespace_a_vendor_package_shares_is_renamed_outside_drupal() { assert!(edit.contains("src/Shared/Mine.php"), "{edit}"); assert!(!edit.contains("vendor/acme"), "{edit}"); } + +#[test] +fn route_patterns_match_any_run_for_each_star() { + use super::matches_route_pattern; + assert!(matches_route_pattern( + "search.view_*", + "search.view_node_search" + )); + assert!(matches_route_pattern( + "*.bc", + "entity.block_content.edit_form.bc" + )); + assert!(matches_route_pattern( + "layout_builder.*.view", + "layout_builder.overrides.node.view" + )); + assert!(matches_route_pattern("acme.*", "acme.")); + assert!(!matches_route_pattern("search.view_*", "search.help_x")); + assert!(!matches_route_pattern( + "layout_builder.*.view", + "layout_builder.overrides.node.edit" + )); + assert!(!matches_route_pattern("a*a", "a")); + assert!(matches_route_pattern("exact", "exact")); + assert!(!matches_route_pattern("exact", "exactly")); +} diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index e8156d9ce..4cb406394 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -484,3 +484,201 @@ async fn routing_defaults_navigate_to_entity_types_services_and_methods() { ); } } + +/// The unknown-route messages the diagnostics of `path` report. +async fn unknown_routes(files_in: &[(&str, &str)], path: &str) -> Vec { + let (backend, dir) = workspace(&files(files_in)).await; + let content = files_in + .iter() + .find(|(file, _)| *file == path) + .map(|(_, content)| *content) + .unwrap(); + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri_for(&dir, path).as_str(), content, &mut diagnostics); + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(tower_lsp_server::gen_lsp_types::Code::String(code)) + if code == "invalid_drupal_route" + ) + }) + .map(|diagnostic| crate::common::message_text(&diagnostic.message).to_string()) + .collect() +} + +const CHECKED: &str = r#"id(); + if ($canonical_route = $this->getCanonicalRoute($entity_type)) { + $collection->add("entity.{$entity_type_id}.canonical", $canonical_route); + } + if ($edit_route = $this->getEditFormRoute($entity_type)) { + $collection->add("entity.{$entity_type_id}.edit_form", $edit_route); + } + return $collection; + } + + protected function getCanonicalRoute($entity_type): ?Route { + return new Route('/canonical'); + } + + protected function getEditFormRoute($entity_type): ?Route { + return new Route('/edit'); + } +} +"#; + +const ENTITY_AND_VIEWS: &str = r#".`: a name of neither shape is +/// reported. +#[tokio::test] +async fn entity_and_views_routes_are_judged_by_their_shape() { + let dir = tempfile::tempdir().unwrap(); + for (path, content) in [ + (".git/HEAD", "ref: refs/heads/main\n"), + (".gitignore", "/web/core/\n"), + ( + "web/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php", + ENTITY_ROUTE_PROVIDER, + ), + ] { + let full = dir.path().join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, content).unwrap(); + } + let path = "web/modules/custom/acme/src/EntityAndViews.php"; + let (backend, dir) = workspace_in( + dir, + &files(&[(ROUTING_PATH, ROUTING), (path, ENTITY_AND_VIEWS)]), + ) + .await; + let php = uri_for(&dir, path); + // Finding references indexes the workspace, core's route providers + // included. + references_at( + &backend, + &php, + position_in(ENTITY_AND_VIEWS, "'entity.nod'", 3), + ) + .await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(php.as_str(), ENTITY_AND_VIEWS, &mut diagnostics); + let reported: Vec<&str> = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(tower_lsp_server::gen_lsp_types::Code::String(code)) + if code == "invalid_drupal_route" + ) + }) + .map(|diagnostic| crate::common::message_text(&diagnostic.message)) + .collect(); + assert_eq!( + reported, + [ + "Unknown route: 'entity'", + "Unknown route: 'entity.nod'", + "Unknown route: 'view'", + "Unknown route: 'view.content'", + ] + ); +} + +/// Test code passes invented route names to mocks, and a project whose +/// routing files were not read has nothing to judge against. +#[tokio::test] +async fn test_code_and_unread_projects_are_not_judged() { + let test_path = "web/modules/custom/acme/tests/src/Unit/CheckedTest.php"; + assert!( + unknown_routes(&[(ROUTING_PATH, ROUTING), (test_path, CHECKED)], test_path) + .await + .is_empty() + ); + let path = "web/modules/custom/acme/src/Checked.php"; + assert!(unknown_routes(&[(path, CHECKED)], path).await.is_empty()); +} From d14c491d64178ecfd87206931d7f7205d265bedb Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Mon, 2 Nov 2026 12:54:39 +0100 Subject: [PATCH 07/18] Show Drupal route names in the playground and the changelog The playground's controller redirects and links to its route, a menu link and the greeting template name it too, and the assertions check that every name they use is one the routing file declares. DR5 leaves the backlog; the route parameters it pointed at are filed as their own item. Assisted-by: claude-code/claude-opus-5-5 --- docs/CHANGELOG.md | 1 + docs/todo/drupal.md | 7 +++--- examples/drupal/README.md | 10 ++++++++ examples/drupal/assertions.php | 17 +++++++++++++ .../modules/custom/acme/acme.links.menu.yml | 8 ++++++ .../src/Controller/GreetingController.php | 25 +++++++++++++++++++ .../acme/templates/acme-greeting.html.twig | 2 ++ 7 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 examples/drupal/web/modules/custom/acme/acme.links.menu.yml diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3eafa211a..cd91f8617 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Symfony services that bundles and the container builder declare.** The service IDs and parameters an installed bundle declares in its own configuration complete, jump to their declaration, and type `$container->get()`, and so do the ones a kernel, an extension, or a compiler pass declares with `register()`, `setAlias()`, `setDefinition()`, `setParameter()`, or `getKernelParameters()`. A parameter set under a name built at runtime, like `'app.' . $key`, leaves the names it can produce unreported. A configurator's `parent()` and the `%name%` placeholders in its arguments are read as the service and parameter they name. - **Drupal services.** In a Drupal project, the service IDs and container parameters that core, contributed, and custom modules declare in their `*.services.yml` complete, jump to their declaration, and list their references wherever a services file, `$container->get()`, or `\Drupal::service()` names them, and a lookup is typed by the class the service is built from, or by the class or interface a lookup names by `::class`. Rename leaves core and contributed extensions alone, as it does vendor code. Contributed by @giuseppe-esposito-87. - **Drupal entity types.** In a Drupal project, an entity type ID declared by an entity class's attribute or annotation completes, jumps to its declaration, and lists its references wherever the entity type manager is asked for it, and `getStorage()` is typed as that entity type's storage, so `load()`, `loadMultiple()`, `create()`, and the other loads return the entity class. An entity query started by `\Drupal::entityQuery()` or a storage's `getQuery()` gives the IDs of that entity type, or their number after `count()`. Contributed by @giuseppe-esposito-87. +- **Drupal route names.** In a Drupal project, a route declared by a routing file, a `#[Route]` attribute, or a route builder completes, jumps to its declaration, and lists its references wherever code, a links file, or a template names it, from `Url::fromRoute()`, `$this->redirect()`, and `$form_state->setRedirect()` to `route_name:` and `path()`. A route name nothing declares is reported. Contributed by @giuseppe-esposito-87. - **Symfony route names.** In a Symfony project, a route name declared by a `#[Route]` attribute, the routing YAML or XML, or a PHP routing configurator completes, jumps to its declaration, and lists its references wherever a controller or a Twig template builds a URL to it, from `generateUrl()` and `redirectToRoute()` to `path()` and `url()`. Names are followed through the imports that load them, bundles' own routing files included, along with the ones Symfony derives from a route, such as `contact.en` for a localized one. A route name nothing declares is reported, unless the router is configured to load its routes some other way. Contributed by @sidux. - **Symfony translations.** In a Symfony project, a translation key declared in a YAML or XLIFF catalogue completes, jumps to its entry in every locale, and lists its references wherever PHP or a Twig template translates it, from `trans()`, `TranslatableMessage`, and `t()` to the `trans` filter. A key is looked up in the domain the call names, and a domain jumps to its catalogues. A key spelled like one (`demo.title`) that nothing declares is reported, in a domain the project's own catalogues hold and no installed bundle ships. Contributed by @sidux. - **Symfony Twig templates.** In a Symfony project, a template name completes, jumps to its file, and lists its references wherever a controller, an email, or another template names it, from `render()` and `#[Template]` to `extends` and `include`. A template the application's `templates/` directory does not hold is reported, with a quick fix that creates it, unless the project has a Twig loader of its own that could serve it. Contributed by @sidux. diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 9a9f4dd4f..4bcb985a5 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -3,9 +3,10 @@ Known gaps and missing features in PHPantom's Drupal support. The web root is detected, core, contrib, and the Drupal file extensions (`.module`, `.install`, `.theme`, …) are indexed past `.gitignore`, and -the services the `*.services.yml` files declare and the entity types -entity classes declare are read. Nothing reads Drupal's other -declarations yet: fields, routes, or hooks. +the services the `*.services.yml` files declare, the entity types +entity classes declare, and the routes the routing files, route +attributes, and route builders declare are read. Nothing reads Drupal's +other declarations yet: fields or hooks. The requests in [#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 1cb635f2a..64166e174 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -40,6 +40,16 @@ 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. +- 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 + definition of `'acme.greeting'` in `greetTheWorld()` or `worldLink()`, + which lands on its key in `acme.routing.yml`, or find its references from + there, `acme.links.menu.yml` and the `path()` in + `templates/acme-greeting.html.twig` included. Complete a route name inside + `redirect('')`, `Url::fromRoute('')`, or after `route_name:` in a links + file; core's routes are offered too. A route name nothing declares is + reported. ## What the Symfony support already reads diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index 16113fe38..d4367ba9e 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -32,6 +32,8 @@ use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\Extension\InfoParser; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Link; +use Drupal\Core\Url; $passed = 0; $failed = 0; @@ -119,6 +121,21 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string check('The _controller string names a public method', (new ReflectionMethod($controllerClass, $controllerMethod))->isPublic()); check('acme.greeting declares the {name} the controller takes', str_contains($routes['acme.greeting']['path'], '{name}') && (new ReflectionMethod(GreetingController::class, 'greet'))->getParameters()[0]->getName() === 'name'); check('GreetingController inherits the helpers of ControllerBase', is_subclass_of(GreetingController::class, ControllerBase::class)); +check('ControllerBase::redirect(), which greetTheWorld() calls, takes a route name first', (new ReflectionMethod(ControllerBase::class, 'redirect'))->getParameters()[0]->getName() === 'route_name'); +check('Url::fromRoute() takes a route name first', (new ReflectionMethod(Url::class, 'fromRoute'))->getParameters()[0]->getName() === 'route_name'); +check('Link::createFromRoute() takes a route name second', (new ReflectionMethod(Link::class, 'createFromRoute'))->getParameters()[1]->getName() === 'route_name'); + +// Every route the demo names is one the routing file declares. +$controllerSource = (string) file_get_contents("$module/src/Controller/GreetingController.php"); +preg_match_all("/(?:redirect|fromRoute)\\('([^']+)'|createFromRoute\\([^,]+, '([^']+)'/", $controllerSource, $named); +$namedInPhp = array_values(array_filter(array_merge($named[1], $named[2]))); +check('GreetingController names routes in redirect(), fromRoute() and createFromRoute()', count($namedInPhp) === 3); +check('every route GreetingController names is declared', array_diff($namedInPhp, array_keys($routes)) === []); +$menuLinks = Yaml::decode((string) file_get_contents("$module/acme.links.menu.yml")); +check('the acme.greeting menu link points at a declared route', isset($routes[$menuLinks['acme.greeting']['route_name'] ?? ''])); +check("the menu link's parameters are the ones the route's path declares", array_keys($menuLinks['acme.greeting']['route_parameters'] ?? []) === ['name']); +preg_match_all("/path\\('([^']+)'/", (string) file_get_contents("$module/templates/acme-greeting.html.twig"), $namedInTwig); +check('the acme_greeting template links to a declared route', $namedInTwig[1] !== [] && array_diff($namedInTwig[1], array_keys($routes)) === []); // ─── Entity types ─────────────────────────────────────────────────────────── diff --git a/examples/drupal/web/modules/custom/acme/acme.links.menu.yml b/examples/drupal/web/modules/custom/acme/acme.links.menu.yml new file mode 100644 index 000000000..b455fb654 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/acme.links.menu.yml @@ -0,0 +1,8 @@ +# Try: go to the definition of acme.greeting, or complete a route name after +# route_name:. +acme.greeting: + title: 'Greeting' + route_name: acme.greeting + route_parameters: + name: 'World' + menu_name: main 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 cc051851f..f4a88dd3a 100644 --- a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php +++ b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php @@ -6,6 +6,9 @@ use Drupal\acme\Greeter; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Link; +use Drupal\Core\Url; +use Symfony\Component\HttpFoundation\RedirectResponse; /** * Shows the greeting for the acme.greeting route. @@ -31,4 +34,26 @@ public function greet(string $name): array '#greeting' => $this->greeter->greet($name), ]; } + + /** + * Sends the visitor to the greeting for the world. + */ + public function greetTheWorld(): RedirectResponse + { + // Try: go to the definition of 'acme.greeting', which lands on its key + // in acme.routing.yml, or complete a route name inside redirect(''). + return $this->redirect('acme.greeting', ['name' => 'World']); + } + + /** + * A link to the greeting for the world. + */ + public function worldLink(): Link + { + // Try: find the references of 'acme.greeting' to see every place that + // links or redirects to it, the links and Twig files included. + $url = Url::fromRoute('acme.greeting', ['name' => 'World']); + + return Link::createFromRoute($this->t('Greet the world'), 'acme.greeting', $url->getRouteParameters()); + } } diff --git a/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig b/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig index e56f8bac3..7ef3e48cf 100644 --- a/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig +++ b/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig @@ -10,4 +10,6 @@ * acme_theme adds in preprocess. */ #} +{# Try: go to the definition of 'acme.greeting' in path(). #} {{ greeting }}

+{{ 'Greet the world'|t }} From 6589c5af635a0c535ce496363c235e867b1c3250 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Mon, 2 Nov 2026 13:15:15 +0100 Subject: [PATCH 08/18] Drupal routes: read every route a builder or controller declares Route builders in traits, routes held in a variable or returned in an array literal, and builder files that import the routing component as a group are read. A name a builder builds with no written-out text in front, such as "$name.$method", stands for the extension's own routes instead of any name with a dot in it, so the unknown-route check keeps judging every other route. A route on an abstract controller is declared, and a Class::method or Class route name is accepted only when attribute route discovery gives a controller that alias. Assisted-by: claude-code/claude-opus-5-5 --- src/diagnostics/laravel_string_keys.rs | 44 ++-- src/drupal/mod.rs | 12 + src/mem_audit.rs | 9 +- src/symbol_map/extraction/class_like.rs | 3 +- src/symbol_map/extraction/drupal.rs | 277 ++++++++++++++++++++---- src/symbol_map/extraction/mod.rs | 3 + src/symbol_map/map.rs | 5 + src/symfony/mod.rs | 3 +- tests/integration/drupal_routes.rs | 10 +- 9 files changed, 288 insertions(+), 78 deletions(-) diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index cfea4c812..471171e0d 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -173,6 +173,9 @@ struct DeclaredKeys<'a> { /// The shapes of the route names Drupal's route builders give the /// routes they build from parts (`search.view_*`). drupal_route_patterns: Vec, + /// The `Class::method` and `Class` names Drupal gives attribute routes, + /// sorted. + drupal_route_aliases: Vec, } impl<'a> DeclaredKeys<'a> { @@ -370,13 +373,14 @@ impl<'a> DeclaredKeys<'a> { Vec::new() }; - let (drupal_routes, drupal_route_patterns) = if has_drupal_route { + let (drupal_routes, drupal_route_patterns, drupal_route_aliases) = if has_drupal_route { ( backend.symfony_declared_names(crate::symbol_map::StringKeyKind::DrupalRoute), backend.drupal_route_patterns(), + backend.drupal_route_aliases(), ) } else { - (Vec::new(), Vec::new()) + (Vec::new(), Vec::new(), Vec::new()) }; Self { @@ -404,6 +408,7 @@ impl<'a> DeclaredKeys<'a> { yii_routes, drupal_routes, drupal_route_patterns, + drupal_route_aliases, } } @@ -534,33 +539,19 @@ impl<'a> DeclaredKeys<'a> { /// `entity..canonical`, and those Views builds for a display /// (`view..`) cannot be told missing, and a project /// whose routing files were not read declares none at all. - /// A route attribute's `Class::method` alias names a method, which has - /// to exist. + /// A `Class::method` or `Class` name is one of the aliases attribute + /// route discovery gives a controller's routes. fn judge_drupal_route(&self, key: &str) -> Verdict { const VERDICT: (&str, &str) = ("route", "invalid_drupal_route"); if self.drupal_routes.is_empty() || crate::drupal::is_views_route_name(key) { return None; } - if key.contains('\\') { - let (class, method) = match key.split_once("::") { - Some((class, method)) => (class, Some(method)), - None => (key, None), - }; - let exists = self.backend.find_or_load_class(class).is_some_and(|class| { - method.is_none_or(|method| { - class - .methods - .iter() - .any(|candidate| candidate.name.eq_ignore_ascii_case(method)) - }) - }); - return Some((exists, VERDICT.0, VERDICT.1)); - } - if self - .drupal_routes - .binary_search_by(|name| name.as_str().cmp(key)) - .is_ok() - { + let declares = |names: &[String]| { + names + .binary_search_by(|name| name.as_str().cmp(key)) + .is_ok() + }; + if declares(&self.drupal_routes) || declares(&self.drupal_route_aliases) { return Some((true, VERDICT.0, VERDICT.1)); } if self @@ -869,6 +860,7 @@ impl Backend { let symbol_map = &ctx.symbol_map; let extra = self.typed_receiver_spans_for(uri, symbol_map); let morph_columns = self.morph_column_spans_for(uri, symbol_map); + let is_drupal_test_code = self.is_drupal_test_code(uri); let key_spans: Vec = symbol_map .spans .iter() @@ -903,9 +895,7 @@ impl Backend { return None; } // Test code passes invented route names to mocks. - if *kind == crate::symbol_map::StringKeyKind::DrupalRoute - && self.is_drupal_test_code(uri) - { + if *kind == crate::symbol_map::StringKeyKind::DrupalRoute && is_drupal_test_code { return None; } Some(( diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 024b22f0a..2eded042d 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -299,6 +299,18 @@ impl Backend { patterns } + /// The `Class::method` and `Class` names Drupal's attribute route + /// discovery gives the project's controller routes, sorted. + pub(crate) fn drupal_route_aliases(&self) -> Vec { + let mut aliases = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + aliases.extend(map.drupal_route_aliases.iter().cloned()); + }); + aliases.sort_unstable(); + aliases.dedup(); + aliases + } + /// Whether `uri` is test code of a Drupal project: a file under a /// `tests` folder of one of its web roots. pub(crate) fn is_drupal_test_code(&self, uri: &str) -> bool { diff --git a/src/mem_audit.rs b/src/mem_audit.rs index cffcbb6dc..383be9683 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1403,8 +1403,13 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { ); sym.add(sm.entity_types.capacity() * size_of::()); sym.add(sm.drupal_route_patterns.capacity() * size_of::()); - for pattern in &sm.drupal_route_patterns { - sym.add(pattern.capacity()); + sym.add(sm.drupal_route_aliases.capacity() * size_of::()); + for name in sm + .drupal_route_patterns + .iter() + .chain(&sm.drupal_route_aliases) + { + sym.add(name.capacity()); } sym.add(sm.routes.capacity() * size_of::()); for route in &sm.routes { diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index 50328c0e4..35973ab33 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -25,7 +25,7 @@ pub(super) fn extract_from_class<'a>(class: &'a Class<'a>, ctx: &mut ExtractionC emit_yii_route_argument_spans(class, ctx); emit_drupal_entity_type_spans(class, ctx); emit_drupal_route_attribute_spans(class, ctx); - emit_drupal_route_builder_spans(class, ctx); + emit_drupal_route_builder_spans(&class.members, class.name.span.start.offset, ctx); if let Some(ref extends) = class.extends { for ident in extends.types.iter() { @@ -199,6 +199,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); 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 f2d961967..60719a4b8 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -111,20 +111,30 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( /// namespace. A route on a method is named by its `name:`, after the name /// of the class's own first `#[Route]`; one without a name is rejected. A /// class with no routes on its methods and an `__invoke()` is a single -/// action, and its own `#[Route]`s are its routes. +/// action, and its own `#[Route]`s are its routes. A method with exactly +/// one route is also reachable as `Class::method`, and a single-action +/// class with one route as `Class` and `Class::__invoke`: those aliases +/// have no text of their own, so they are kept beside the spans. +/// +/// Drupal skips an abstract class but reads the routes its subclasses +/// inherit, so an abstract controller's routes are declared where they are +/// written, under its own prefix, and without aliases, whose class name +/// would be the subclass's. pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( class: &Class<'_>, ctx: &mut ExtractionCtx<'_>, ) { - if !ctx.drupal || class.modifiers.contains_abstract() { + if !ctx.drupal { return; } - let is_controller = ctx + let Some(fqn) = ctx .resolved_name_at(class.name.span.start.offset) - .is_some_and(is_controller_class); - if !is_controller { + .map(|fqn| fqn.trim_start_matches('\\')) + .filter(|fqn| is_controller_class(fqn)) + else { return; - } + }; + let is_abstract = class.modifiers.contains_abstract(); // Drupal's discovery reads attributes only, never `@Route` annotations. let class_routes = route_declarations(&class.attribute_lists, None, class, ctx); let prefix = match class_routes.first().map(|route| &route.name) { @@ -133,15 +143,26 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( }; let is_named = |route: &&RouteDeclaration| matches!(route.name, RouteName::Literal(..)); let mut spans = Vec::new(); + let mut aliases = Vec::new(); + let mut route_count = 0usize; let mut any_method_route = false; let mut invokable = false; + let mut fqcn_alias = false; for member in class.members.iter() { let ClassLikeMember::Method(method) = member else { continue; }; - invokable |= bytes_to_str(method.name.value).eq_ignore_ascii_case("__invoke"); + let method_name = bytes_to_str(method.name.value); + let is_invoke = method_name.eq_ignore_ascii_case("__invoke"); + invokable |= is_invoke; let routes = route_declarations(&method.attribute_lists, None, class, ctx); any_method_route |= !routes.is_empty(); + let named = routes.iter().filter(is_named).count(); + route_count += named; + fqcn_alias |= is_invoke && named > 0; + if named == 1 { + aliases.push(format!("{fqn}::{method_name}")); + } for route in routes.iter().filter(is_named) { push_route_spans( route, @@ -153,11 +174,24 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( ); } } - if !any_method_route && invokable { + if !any_method_route && invokable && !is_abstract { for route in class_routes.iter().filter(is_named) { + route_count += 1; + fqcn_alias = true; push_route_spans(route, "", None, StringKeyKind::DrupalRoute, &[], &mut spans); } } + if fqcn_alias && route_count == 1 { + aliases.push(fqn.to_string()); + aliases.push(format!("{fqn}::__invoke")); + } + if !is_abstract { + for alias in aliases { + if !ctx.drupal_route_aliases.contains(&alias) { + ctx.drupal_route_aliases.push(alias); + } + } + } ctx.spans.extend(spans); } @@ -178,41 +212,61 @@ enum BuiltName { Pattern(String), } -/// Record the routes a route builder adds: a class whose file uses -/// Symfony's `Route` or `RouteCollection`, as the classes a routing file's -/// `route_callbacks` name, route subscribers, and entity route providers -/// do. `$collection->add(NAME, $route)` and `$routes[NAME] = new Route(…)` -/// add the route `NAME`: written out, it is declared; built from parts, its -/// shape is kept as a pattern, and a name with no part written out stands -/// for any route under the extension's own prefix. +/// Record the routes a route builder adds: a class or trait whose file +/// uses Symfony's routing component, as the classes a routing file's +/// `route_callbacks` name, route subscribers, entity route providers, and +/// the traits they share do. `$collection->add(NAME, $route)`, +/// `$routes[NAME] = new Route(…)` (or a variable holding one), and an +/// array literal's `NAME => new Route(…)` add the route `NAME`: written +/// out, it is declared; built from parts, its shape is kept as a pattern. +/// A shape that does not start with written-out text stands for any route +/// under the extension's own prefix, which is where Drupal's own builders +/// put such routes. pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( - class: &Class<'_>, + members: &Sequence<'_, ClassLikeMember<'_>>, + name_offset: u32, ctx: &mut ExtractionCtx<'_>, ) { - if !ctx.drupal || !ctx.content.contains("Symfony\\Component\\Routing\\Route") { + if !ctx.drupal || !ctx.content.contains("Symfony\\Component\\Routing\\") { return; } let mut found = BuilderSites::default(); - for member in class.members.iter() { + for member in members.iter() { if let ClassLikeMember::Method(method) = member { mago_syntax::walker::Walker::walk_method(&BuilderWalker, method, &mut found); } } - let mut names: Vec<&Expression<'_>> = found.added; - for (name, class_expr) in found.assigned { - let Expression::Identifier(identifier) = class_expr else { - continue; + let is_route_class = |class: &Expression<'_>| { + let Expression::Identifier(identifier) = class else { + return false; }; - let resolved = ctx - .resolved_name_at(identifier.span().start.offset) - .unwrap_or_else(|| bytes_to_str(identifier.value())); - if resolved + ctx.resolved_name_at(identifier.span().start.offset) + .unwrap_or_else(|| bytes_to_str(identifier.value())) .trim_start_matches('\\') .eq_ignore_ascii_case("Symfony\\Component\\Routing\\Route") - { - names.push(name); + }; + let route_variables: Vec<&[u8]> = found + .route_variables + .iter() + .filter(|(_, class)| is_route_class(class)) + .map(|(name, _)| *name) + .collect(); + let holds_route = |value: &Expression<'_>| match value { + Expression::Instantiation(instantiation) => is_route_class(instantiation.class), + Expression::Variable(Variable::Direct(variable)) => { + route_variables.contains(&variable.name) } - } + _ => false, + }; + let mut names: Vec<&Expression<'_>> = found.added; + names.extend( + found + .keyed + .into_iter() + .filter(|(_, value)| holds_route(value)) + .map(|(name, _)| name), + ); + let extension = ctx.resolved_name_at(name_offset).and_then(extension_of); for name in names { match built_name(name, ctx.content) { BuiltName::Literal(key, start, end) => ctx.spans.push(SymbolSpan { @@ -226,14 +280,11 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( }, }), BuiltName::Pattern(pattern) => { - let pattern = if pattern.bytes().all(|byte| byte == b'*') { - let Some(prefix) = ctx - .resolved_name_at(class.name.span.start.offset) - .and_then(extension_of) - else { + let pattern = if pattern.starts_with('*') { + let Some(extension) = extension else { continue; }; - format!("{prefix}.*") + format!("{extension}.*") } else { pattern }; @@ -245,14 +296,17 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( } } -/// The calls and assignments of a route builder's methods that may add a -/// route. +/// The calls, assignments, and array entries of a route builder's methods +/// that may add a route. #[derive(Default)] struct BuilderSites<'ast, 'arena> { /// The first argument of each `->add()` with two or more arguments. added: Vec<&'ast Expression<'arena>>, - /// The index of each `$routes[NAME] = new Class(…)`, with the class. - assigned: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, + /// The key and the value of each `$routes[KEY] = VALUE` and each + /// `KEY => VALUE` array entry. + keyed: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, + /// Each variable assigned a `new Class(…)`, with the class. + route_variables: Vec<(&'arena [u8], &'ast Expression<'arena>)>, } struct BuilderWalker; @@ -282,10 +336,41 @@ impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BuilderSites<'ast, assignment: &'ast Assignment<'arena>, found: &mut BuilderSites<'ast, 'arena>, ) { - if let Expression::ArrayAccess(access) = assignment.lhs - && let Expression::Instantiation(instantiation) = assignment.rhs - { - found.assigned.push((access.index, instantiation.class)); + match (assignment.lhs, assignment.rhs) { + (Expression::ArrayAccess(access), value) => found.keyed.push((access.index, value)), + ( + Expression::Variable(Variable::Direct(variable)), + Expression::Instantiation(instantiation), + ) => found + .route_variables + .push((variable.name, instantiation.class)), + _ => {} + } + } + + fn walk_in_array(&self, array: &'ast Array<'arena>, found: &mut BuilderSites<'ast, 'arena>) { + push_keyed_entries(array.elements.iter(), found); + } + + fn walk_in_legacy_array( + &self, + array: &'ast LegacyArray<'arena>, + found: &mut BuilderSites<'ast, 'arena>, + ) { + push_keyed_entries(array.elements.iter(), found); + } +} + +/// Record each `KEY => VALUE` entry of an array literal. +fn push_keyed_entries<'ast, 'arena>( + elements: impl Iterator>, + found: &mut BuilderSites<'ast, 'arena>, +) where + 'arena: 'ast, +{ + for element in elements { + if let ArrayElement::KeyValue(entry) = element { + found.keyed.push((entry.key, entry.value)); } } } @@ -650,9 +735,111 @@ class SearchPageRoutes { declared.sort(); patterns.sort(); assert_eq!(declared, ["search.literal", "search.view"]); + // `"{$name}.bc"` and `$name` start with no written-out text, so + // they stand for the extension's own routes. + assert_eq!(patterns, ["search.*", "search.help_*", "search.view_*"]); + } + + #[test] + fn routes_held_in_variables_array_literals_and_traits_are_built() { + let php = r#"add("$prefix.view", $route); + $options['not_a_route'] = $prefix; + return ['layout_builder.literal' => new Route('/y'), 'not_either' => [1]]; + } +} +"#; + let (mut declared, patterns) = built(php); + declared.sort(); + assert_eq!(declared, ["layout_builder.held", "layout_builder.literal"]); + assert_eq!(patterns, ["layout_builder.*"]); + } + + /// The route names and aliases `php`'s controllers declare. + fn attribute_routes(php: &str) -> (Vec, 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)); + let map = crate::symbol_map::extraction::extract_symbol_map_for_index( + program, + php, + &names, + crate::symbol_map::extraction::Frameworks { + symfony: false, + service_container: true, + drupal: true, + twig: false, + }, + None, + ); + let mut declared: Vec = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key, + is_write: true, + .. + } => Some(key.clone()), + _ => None, + }) + .collect(); + declared.sort(); + let mut aliases = map.drupal_route_aliases.clone(); + aliases.sort(); + (declared, aliases) + } + + #[test] + fn controllers_alias_single_routes_by_class_and_method() { + let php = r#" { /// The entity types the file's entity classes declare. entity_types: Vec, drupal_route_patterns: Vec, + drupal_route_aliases: Vec, /// Current conditional nesting depth (if/else, switch, while, for, etc.). /// Incremented when entering a conditional block, decremented when leaving. cond_nesting_depth: u16, @@ -479,6 +480,7 @@ fn extract_symbol_map_inner( container_values: Vec::new(), entity_types: Vec::new(), drupal_route_patterns: Vec::new(), + drupal_route_aliases: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, @@ -673,6 +675,7 @@ fn extract_symbol_map_inner( container_values: ctx.container_values, entity_types: ctx.entity_types, drupal_route_patterns: ctx.drupal_route_patterns, + drupal_route_aliases: ctx.drupal_route_aliases, routes: ctx.routes, declares_twig_loader: ctx.declares_twig_loader, route_imports: ctx diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index a076c5913..de3a3736f 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -312,6 +312,11 @@ pub(crate) struct SymbolMap { /// written out (`search.view_*`). Empty for every file that builds /// none. pub drupal_route_patterns: Vec, + /// The `Class::method` and `Class` names Drupal's attribute route + /// discovery gives the file's controller routes besides their own, + /// which no text of the file spells out. Empty for every file that + /// declares none. + pub drupal_route_aliases: Vec, /// The routes the file declares, in source order. Empty for every file /// that declares none. pub routes: Vec, diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index 0d4eb7068..ee65d40c8 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -148,7 +148,8 @@ fn declares_symfony_keys(map: &SymbolMap, enabled: impl Fn(StringKeyKind) -> boo || (!map.routes.is_empty() && enabled(StringKeyKind::YiiRoute)) || (!map.route_imports.is_empty() && enabled(StringKeyKind::SymfonyRoute)) || (map.declares_twig_loader && enabled(StringKeyKind::TwigTemplate)) - || (!map.drupal_route_patterns.is_empty() && enabled(StringKeyKind::DrupalRoute)) + || ((!map.drupal_route_patterns.is_empty() || !map.drupal_route_aliases.is_empty()) + && enabled(StringKeyKind::DrupalRoute)) || map.spans.iter().any(|span| { matches!( &span.kind, diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index 4cb406394..080b2b31c 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -519,8 +519,9 @@ final class Checked { Url::fromRoute('acme.greting'); Url::fromRoute('acme.dyn_42'); Url::fromRoute('acme.built'); + Url::fromRoute('Drupal\acme\Controller\AboutController'); + Url::fromRoute('Drupal\acme\Controller\NoteController::list'); Url::fromRoute('Drupal\acme\Routing\Routes::routes'); - Url::fromRoute('Drupal\acme\Routing\Routes::nope'); } } "#; @@ -546,6 +547,8 @@ async fn an_undeclared_route_name_is_reported() { let path = "web/modules/custom/acme/src/Checked.php"; let reported = unknown_routes( &[ + ROUTE_ATTRIBUTE, + (ATTRIBUTE_PATH, ATTRIBUTE_CONTROLLER), (ROUTING_PATH, ROUTING), (BUILDER_PATH, DYNAMIC_BUILDER), (path, CHECKED), @@ -553,11 +556,14 @@ async fn an_undeclared_route_name_is_reported() { path, ) .await; + // A route builder's own method is no route: only a controller method + // with one `#[Route]`, or a single-action controller, is aliased by its + // class name. assert_eq!( reported, [ "Unknown route: 'acme.greting'", - "Unknown route: 'Drupal\\acme\\Routing\\Routes::nope'", + "Unknown route: 'Drupal\\acme\\Routing\\Routes::routes'", ] ); } From 73d24429467cd9ee415df8fbdff80097f6bd4fab Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Mon, 2 Nov 2026 13:15:15 +0100 Subject: [PATCH 09/18] Drupal routes: complete angle-bracket routes in Twig and same-indent lists path(' and the other angle-bracket routes, and an appears_on list whose items sit at the key's indentation is read and completed. Assisted-by: claude-code/claude-opus-5-5 --- src/completion/symfony_string_keys.rs | 6 +++--- src/drupal/links.rs | 25 ++++++++++++++++++++----- src/symfony/routes.rs | 27 ++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/completion/symfony_string_keys.rs b/src/completion/symfony_string_keys.rs index a43a4261f..b22f5fc13 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -353,7 +353,7 @@ impl Backend { if !self.is_drupal_project() { return None; } - let start = crate::symfony::routes::twig_route_name_start(content, cursor)?; + let start = crate::symfony::routes::drupal_twig_route_name_start(content, cursor)?; return self.symfony_key_response( uri, StringKeyKind::DrupalRoute, @@ -395,7 +395,7 @@ impl Backend { (StringKeyKind::TwigTemplate, start) } else if let Some(start) = self .is_in_drupal_web_root(uri) - .then(|| crate::symfony::routes::twig_route_name_start(content, cursor)) + .then(|| crate::symfony::routes::drupal_twig_route_name_start(content, cursor)) .flatten() { // The templates in a Drupal web root name Drupal's routes, @@ -408,7 +408,7 @@ impl Backend { (StringKeyKind::SymfonyRoute, start) } else if let Some(start) = self .is_drupal_project() - .then(|| crate::symfony::routes::twig_route_name_start(content, cursor)) + .then(|| crate::symfony::routes::drupal_twig_route_name_start(content, cursor)) .flatten() { (StringKeyKind::DrupalRoute, start) diff --git a/src/drupal/links.rs b/src/drupal/links.rs index 309448a57..784ec7f75 100644 --- a/src/drupal/links.rs +++ b/src/drupal/links.rs @@ -26,8 +26,9 @@ pub(crate) fn links_route_spans(content: &str) -> Vec { } let indent = crate::framework::leading_spaces(semantic); let text = &semantic[indent..]; + // YAML lets a block list's items sit at its key's indentation. if let Some(list) = appears_on { - if indent > list + if indent >= list && let Some(item) = text.strip_prefix('-') { let item_start = line_start + indent + 1; @@ -113,14 +114,18 @@ pub(crate) fn links_route_name_start(content: &str, cursor: usize) -> Option bool { above .lines() .rev() .map(crate::symfony::resources::yaml_content_before_comment) .filter(|line| !line.trim().is_empty()) - .find(|line| crate::framework::leading_spaces(line) < indent) + .find(|line| { + let line_indent = crate::framework::leading_spaces(line); + line_indent < indent || (line_indent == indent && !line[line_indent..].starts_with('-')) + }) .is_some_and(|line| line.trim() == "appears_on:") } @@ -137,8 +142,13 @@ mod tests { #[test] fn route_keys_and_appears_on_items_read_routes() { - let yaml = "a:\n route_name: one\n base_route: 'two'\n parent_id: no\n appears_on:\n - three\n - \"four\" # comment\n parent: no\nb:\n appears_on: [five, 'six']\nroute_name: not_a_link\n"; - assert_eq!(routes(yaml), ["one", "two", "three", "four", "five", "six"]); + let yaml = "a:\n route_name: one\n base_route: 'two'\n parent_id: no\n appears_on:\n - three\n - \"four\" # comment\n parent: no\nb:\n appears_on: [five, 'six']\nc:\n appears_on:\n - seven\n - eight\n title: no\nroute_name: not_a_link\n"; + assert_eq!( + routes(yaml), + [ + "one", "two", "three", "four", "five", "six", "seven", "eight" + ] + ); } #[test] @@ -157,5 +167,10 @@ mod tests { assert_eq!(links_route_name_start(yaml, yaml.len()), None); let yaml = "a:\n menu_name:\n - x"; assert_eq!(links_route_name_start(yaml, yaml.len()), None); + let yaml = "a:\n appears_on:\n - one\n - tw"; + assert_eq!( + links_route_name_start(yaml, yaml.len()), + Some(yaml.len() - 2) + ); } } diff --git a/src/symfony/routes.rs b/src/symfony/routes.rs index 3902bedc2..fab6cffec 100644 --- a/src/symfony/routes.rs +++ b/src/symfony/routes.rs @@ -446,10 +446,28 @@ fn route_function_before(before: &str) -> Option<&str> { /// Where the route name a Twig `path('` or `url('` the cursor is in starts, /// when the name typed so far runs up to `cursor`. pub(crate) fn twig_route_name_start(content: &str, cursor: usize) -> Option { + twig_route_name_start_where(content, cursor, |byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-') + }) +} + +/// [`twig_route_name_start`] for a Drupal route name, which may be one of +/// the angle-bracketed routes such as ``. +pub(crate) fn drupal_twig_route_name_start(content: &str, cursor: usize) -> Option { + twig_route_name_start_where(content, cursor, |byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'<' | b'>') + }) +} + +fn twig_route_name_start_where( + content: &str, + cursor: usize, + is_name_byte: impl Fn(u8) -> bool, +) -> Option { let before = content.get(..cursor)?; let name_start = before .bytes() - .rposition(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))) + .rposition(|byte| !is_name_byte(byte)) .map_or(0, |index| index + 1); let before_name = before[..name_start].strip_suffix(['\'', '"'])?; let before_paren = before_name.trim_end().strip_suffix('(')?; @@ -587,6 +605,13 @@ mod tests { ); } + #[test] + fn drupal_twig_completion_reads_angle_brackets() { + let twig = "{{ path(' Date: Mon, 2 Nov 2026 13:27:07 +0100 Subject: [PATCH 10/18] Drupal routes: only an add() of a route declares one A route builder's add() declares a route only when its second argument is a new Route or a variable, so an add() on any other collection in the same file declares no route and does not stand for the extension's routes. Assisted-by: claude-code/claude-opus-5-5 --- src/symbol_map/extraction/drupal.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index 60719a4b8..bb51eaf83 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -258,7 +258,17 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( } _ => false, }; - let mut names: Vec<&Expression<'_>> = found.added; + // A route collection's `add()` takes the route second: a `new Route`, + // or a variable holding one, a parameter included, which the file + // alone cannot type. Any other `add()` adds something else. + let mut names: Vec<&Expression<'_>> = found + .added + .into_iter() + .filter(|(_, route)| { + holds_route(route) || matches!(route, Expression::Variable(Variable::Direct(_))) + }) + .map(|(name, _)| name) + .collect(); names.extend( found .keyed @@ -300,8 +310,8 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( /// that may add a route. #[derive(Default)] struct BuilderSites<'ast, 'arena> { - /// The first argument of each `->add()` with two or more arguments. - added: Vec<&'ast Expression<'arena>>, + /// The first two arguments of each `->add()` with two or more. + added: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, /// The key and the value of each `$routes[KEY] = VALUE` and each /// `KEY => VALUE` array entry. keyed: Vec<(&'ast Expression<'arena>, &'ast Expression<'arena>)>, @@ -324,10 +334,10 @@ impl<'ast, 'arena> mago_syntax::walker::Walker<'ast, 'arena, BuilderSites<'ast, }; let arguments = &call.argument_list.arguments; if name.value.eq_ignore_ascii_case(b"add") - && arguments.len() >= 2 - && let Some(Argument::Positional(first)) = arguments.first() + && let (Some(Argument::Positional(first)), Some(Argument::Positional(second))) = + (arguments.first(), arguments.get(1)) { - found.added.push(first.value); + found.added.push((first.value, second.value)); } } @@ -727,6 +737,8 @@ class SearchPageRoutes { $collection->add($name, new Route('/z')); $routes['not_a_route'] = new \ArrayObject(); $form->add('not_a_route_either'); + $bag->add('nor_this', 1); + $bag->add($id . '.nor_this', [1]); return $routes; } } From c9774159d397b8058020aaa2aeed17b558148754 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Mon, 2 Nov 2026 13:27:07 +0100 Subject: [PATCH 11/18] Drupal routes: leave the examples of *.api.php files unjudged The example code that documents hooks in an extension's *.api.php names invented routes, like test code, so its route names are not reported. Assisted-by: claude-code/claude-opus-5-5 --- src/diagnostics/laravel_string_keys.rs | 5 +++-- src/drupal/mod.rs | 22 ++++++++++++++-------- tests/integration/drupal_routes.rs | 11 +++++++++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index 471171e0d..2f6619827 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -860,7 +860,7 @@ impl Backend { let symbol_map = &ctx.symbol_map; let extra = self.typed_receiver_spans_for(uri, symbol_map); let morph_columns = self.morph_column_spans_for(uri, symbol_map); - let is_drupal_test_code = self.is_drupal_test_code(uri); + let is_drupal_test_code = self.is_drupal_example_or_test_code(uri); let key_spans: Vec = symbol_map .spans .iter() @@ -894,7 +894,8 @@ impl Backend { { return None; } - // Test code passes invented route names to mocks. + // 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 { return None; } diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 2eded042d..61c758bc9 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -311,20 +311,26 @@ impl Backend { aliases } - /// Whether `uri` is test code of a Drupal project: a file under a - /// `tests` folder of one of its web roots. - pub(crate) fn is_drupal_test_code(&self, uri: &str) -> bool { + /// Whether `uri` is code a Drupal site never runs against its own + /// declarations: a file under a `tests` folder of one of its web roots, + /// whose route names are a test module's or invented for a mock, or an + /// extension's `*.api.php`, which documents hooks with example code. + pub(crate) fn is_drupal_example_or_test_code(&self, uri: &str) -> bool { if !self.is_drupal_project() { return false; } let Some(path) = crate::util::uri_to_path(uri) else { return false; }; - self.drupal - .web_roots - .read() - .iter() - .any(|root| path.starts_with(root) && is_test_fixture(&path, root)) + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".api.php")) + || self + .drupal + .web_roots + .read() + .iter() + .any(|root| path.starts_with(root) && is_test_fixture(&path, root)) } /// Whether `uri` belongs to a test module of a Drupal project and is not diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index 080b2b31c..bfe9dcd4c 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -675,8 +675,9 @@ async fn entity_and_views_routes_are_judged_by_their_shape() { ); } -/// Test code passes invented route names to mocks, and a project whose -/// routing files were not read has nothing to judge against. +/// Test code passes invented route names to mocks, `*.api.php` examples +/// name invented routes, and a project whose routing files were not read +/// has nothing to judge against. #[tokio::test] async fn test_code_and_unread_projects_are_not_judged() { let test_path = "web/modules/custom/acme/tests/src/Unit/CheckedTest.php"; @@ -687,4 +688,10 @@ async fn test_code_and_unread_projects_are_not_judged() { ); let path = "web/modules/custom/acme/src/Checked.php"; assert!(unknown_routes(&[(path, CHECKED)], path).await.is_empty()); + let api_path = "web/modules/custom/acme/acme.api.php"; + assert!( + unknown_routes(&[(ROUTING_PATH, ROUTING), (api_path, CHECKED)], api_path) + .await + .is_empty() + ); } From bbc06143ef199930d83b15e8e4f7f03678f8b0f6 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Tue, 3 Nov 2026 11:44:02 +0100 Subject: [PATCH 12/18] Check routes and Twig in a mixed workspace and their receivers The mixed workspace checks that a Symfony template and Laravel's route() keep naming their own routes, that a Drupal template names Drupal's and still completes an extension's Twig filters, and the receiver unit test covers the route methods Drupal reads. Assisted-by: claude-code/claude-opus-5-5 --- src/symbol_map/extraction/symfony.rs | 16 +++ tests/integration/drupal_mixed_workspace.rs | 116 +++++++++++++++++++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index 0495959f6..b1ee6a610 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -2637,11 +2637,27 @@ mod tests { ("getRouteProviders", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), ("hasHandler", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), ("getHandler", 0, ENTITY_TYPE_MANAGER, ENTITY_TYPE), + ("generate", 0, URL_GENERATOR, ROUTE), + ("generateFromRoute", 0, URL_GENERATOR, ROUTE), + ("setRedirect", 0, FORM_STATE, ROUTE), + ("redirect", 0, CONTROLLER_BASE, ROUTE), + ("redirect", 0, FORM_BASE, ROUTE), + ("redirect", 0, ENTITY_CONTROLLER, ROUTE), + ("checkNamedRoute", 0, ACCESS_MANAGER, ROUTE), + ("getRouteByName", 0, ROUTE_PROVIDER, ROUTE), ]; const ENTITY_TYPE_MANAGER: &str = "Drupal\\Core\\Entity\\EntityTypeManagerInterface"; const ENTITY_TYPE: crate::symbol_map::StringKeyKind = crate::symbol_map::StringKeyKind::DrupalEntityType; + const URL_GENERATOR: &str = "Drupal\\Core\\Routing\\UrlGeneratorInterface"; + const FORM_STATE: &str = "Drupal\\Core\\Form\\FormStateInterface"; + const CONTROLLER_BASE: &str = "Drupal\\Core\\Controller\\ControllerBase"; + const FORM_BASE: &str = "Drupal\\Core\\Form\\FormBase"; + const ENTITY_CONTROLLER: &str = "Drupal\\Core\\Entity\\Controller\\EntityController"; + const ACCESS_MANAGER: &str = "Drupal\\Core\\Access\\AccessManagerInterface"; + const ROUTE_PROVIDER: &str = "Drupal\\Core\\Routing\\RouteProviderInterface"; + const ROUTE: crate::symbol_map::StringKeyKind = crate::symbol_map::StringKeyKind::DrupalRoute; #[test] fn every_drupal_receiver_method_reaches_its_drupal_class() { diff --git a/tests/integration/drupal_mixed_workspace.rs b/tests/integration/drupal_mixed_workspace.rs index f2e93fbbb..412f2a7b6 100644 --- a/tests/integration/drupal_mixed_workspace.rs +++ b/tests/integration/drupal_mixed_workspace.rs @@ -109,6 +109,55 @@ final class Notes { const DRUPAL_NOTE_PATH: &str = "drupal/web/modules/custom/acme/src/Entity/Note.php"; const DRUPAL_NOTES_PATH: &str = "drupal/web/modules/custom/acme/src/Notes.php"; +/// Each framework's routes, and the templates and calls that name them; +/// and a Twig filter a Drupal module declares. +const ROUTE_FILES: &[(&str, &str)] = &[ + (LARAVEL_ROUTES_PATH, LARAVEL_ROUTES), + (LARAVEL_LINKS_PATH, LARAVEL_LINKS), + (DRUPAL_ROUTING_PATH, DRUPAL_ROUTING), + (DRUPAL_TEMPLATE_PATH, DRUPAL_TEMPLATE), + (DRUPAL_TWIG_EXTENSION_PATH, DRUPAL_TWIG_EXTENSION), + (SYMFONY_ROUTES_PATH, SYMFONY_ROUTES), + (SYMFONY_TEMPLATE_PATH, SYMFONY_TEMPLATE), +]; + +const LARAVEL_ROUTES: &str = "name('home');\n"; +const LARAVEL_LINKS: &str = "Home\n"; +const SYMFONY_ROUTES_PATH: &str = "symfony/config/routes.yaml"; +const SYMFONY_TEMPLATE_PATH: &str = "symfony/templates/home.html.twig"; + // ─── Symfony, in `symfony/` ───────────────────────────────────────────────── const SYMFONY_COMPOSER: &str = r#"{ @@ -285,13 +334,14 @@ fn mixed_files() -> Vec<(String, &'static str)> { files.extend( ENTITY_FILES .iter() + .chain(ROUTE_FILES) .map(|(path, content)| (path.to_string(), *content)), ); files } -/// An initialized workspace holding `files`, with every PHP and YAML file -/// among them open. +/// An initialized workspace holding `files`, with every PHP, YAML, and +/// Twig file among them open. async fn workspace(files: &[(String, &str)]) -> (Backend, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); for (path, content) in files { @@ -306,6 +356,8 @@ async fn workspace(files: &[(String, &str)]) -> (Backend, tempfile::TempDir) { "php" } else if path.ends_with(".yml") || path.ends_with(".yaml") { "yaml" + } else if path.ends_with(".twig") { + "twig" } else { continue; }; @@ -542,6 +594,66 @@ async fn a_symfony_container_builder_still_names_services_beside_a_drupal_site() ); } +/// A Symfony template outside the Drupal web root names Symfony's routes, +/// and Laravel's `route()` names Laravel's. +#[tokio::test] +async fn symfony_and_laravel_routes_keep_working_beside_a_drupal_site() { + let (backend, dir) = workspace(&mixed_files()).await; + + let found = definitions( + &backend, + &uri_for(&dir, SYMFONY_TEMPLATE_PATH), + position_in(SYMFONY_TEMPLATE, "path('app_home')", 7), + ) + .await; + assert_eq!( + found, + vec![( + uri_for(&dir, SYMFONY_ROUTES_PATH), + line_of(SYMFONY_ROUTES, "app_home:") + )] + ); + + let found = definitions( + &backend, + &uri_for(&dir, LARAVEL_LINKS_PATH), + position_in(LARAVEL_LINKS, "route('home')", 8), + ) + .await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].0, uri_for(&dir, LARAVEL_ROUTES_PATH)); +} + +/// A Drupal template names Drupal's routes, and still completes the Twig +/// filters an extension declares. +#[tokio::test] +async fn drupal_templates_work_in_the_drupal_site() { + let (backend, dir) = workspace(&mixed_files()).await; + let template = uri_for(&dir, DRUPAL_TEMPLATE_PATH); + + let found = definitions( + &backend, + &template, + position_in(DRUPAL_TEMPLATE, "path('acme.greeting')", 8), + ) + .await; + assert_eq!( + found, + vec![( + uri_for(&dir, DRUPAL_ROUTING_PATH), + line_of(DRUPAL_ROUTING, "acme.greeting:") + )] + ); + + let labels = completion_labels( + &backend, + &template, + position_in(DRUPAL_TEMPLATE, "|acme_", 6), + ) + .await; + assert!(labels.contains(&"acme_shout".to_string()), "{labels:?}"); +} + /// A Drupal lookup reads every project's service declarations, the /// Symfony application's PHP configurator among them; confirming its calls /// from the Drupal site must not resolve them against the Drupal site's From df8e0a6c6de118613eeceb023648c4860a823b5c Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Wed, 4 Nov 2026 15:57:18 +0100 Subject: [PATCH 13/18] Read the parameters Drupal routes declare and the calls that pass them Each placeholder of a routing file's path or a #[Route] path declares a parameter of its route, and the keys of the parameters Url::fromRoute(), new Url(), Link::createFromRoute(), the redirect and URL generator calls, and a links file's route_parameters pass go to it, find its references, hover, and complete. A parameter no path declares is not reported, since Drupal sends it as the query string. Assisted-by: claude-code/claude-opus-5-5 --- src/blade/typed_receiver.rs | 8 +- src/completion/drupal_route_params.rs | 180 ++++++ src/completion/handler/string_arguments.rs | 13 + src/completion/laravel_string_keys/mod.rs | 8 +- src/completion/mod.rs | 1 + src/completion/symfony_string_keys.rs | 4 +- src/diagnostics/laravel_string_keys.rs | 3 + src/drupal/links.rs | 92 ++- src/drupal/mod.rs | 60 +- src/drupal/routing.rs | 357 ++++++++++- src/hover/laravel.rs | 15 + src/mem_audit.rs | 18 + src/resource_navigation.rs | 6 + src/symbol_map/extraction/drupal.rs | 591 +++++++++++++++++- .../extraction/expressions/calls.rs | 1 + src/symbol_map/extraction/mod.rs | 4 + src/symbol_map/extraction/symfony.rs | 2 +- src/symbol_map/map.rs | 3 + src/symbol_map/string_keys.rs | 81 ++- src/virtual_members/laravel/string_keys.rs | 6 +- tests/integration/drupal_routes.rs | 140 +++++ 21 files changed, 1554 insertions(+), 39 deletions(-) create mode 100644 src/completion/drupal_route_params.rs diff --git a/src/blade/typed_receiver.rs b/src/blade/typed_receiver.rs index 72d93f0eb..913a92d00 100644 --- a/src/blade/typed_receiver.rs +++ b/src/blade/typed_receiver.rs @@ -458,7 +458,8 @@ struct ReceiverCallWalker<'a, 'sites> { impl<'sites> ReceiverCallWalker<'_, 'sites> { /// The candidates the argument at `index` holds: in the string itself, /// or in the entries of the array a `first(['a', 'b'])` or an - /// `args(['%app.sender%'])` builds. + /// `args(['%app.sender%'])` builds, their keys included, which name + /// the parameters of a Drupal `setRedirect('route', ['node' => 1])`. fn matching_sites( &self, expr: &Expression<'_>, @@ -496,7 +497,10 @@ impl<'sites> ReceiverCallWalker<'_, 'sites> { for element in elements.iter() { match element { ArrayElement::Value(value) => self.matching_sites(value.value, index, out), - ArrayElement::KeyValue(value) => self.matching_sites(value.value, index, out), + ArrayElement::KeyValue(value) => { + self.matching_sites(value.key, index, out); + self.matching_sites(value.value, index, out); + } _ => {} } } diff --git a/src/completion/drupal_route_params.rs b/src/completion/drupal_route_params.rs new file mode 100644 index 000000000..33e0b2775 --- /dev/null +++ b/src/completion/drupal_route_params.rs @@ -0,0 +1,180 @@ +//! Completion for the parameters a Drupal route's path declares. +//! +//! The parameters array of `Url::fromRoute('entity.node.canonical', ['|' => +//! …])`, and of the other calls that take a route name and then its +//! parameters, is keyed by the placeholders of the named route's path, +//! which the route side tables keep beside each name (see +//! [`crate::symbol_map::DrupalRouteSite`]). A static call or a `new Url()` +//! is recognised by its class, and a method call by its receiver, as its +//! route name is. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::completion::eloquent_string::detect_string_call_context; +use crate::completion::source::code_context::{CodeContext, OpenBracket}; +use crate::completion::symfony_string_keys::{is_instantiation, trailing_name}; +use crate::symbol_map::StringKeyKind; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::types::FileContext; + +impl Backend { + /// Complete a key of the parameters array of a call that takes a Drupal + /// route name. + /// + /// `None` when the cursor is not on such a key, or when the route's + /// path declares no parameters. + pub(crate) fn try_drupal_route_parameter_completion( + &self, + content: &str, + position: Position, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let cursor = position_to_offset(content, position) as usize; + let (quote, _) = code.open_string?; + // A key starts the array or follows a comma in it. + if !matches!(code.last_code_byte(), Some(b'[' | b',')) { + return None; + } + // The array is an argument of the call, right inside it. + let [.., call, array] = &code.open_brackets[..] else { + return None; + }; + if call.byte != b'(' || array.byte != b'[' { + return None; + } + let route_index = self.drupal_route_argument(content, cursor, call, code, ctx)?; + if call.commas != route_index + 1 { + return None; + } + let route = string_argument(content, call.offset, route_index)?; + + let prefix = content.get(quote + 1..cursor)?.as_bytes(); + let edit_range = Range { + start: offset_to_position(content, quote + 1), + end: position, + }; + let mut items: Vec = Vec::new(); + for path in self.drupal_route_paths(&route) { + for name in crate::drupal::routing::path_parameter_names(&path) { + let matches = name + .as_bytes() + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)); + if !matches || items.iter().any(|item| item.label == name) { + continue; + } + items.push(CompletionItem { + label: name.to_string(), + kind: Some(CompletionItemKind::Field), + detail: Some(path.clone()), + sort_text: Some(format!("{:05}", items.len())), + filter_text: Some(name.to_string()), + text_edit: Some(CompletionItemTextEdit::TextEdit(TextEdit { + range: edit_range, + new_text: name.to_string(), + })), + ..Default::default() + }); + } + } + (!items.is_empty()).then_some(CompletionResponse::CompletionItemList(items)) + } + + /// The argument the call whose `(` is `call` takes a Drupal route name + /// in: `Url::fromRoute()`, `new Url()`, `Link::createFromRoute()`, or a + /// method call whose receiver the type engine confirms takes one. + fn drupal_route_argument( + &self, + content: &str, + cursor: usize, + call: &OpenBracket, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let Some(operator) = &call.callee_operator else { + let written = trailing_name(&content[..call.code_before]); + let offset = call.code_before - written.len(); + let class = ctx.resolve_name_at(written, offset as u32); + return (is_instantiation(&content[..offset]) && crate::drupal::is_url_class(&class)) + .then_some(0); + }; + let found = detect_string_call_context(content, cursor, code)?; + let subject = found.subject.as_deref()?; + if operator.is_static { + let class = ctx.resolve_name_at(subject, cursor as u32); + return crate::drupal::static_route_argument(&class, &found.method_name); + } + crate::drupal::route_parameter_call(&found.method_name)?; + (self.receiver_call_key(subject, &found.method_name, 0, content, cursor, ctx) + == Some(StringKeyKind::DrupalRoute)) + .then_some(0) + } +} + +/// The string literal the call whose `(` is at `paren` passes as its +/// argument `index`, when the argument is one. +fn string_argument(content: &str, paren: usize, index: usize) -> Option { + let bytes = content.as_bytes(); + let mut argument = 0; + let mut depth = 0usize; + // Whether the argument being read holds anything before a literal. + let mut started = false; + let mut at = paren + 1; + 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 == 0 && argument == index && !started { + return content.get(at + 1..end).map(str::to_string); + } + started = true; + at = end + 1; + continue; + } + b'(' | b'[' | b'{' => { + depth += 1; + started = true; + } + b')' | b']' | b'}' => { + depth = depth.checked_sub(1)?; + } + b',' if depth == 0 => { + argument += 1; + started = false; + if argument > index { + return None; + } + } + byte if !byte.is_ascii_whitespace() => started = true, + _ => {} + } + at += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_route_argument_is_read_at_its_index() { + let php = "Link::createFromRoute($this->t('Hi'), 'acme.greeting', ['"; + let paren = php.find('(').unwrap(); + assert_eq!( + string_argument(php, paren, 1).as_deref(), + Some("acme.greeting") + ); + assert_eq!(string_argument(php, paren, 0), None); + let php = "Url::fromRoute( 'it\\'s', ["; + assert_eq!( + string_argument(php, php.find('(').unwrap(), 0).as_deref(), + Some("it\\'s") + ); + } +} diff --git a/src/completion/handler/string_arguments.rs b/src/completion/handler/string_arguments.rs index e05c2082c..085ffaba4 100644 --- a/src/completion/handler/string_arguments.rs +++ b/src/completion/handler/string_arguments.rs @@ -95,6 +95,19 @@ impl Backend { return Some(response); } + // ── Drupal route parameters ───────────────────────────── + // The keys of `Url::fromRoute('entity.node.canonical', ['|' => …])` + // are the placeholders of the named route's path. Asked first, so + // that a key after a comma is not read as an argument of the call. + if matches!(string_ctx, StringContext::InStringLiteral) + && self.is_drupal_project() + && let Some(code) = code_ctx.as_ref() + && let Some(response) = + self.try_drupal_route_parameter_completion(content, position, code, ctx) + { + return Some(response); + } + // ── Symfony and Yii names ─────────────────────────────── // `$container->get('|')`, `service('|')`, `#[Autowire(param: '|')]`, // `$translator->trans('|')`, in any project that uses Twig, diff --git a/src/completion/laravel_string_keys/mod.rs b/src/completion/laravel_string_keys/mod.rs index e40c4a2c5..a99410ba2 100644 --- a/src/completion/laravel_string_keys/mod.rs +++ b/src/completion/laravel_string_keys/mod.rs @@ -46,7 +46,9 @@ pub(crate) fn string_key_item_kind(kind: &StringKeyKind) -> CompletionItemKind { StringKeyKind::SymfonyConfigKey => CompletionItemKind::Field, StringKeyKind::SymfonyEvent | StringKeyKind::WordPressHook => CompletionItemKind::Event, StringKeyKind::MessengerBus => CompletionItemKind::Reference, - StringKeyKind::YiiRouteArgument => CompletionItemKind::Field, + StringKeyKind::YiiRouteArgument | StringKeyKind::DrupalRouteParameter => { + CompletionItemKind::Field + } StringKeyKind::TwigFilter | StringKeyKind::TwigFunction => CompletionItemKind::Function, StringKeyKind::TwigVariable => CompletionItemKind::Variable, StringKeyKind::Route @@ -192,13 +194,15 @@ impl Backend { // 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. A hook name is + // the routes of the handler it is written in, and a Drupal route + // parameter from the route its call names. A hook name is // completed with its patterns as snippets, which only the // WordPress completion builds. StringKeyKind::SymfonyTranslation | StringKeyKind::WordPressHook | StringKeyKind::SymfonyConfigKey | StringKeyKind::YiiRouteArgument + | StringKeyKind::DrupalRouteParameter | StringKeyKind::Section | StringKeyKind::Stack | StringKeyKind::TwigBlock diff --git a/src/completion/mod.rs b/src/completion/mod.rs index cc4af550d..e3264f13a 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -78,6 +78,7 @@ pub(crate) mod array_callable; pub mod array_shape; pub(crate) mod builder; pub(crate) mod command_params; +mod drupal_route_params; 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 b22f5fc13..61a62bd7e 100644 --- a/src/completion/symfony_string_keys.rs +++ b/src/completion/symfony_string_keys.rs @@ -655,7 +655,7 @@ where } /// Whether `before`, the text before a callee's name, ends with `new`. -fn is_instantiation(before: &str) -> bool { +pub(crate) fn is_instantiation(before: &str) -> bool { let before = before.trim_end(); before.strip_suffix("new").is_some_and(|rest| { !rest @@ -666,7 +666,7 @@ fn is_instantiation(before: &str) -> bool { } /// The (possibly qualified) name `text` ends with. -fn trailing_name(text: &str) -> &str { +pub(crate) fn trailing_name(text: &str) -> &str { let start = text .bytes() .rposition(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'\\'))) diff --git a/src/diagnostics/laravel_string_keys.rs b/src/diagnostics/laravel_string_keys.rs index 2f6619827..27e1377b1 100644 --- a/src/diagnostics/laravel_string_keys.rs +++ b/src/diagnostics/laravel_string_keys.rs @@ -95,8 +95,11 @@ impl CheckedStringKind { // 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()`. + // Drupal sends a route parameter no path declares as the query + // string. StringKeyKind::WordPressHook | StringKeyKind::DrupalEntityType + | StringKeyKind::DrupalRouteParameter | StringKeyKind::TranslationDomain | StringKeyKind::Section | StringKeyKind::Stack diff --git a/src/drupal/links.rs b/src/drupal/links.rs index 784ec7f75..673ff4a36 100644 --- a/src/drupal/links.rs +++ b/src/drupal/links.rs @@ -4,8 +4,10 @@ //! top-level key, and places it by route: the link's `route_name`, the //! `base_route` a task is grouped under, and the routes an action //! `appears_on`. A `parent` or `parent_id` names another link, not a -//! route. +//! route. The keys of a link's `route_parameters` are parameters of the +//! route its `route_name` names. +use super::routing::{entries, lines, route_parameter_key, scalar}; use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; use crate::symfony::routes::is_drupal_route_name; @@ -58,6 +60,71 @@ pub(crate) fn links_route_spans(content: &str) -> Vec { spans } +/// The spans of the route parameters a links file passes: each key of a +/// link's `route_parameters`, as a parameter of its `route_name`, which +/// may come before or after it. +pub(crate) fn links_parameter_spans(content: &str) -> Vec { + let lines = lines(content); + let mut spans = Vec::new(); + for (_, link) in entries(&lines) { + let mut route = None; + let mut keys: Vec<(String, usize)> = Vec::new(); + for (child, nested) in entries(link) { + match child.key { + "route_name" => route = Some(scalar(child.value, child.value_start).0), + "route_parameters" if nested.is_empty() => { + keys.extend(flow_keys(child.value, child.value_start)); + } + "route_parameters" => keys.extend( + entries(nested) + .into_iter() + .map(|(parameter, _)| (parameter.key.to_string(), parameter.key_start)), + ), + _ => {} + } + } + let Some(route) = route.filter(|route| is_drupal_route_name(route)) else { + continue; + }; + for (key, start) in keys { + spans.push(SymbolSpan { + start: start as u32, + end: (start + key.len()) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: route_parameter_key(&route, &key), + is_write: false, + is_optional: false, + }, + }); + } + } + spans +} + +/// The keys of a `{ a: 1, 'b': 2 }` flow mapping written at `start`, each +/// with the absolute offset of its text. +fn flow_keys(raw: &str, start: usize) -> Vec<(String, usize)> { + let Some(open) = raw.find('{') else { + return Vec::new(); + }; + let close = raw.rfind('}').unwrap_or(raw.len()).max(open + 1); + let mut keys = Vec::new(); + let mut pair_start = open + 1; + for pair in raw[open + 1..close].split(',') { + if let Some(colon) = pair.find(':') { + let written = pair[..colon].trim(); + let leading = pair[..colon].len() - pair[..colon].trim_start().len(); + let (key, (quote, _)) = crate::framework::strip_yaml_quotes(written); + if !key.is_empty() { + keys.push((key.to_string(), start + pair_start + leading + quote)); + } + } + pair_start += pair.len() + 1; + } + keys +} + /// Push the route `raw`, a YAML scalar starting at `start`, names. fn push_route(raw: &str, start: usize, spans: &mut Vec) { let trimmed = raw.trim_start(); @@ -151,6 +218,29 @@ mod tests { ); } + #[test] + fn route_parameters_are_keys_of_the_links_route() { + let yaml = "a:\n route_parameters:\n name: 'World'\n 'id': 1\n route_name: acme.greeting\nb:\n route_name: acme.note\n route_parameters: { acme_note: 1, 'mode': full }\nc:\n route_parameters:\n orphan: 1\n"; + let found: Vec<(&str, String)> = links_parameter_spans(yaml) + .iter() + .map(|span| { + let SymbolKind::StringKey { key, .. } = &span.kind else { + unreachable!() + }; + (&yaml[span.start as usize..span.end as usize], key.clone()) + }) + .collect(); + assert_eq!( + found, + [ + ("name", "acme.greeting#name".to_string()), + ("id", "acme.greeting#id".to_string()), + ("acme_note", "acme.note#acme_note".to_string()), + ("mode", "acme.note#mode".to_string()), + ] + ); + } + #[test] fn completion_starts_in_route_values_only() { let yaml = "a:\n route_name: on"; diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index 61c758bc9..e01af5042 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -21,7 +21,7 @@ pub(crate) mod types; use crate::Backend; use crate::atom::Atom; -use crate::symbol_map::{StringKeyKind, SymbolKind}; +use crate::symbol_map::{ReceiverClass, StringKeyKind, SymbolKind}; /// The web roots of the workspace's Drupal projects, and the declaration /// files found under them. @@ -127,6 +127,48 @@ pub(crate) fn static_route_argument(class: &str, method: &str) -> Option } } +/// The calls on a typed receiver that take a route's parameters after the +/// route name they take first, with the receiver that confirms them. Each +/// one's route name is read through the receiver +/// `symbol_map::extraction::symfony_receiver_method` gives it. +const ROUTE_PARAMETER_CALLS: &[(&str, ReceiverClass, &str)] = &[ + ( + "setRedirect", + ReceiverClass::FormRedirectParameters, + "route_parameters", + ), + ( + "redirect", + ReceiverClass::RouteRedirectParameters, + "route_parameters", + ), + ( + "generateFromRoute", + ReceiverClass::UrlGeneratorParameters, + "parameters", + ), + ( + "generate", + ReceiverClass::UrlGeneratorParameters, + "parameters", + ), + ( + "checkNamedRoute", + ReceiverClass::AccessCheckParameters, + "parameters", + ), +]; + +/// The receiver that confirms the route parameters a call of `method` +/// passes second, and the name of their argument, when the call takes +/// them. +pub(crate) fn route_parameter_call(method: &str) -> Option<(ReceiverClass, &'static str)> { + ROUTE_PARAMETER_CALLS + .iter() + .find(|(name, _, _)| method.eq_ignore_ascii_case(name)) + .map(|(_, receiver, parameter)| (*receiver, *parameter)) +} + /// Whether `class`, a resolved class name, is `Drupal\Core\Url`, whose /// constructor takes a route name first. pub(crate) fn is_url_class(class: &str) -> bool { @@ -311,6 +353,22 @@ impl Backend { aliases } + /// The paths of the routes the project names `name`, sorted. + pub(crate) fn drupal_route_paths(&self, name: &str) -> Vec { + let mut paths = Vec::new(); + self.visit_declaring_symbol_maps(|_, map| { + paths.extend( + map.drupal_routes + .iter() + .filter(|route| route.name == name) + .map(|route| route.path.clone()), + ); + }); + paths.sort_unstable(); + paths.dedup(); + paths + } + /// Whether `uri` is code a Drupal site never runs against its own /// declarations: a file under a `tests` folder of one of its web roots, /// whose route names are a test module's or invented for a mock, or an diff --git a/src/drupal/routing.rs b/src/drupal/routing.rs index 641b9c1c6..330d32531 100644 --- a/src/drupal/routing.rs +++ b/src/drupal/routing.rs @@ -1,9 +1,288 @@ -//! The names a Drupal routing file's route defaults read besides route -//! names: the entity type an `_entity_form`, `_entity_list`, or +//! What a Drupal routing file declares besides route names: the +//! parameters each route's path declares, what Drupal upcasts them by (see +//! [`crate::symbol_map::DrupalRouteSite`]), and the names the route +//! defaults read: the entity type an `_entity_form`, `_entity_list`, or //! `_entity_view` works on, and the service a `service:method` controller //! or title callback calls. +//! +//! A parameter only means something under its route, so its span's key +//! names the route as well as the parameter (see [`route_parameter_key`]). -use crate::symbol_map::{StringKeyKind, SymbolKind, SymbolSpan}; +use crate::symbol_map::{ + DeclaredParameter, DrupalRouteSite, StringKeyKind, SymbolKind, SymbolSpan, +}; + +/// The key of a [`StringKeyKind::DrupalRouteParameter`] span: the +/// parameter `name` under the route `route`. +pub(crate) fn route_parameter_key(route: &str, name: &str) -> String { + format!("{route}#{name}") +} + +/// The route and the parameter name of a route parameter's key. +pub(crate) fn split_route_parameter_key(key: &str) -> (&str, &str) { + key.rsplit_once('#').unwrap_or(("", key)) +} + +/// The byte range of the name of each placeholder of a route path, the way +/// Symfony's route compiler, which Drupal's extends, reads them: a name of +/// word characters that does not start with a digit, after an optional `!` +/// and before an optional inline requirement (`{id<\d+>}`) or default +/// (`{page?1}`). +pub(crate) fn path_parameters(path: &str) -> Vec<(usize, usize)> { + let bytes = path.as_bytes(); + let mut found = Vec::new(); + let mut cursor = 0; + while let Some(relative) = memchr::memchr(b'{', &bytes[cursor..]) { + let mut start = cursor + relative + 1; + if bytes.get(start) == Some(&b'!') { + start += 1; + } + let mut end = start; + while bytes + .get(end) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte >= 0x80) + { + end += 1; + } + cursor = end; + if end == start || bytes[start].is_ascii_digit() { + continue; + } + let mut after = end; + if bytes.get(after) == Some(&b'<') { + let Some(close) = memchr::memchr(b'>', &bytes[after..]) else { + continue; + }; + after += close + 1; + } + if bytes.get(after) == Some(&b'?') { + let Some(close) = memchr::memchr(b'}', &bytes[after..]) else { + continue; + }; + after += close; + } + if bytes.get(after) == Some(&b'}') { + found.push((start, end)); + cursor = after + 1; + } + } + found +} + +/// The names of the placeholders a route path declares, in order. +pub(crate) fn path_parameter_names(path: &str) -> impl Iterator { + path_parameters(path) + .into_iter() + .map(move |(start, end)| &path[start..end]) +} + +/// The method a `_controller`, `_form`, or `_title_callback` value names, +/// as [`DrupalRouteSite::controller`] spells it: `Class::method` as it is, +/// and a class alone as its `__invoke()`. A `service:method` is `None`. +pub(crate) fn handler_of(value: &str) -> Option { + let value = value.trim_start_matches('\\'); + if value.is_empty() { + return None; + } + if value.contains("::") { + Some(value.to_string()) + } else if value.contains(':') { + None + } else { + Some(format!("{value}::__invoke")) + } +} + +/// One `key: value` line of a routing or links file. +pub(super) struct Line<'a> { + pub(super) indent: usize, + pub(super) key: &'a str, + /// Absolute offset of [`Self::key`], quotes excluded. + pub(super) key_start: usize, + pub(super) value: &'a str, + /// Absolute offset of [`Self::value`]. + pub(super) value_start: usize, +} + +/// The `key: value` lines of a YAML file, comments and list items left +/// out. +pub(super) fn lines(content: &str) -> Vec> { + let mut lines = Vec::new(); + for (line_start, line) in crate::framework::line_offsets(content) { + let semantic = crate::symfony::resources::yaml_content_before_comment(line); + let indent = crate::framework::leading_spaces(semantic); + let text = &semantic[indent..]; + if text.is_empty() || text.starts_with('-') { + continue; + } + let Some(colon) = text.find(':') else { + continue; + }; + let (key, (quote, _)) = crate::framework::strip_yaml_quotes(text[..colon].trim_end()); + lines.push(Line { + indent, + key, + key_start: line_start + indent + quote, + value: &text[colon + 1..], + value_start: line_start + indent + colon + 1, + }); + } + lines +} + +/// The entries of the mapping `block` holds, each with the lines nested +/// under it. +pub(super) fn entries<'b, 'a>(block: &'b [Line<'a>]) -> Vec<(&'b Line<'a>, &'b [Line<'a>])> { + let Some(first) = block.first() else { + return Vec::new(); + }; + let indent = first.indent; + let mut found = Vec::new(); + for (at, line) in block.iter().enumerate() { + if line.indent != indent { + continue; + } + let nested = block[at + 1..] + .iter() + .take_while(|nested| nested.indent > indent) + .count(); + found.push((line, &block[at + 1..at + 1 + nested])); + } + found +} + +/// A YAML scalar written at `start`, without its quotes and the space +/// around it, with the absolute offset of its text. +pub(super) fn scalar(value: &str, start: usize) -> (String, usize) { + let trimmed = value.trim_start(); + let leading = value.len() - trimmed.len(); + let (text, (quote, _)) = crate::framework::strip_yaml_quotes(trimmed.trim_end()); + // A double-quoted scalar escapes its backslashes. + let text = if trimmed.starts_with('"') { + text.replace("\\\\", "\\") + } else { + text.to_string() + }; + (text, start + leading + quote) +} + +/// The routes a routing file declares, in source order, and the spans of +/// the parameters their paths declare. +/// +/// A route is a top-level key whose mapping has a `path:`; the +/// `route_callbacks:` list names the classes that build more. +pub(crate) fn routing_routes(content: &str) -> (Vec, Vec) { + let lines = lines(content); + let mut spans = Vec::new(); + let mut routes = Vec::new(); + for (index, entry) in lines.iter().enumerate() { + if entry.indent != 0 + || entry.key == "route_callbacks" + || !crate::symfony::routes::is_drupal_route_name(entry.key) + { + continue; + } + let block_len = lines[index + 1..] + .iter() + .take_while(|line| line.indent > 0) + .count(); + let block = &lines[index + 1..index + 1 + block_len]; + let mut path = None; + let mut defaults: &[Line<'_>] = &[]; + let mut options: &[Line<'_>] = &[]; + for (child, nested) in entries(block) { + match child.key { + "path" => path = Some(scalar(child.value, child.value_start)), + "defaults" => defaults = nested, + "options" => options = nested, + _ => {} + } + } + let Some((path, path_start)) = path else { + continue; + }; + for (start, end) in path_parameters(&path) { + spans.push(SymbolSpan { + start: (path_start + start) as u32, + end: (path_start + end) as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: route_parameter_key(entry.key, &path[start..end]), + is_write: true, + is_optional: false, + }, + }); + } + + let mut controller = None; + let mut form = None; + let mut title_callback = None; + let mut entity_view = None; + let mut entity_form = None; + let mut values = Vec::new(); + for (default, _) in entries(defaults) { + let (value, _) = scalar(default.value, default.value_start); + if !default.key.starts_with('_') { + values.push((default.key.to_string(), is_yaml_null(&value))); + } + match default.key { + "_controller" => controller = handler_of(&value), + "_form" => { + form = (!value.is_empty() && !value.contains(':')) + .then(|| format!("{}::buildForm", value.trim_start_matches('\\'))); + } + "_title_callback" => title_callback = handler_of(&value), + "_entity_view" => entity_view = entity_type_of(&value), + "_entity_form" => entity_form = entity_type_of(&value), + _ => {} + } + } + let mut parameters = Vec::new(); + for (option, nested) in entries(options) { + if option.key != "parameters" { + continue; + } + for (parameter, declared) in entries(nested) { + let settings = entries(declared); + parameters.push(DeclaredParameter { + name: parameter.key.to_string(), + ty: settings + .iter() + .find(|(setting, _)| setting.key == "type") + .map(|(ty, _)| scalar(ty.value, ty.value_start).0), + converted_otherwise: settings + .iter() + .any(|(setting, _)| matches!(setting.key, "converter" | "tempstore")), + }); + } + } + routes.push(DrupalRouteSite { + name: entry.key.to_string(), + path, + controller: form.or(controller), + title_callback, + parameters, + defaults: values, + entity_type: entity_view.or(entity_form), + }); + } + (spans, routes) +} + +/// Whether a YAML scalar, unquoted, is `null`. +fn is_yaml_null(value: &str) -> bool { + matches!(value, "" | "~" | "null" | "Null" | "NULL") +} + +/// The entity type an `_entity_view` or `_entity_form` value names: the +/// part before its first `.`. +pub(crate) fn entity_type_of(value: &str) -> Option { + value + .split('.') + .next() + .filter(|id| is_plain_name(id)) + .map(str::to_string) +} /// The entity type and service spans of a routing file, in source order. pub(crate) fn routing_value_spans(content: &str) -> Vec { @@ -88,4 +367,76 @@ mod tests { ] ); } + + fn names(path: &str) -> Vec<&str> { + path_parameter_names(path).collect() + } + + #[test] + fn path_parameters_are_read_the_way_symfony_compiles_them() { + assert_eq!( + names("/node/{node}/revisions/{node_revision}/view"), + ["node", "node_revision"] + ); + assert_eq!( + names("/a/{id<\\d+>}/{page?1}/{!slug}"), + ["id", "page", "slug"] + ); + assert!(names("/a/{1x}/{}/{ x }/{open").is_empty()); + } + + #[test] + fn routes_keep_their_path_handlers_and_upcasting() { + let yaml = "acme.note:\n path: '/note/{acme_note}/{mode}'\n defaults:\n _controller: '\\Drupal\\acme\\Controller\\NoteController::view'\n _title_callback: 'Drupal\\acme\\Controller\\NoteController::title'\n _entity_view: 'acme_note.full'\n mode: ~\n options:\n parameters:\n acme_note:\n type: entity:acme_note\n mode:\n converter: x\nacme.form:\n path: /form/{node}\n defaults:\n _controller: 'acme.runner:run'\n _form: '\\Drupal\\acme\\Form\\NoteForm'\nacme.invoke:\n path: /invoke\n defaults:\n _controller: \"\\\\Drupal\\\\acme\\\\Controller\\\\Invoked\"\n _title_callback: acme.titles:title\nnot_a_route:\n defaults: {}\nroute_callbacks:\n - '\\Drupal\\acme\\Routing\\Routes::routes'\n"; + let (spans, routes) = routing_routes(yaml); + let declared: Vec<(&str, String)> = spans + .iter() + .map(|span| { + let SymbolKind::StringKey { key, is_write, .. } = &span.kind else { + unreachable!() + }; + assert!(is_write); + (&yaml[span.start as usize..span.end as usize], key.clone()) + }) + .collect(); + assert_eq!( + declared, + [ + ("acme_note", "acme.note#acme_note".to_string()), + ("mode", "acme.note#mode".to_string()), + ("node", "acme.form#node".to_string()), + ] + ); + let note = DrupalRouteSite { + name: "acme.note".to_string(), + path: "/note/{acme_note}/{mode}".to_string(), + controller: Some("Drupal\\acme\\Controller\\NoteController::view".to_string()), + title_callback: Some("Drupal\\acme\\Controller\\NoteController::title".to_string()), + parameters: vec![ + DeclaredParameter { + name: "acme_note".to_string(), + ty: Some("entity:acme_note".to_string()), + converted_otherwise: false, + }, + DeclaredParameter { + name: "mode".to_string(), + ty: None, + converted_otherwise: true, + }, + ], + defaults: vec![("mode".to_string(), true)], + entity_type: Some("acme_note".to_string()), + }; + assert_eq!(routes[0], note); + assert_eq!( + routes[1].controller.as_deref(), + Some("Drupal\\acme\\Form\\NoteForm::buildForm") + ); + assert_eq!( + routes[2].controller.as_deref(), + Some("Drupal\\acme\\Controller\\Invoked::__invoke") + ); + assert_eq!(routes[2].title_callback, None); + assert_eq!(routes.len(), 3); + } } diff --git a/src/hover/laravel.rs b/src/hover/laravel.rs index b5f44f21d..2b8e2f669 100644 --- a/src/hover/laravel.rs +++ b/src/hover/laravel.rs @@ -373,6 +373,21 @@ impl Backend { }; ("Route argument", detail) } + // A parameter is shown by its name, under the paths of its + // route. + StringKeyKind::DrupalRouteParameter => { + let (route, name) = crate::drupal::routing::split_route_parameter_key(key); + shown = name; + let mut detail = format!("Route `{}`", route); + let paths = self.drupal_route_paths(route); + if paths.is_empty() { + detail.push_str("\n\nNot declared in the project's routing files"); + } + for path in paths { + detail.push_str(&format!("\n\nPath `{}`", path)); + } + ("Route parameter", detail) + } // An installed bundle's schema is not read, so a key no schema // of the project's declares is only not one of its own. StringKeyKind::SymfonyConfigKey => { diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 383be9683..8aa5429e5 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1411,6 +1411,24 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { { sym.add(name.capacity()); } + sym.add(sm.drupal_routes.capacity() * size_of::()); + for route in &sm.drupal_routes { + sym.add(route.name.capacity() + route.path.capacity()); + sym.add(route.controller.as_ref().map_or(0, String::capacity)); + sym.add(route.title_callback.as_ref().map_or(0, String::capacity)); + sym.add(route.entity_type.as_ref().map_or(0, String::capacity)); + sym.add( + route.parameters.capacity() * size_of::(), + ); + for parameter in &route.parameters { + sym.add(parameter.name.capacity()); + sym.add(parameter.ty.as_ref().map_or(0, String::capacity)); + } + sym.add(route.defaults.capacity() * size_of::<(String, bool)>()); + for (name, _) in &route.defaults { + sym.add(name.capacity()); + } + } sym.add(sm.routes.capacity() * size_of::()); for route in &sm.routes { sym.add(route.path.as_ref().map_or(0, String::capacity)); diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index d35ffc1ec..6dde1b982 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -276,6 +276,7 @@ impl Backend { let mut spans = Vec::new(); let mut container_values = Vec::new(); let mut route_imports = Vec::new(); + let mut drupal_routes = Vec::new(); let mut declares_twig_loader = false; // A Drupal declaration file is Drupal's, also in a Drupal site that // requires Symfony's container and has a `config` directory on the @@ -291,9 +292,13 @@ impl Backend { crate::drupal::DeclarationFile::Routing => { spans = crate::symfony::routes::drupal_routing_spans(content); spans.extend(crate::drupal::routing::routing_value_spans(content)); + let (parameters, routes) = crate::drupal::routing::routing_routes(content); + spans.extend(parameters); + drupal_routes = routes; } crate::drupal::DeclarationFile::Links => { spans = crate::drupal::links::links_route_spans(content); + spans.extend(crate::drupal::links::links_parameter_spans(content)); } } } else if is_symfony && crate::symfony::resources::is_container_config_uri(uri) { @@ -380,6 +385,7 @@ impl Backend { member_access_indices, container_values, route_imports, + drupal_routes, declares_twig_loader, source_len: u32::try_from(content.len()).unwrap_or(u32::MAX), ..SymbolMap::default() diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index bb51eaf83..b5f80183d 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -4,9 +4,9 @@ use super::*; use crate::symbol_map::extraction::symfony::{ - RouteDeclaration, RouteName, push_route_spans, route_declarations, + RouteDeclaration, RouteName, is_route_attribute, push_route_spans, route_declarations, }; -use crate::symbol_map::{EntityTypeSite, StringKeyKind}; +use crate::symbol_map::{DeclaredParameter, DrupalRouteSite, EntityTypeSite, StringKeyKind}; use crate::virtual_members::laravel::helpers::resolved_string_literal; const CONTENT_STORAGE: &str = "Drupal\\Core\\Entity\\Sql\\SqlContentEntityStorage"; @@ -120,6 +120,13 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_entity_type_spans( /// inherit, so an abstract controller's routes are declared where they are /// written, under its own prefix, and without aliases, whose class name /// would be the subclass's. +/// +/// Each placeholder of a route's path declares a parameter of the route, +/// the class route's path included, which comes before every method +/// route's. A route is kept beside the spans with the method it +/// dispatches to, for its parameters to be upcast by (see +/// [`DrupalRouteSite`]); an abstract controller's are not, since that +/// method is the subclass's. pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( class: &Class<'_>, ctx: &mut ExtractionCtx<'_>, @@ -137,12 +144,14 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( let is_abstract = class.modifiers.contains_abstract(); // Drupal's discovery reads attributes only, never `@Route` annotations. let class_routes = route_declarations(&class.attribute_lists, None, class, ctx); + let class_details = route_details(&class.attribute_lists, ctx); let prefix = match class_routes.first().map(|route| &route.name) { Some(RouteName::Literal(name, _, _)) => name.as_str(), _ => "", }; let is_named = |route: &&RouteDeclaration| matches!(route.name, RouteName::Literal(..)); let mut spans = Vec::new(); + let mut sites = Vec::new(); let mut aliases = Vec::new(); let mut route_count = 0usize; let mut any_method_route = false; @@ -156,6 +165,7 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( let is_invoke = method_name.eq_ignore_ascii_case("__invoke"); invokable |= is_invoke; let routes = route_declarations(&method.attribute_lists, None, class, ctx); + let details = route_details(&method.attribute_lists, ctx); any_method_route |= !routes.is_empty(); let named = routes.iter().filter(is_named).count(); route_count += named; @@ -163,7 +173,10 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( if named == 1 { aliases.push(format!("{fqn}::{method_name}")); } - for route in routes.iter().filter(is_named) { + for (route, own) in routes.iter().zip(&details) { + let RouteName::Literal(name, _, _) = &route.name else { + continue; + }; push_route_spans( route, prefix, @@ -172,13 +185,30 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( &[], &mut spans, ); + sites.extend(attribute_route( + &format!("{prefix}{name}"), + class_details.first(), + own, + format!("{fqn}::{method_name}"), + &mut spans, + )); } } if !any_method_route && invokable && !is_abstract { - for route in class_routes.iter().filter(is_named) { + for (route, own) in class_routes.iter().zip(&class_details) { + let RouteName::Literal(name, _, _) = &route.name else { + continue; + }; route_count += 1; fqcn_alias = true; push_route_spans(route, "", None, StringKeyKind::DrupalRoute, &[], &mut spans); + sites.extend(attribute_route( + name, + None, + own, + format!("{fqn}::__invoke"), + &mut spans, + )); } } if fqcn_alias && route_count == 1 { @@ -191,10 +221,222 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_attribute_spans( ctx.drupal_route_aliases.push(alias); } } + ctx.drupal_routes.extend(sites); } ctx.spans.extend(spans); } +/// What a `#[Route]` gives besides its name: its path, as written with the +/// offset of its text, and the defaults and options Drupal upcasts the +/// route's parameters by. +#[derive(Default)] +struct RouteDetails { + path: Option<(String, u32)>, + /// Whether the path is written as something other than a literal. + path_unreadable: bool, + /// The parameters `options` declares, or `None` when it has no + /// `parameters`. + parameters: Option>, + /// The `defaults` keys that are not Drupal's own, with whether each is + /// `NULL`. + defaults: Vec<(String, bool)>, + title_callback: Option, + entity_view: Option, + entity_form: Option, +} + +/// The details of each `#[Route]` among `attribute_lists`, in the order +/// [`route_declarations`] reads them. +fn route_details( + attribute_lists: &Sequence<'_, AttributeList<'_>>, + ctx: &ExtractionCtx<'_>, +) -> Vec { + let content = ctx.content; + let literal = |value: &Expression<'_>| { + resolved_string_literal(value, content).map(|(text, _, _)| text.to_string()) + }; + let mut found = Vec::new(); + for attribute in attribute_lists + .iter() + .flat_map(|list| list.attributes.iter()) + { + let written = bytes_to_str(attribute.name.value()); + let resolved = ctx + .resolved_name_at(attribute.name.span().start.offset) + .unwrap_or(written); + if !is_route_attribute(resolved) { + continue; + } + let mut details = RouteDetails::default(); + for (parameter, value) in attribute_arguments( + attribute, + &["path", "name", "requirements", "options", "defaults"], + ) { + match parameter { + "path" => match resolved_string_literal(value, content) { + Some((path, start, _)) => details.path = Some((path.to_string(), start as u32)), + None => details.path_unreadable = true, + }, + "options" => { + details.parameters = array_entry(value, "parameters", content) + .map(|parameters| declared_parameters(parameters, content)); + } + "defaults" => { + let default = |key: &str| array_entry(value, key, content).and_then(literal); + details.title_callback = default("_title_callback") + .and_then(|callback| crate::drupal::routing::handler_of(&callback)); + details.entity_view = default("_entity_view") + .and_then(|view| crate::drupal::routing::entity_type_of(&view)); + details.entity_form = default("_entity_form") + .and_then(|form| crate::drupal::routing::entity_type_of(&form)); + details.defaults = default_values(value, content); + } + _ => {} + } + } + found.push(details); + } + found +} + +/// The route an attribute declares under `name`, dispatching to +/// `controller`, after the class route `globals`: the spans of the +/// parameters its path declares, pushed to `spans`, and the route, unless +/// its path is not all written out. +/// +/// Drupal puts the class route's path in front of the route's, and lets the +/// route's own defaults and options replace the class route's key by key. +fn attribute_route( + name: &str, + globals: Option<&RouteDetails>, + own: &RouteDetails, + controller: String, + spans: &mut Vec, +) -> Option { + let mut path = String::new(); + let mut readable = true; + for details in globals.into_iter().chain([own]) { + readable &= !details.path_unreadable; + let Some((written, start)) = &details.path else { + continue; + }; + for (from, to) in crate::drupal::routing::path_parameters(written) { + spans.push(SymbolSpan { + start: start + from as u32, + end: start + to as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: crate::drupal::routing::route_parameter_key(name, &written[from..to]), + is_write: true, + is_optional: false, + }, + }); + } + path.push_str(written); + } + if !readable { + return None; + } + let entity_view = own + .entity_view + .clone() + .or_else(|| globals.and_then(|globals| globals.entity_view.clone())); + let entity_form = own + .entity_form + .clone() + .or_else(|| globals.and_then(|globals| globals.entity_form.clone())); + let title_callback = own + .title_callback + .clone() + .or_else(|| globals.and_then(|globals| globals.title_callback.clone())); + let mut defaults = own.defaults.clone(); + for (name, is_null) in globals.iter().flat_map(|globals| &globals.defaults) { + if !defaults.iter().any(|(own, _)| own == name) { + defaults.push((name.clone(), *is_null)); + } + } + Some(DrupalRouteSite { + name: name.to_string(), + path, + controller: Some(controller), + title_callback, + parameters: own + .parameters + .clone() + .or_else(|| globals.and_then(|globals| globals.parameters.clone())) + .unwrap_or_default(), + defaults, + entity_type: entity_view.or(entity_form), + }) +} + +/// The value an array literal gives the string key `key`. +fn array_entry<'a, 'arena>( + array: &'a Expression<'arena>, + key: &str, + content: &str, +) -> Option<&'a Expression<'arena>> { + crate::parser::array_literal_elements(array)? + .iter() + .find_map(|element| match element { + ArrayElement::KeyValue(entry) => resolved_string_literal(entry.key, content) + .filter(|(written, _, _)| *written == key) + .map(|_| entry.value), + _ => None, + }) +} + +/// Each parameter an `options` array's `parameters` declares, with the +/// `type` it gives and whether it names its own converter. +fn declared_parameters(parameters: &Expression<'_>, content: &str) -> Vec { + let Some(entries) = crate::parser::array_literal_elements(parameters) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|element| { + let ArrayElement::KeyValue(entry) = element else { + return None; + }; + let (name, _, _) = resolved_string_literal(entry.key, content)?; + let ty = array_entry(entry.value, "type", content) + .and_then(|ty| resolved_string_literal(ty, content)) + .map(|(ty, _, _)| ty.to_string()); + let converted_otherwise = ["converter", "tempstore"] + .iter() + .any(|key| array_entry(entry.value, key, content).is_some()); + Some(DeclaredParameter { + name: name.to_string(), + ty, + converted_otherwise, + }) + }) + .collect() +} + +/// The keys of a `defaults` array that are not Drupal's own, with whether +/// each is `NULL`. +fn default_values(defaults: &Expression<'_>, content: &str) -> Vec<(String, bool)> { + let Some(entries) = crate::parser::array_literal_elements(defaults) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|element| { + let ArrayElement::KeyValue(entry) = element else { + return None; + }; + let (name, _, _) = resolved_string_literal(entry.key, content)?; + (!name.starts_with('_')).then(|| { + ( + name.to_string(), + matches!(entry.value, Expression::Literal(Literal::Null(_))), + ) + }) + }) + .collect() +} + /// Whether `fqn` is a class under an extension's (or core's) `Controller` /// namespace: `Drupal\\Controller\…`. fn is_controller_class(fqn: &str) -> bool { @@ -458,34 +700,121 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_url_spans( } /// Record the route name a call reads in its positional argument `index`, -/// or in the `route_name:` argument that names it. +/// or in the `route_name:` argument that names it, and the parameters of +/// that route it passes in the argument after it, or in the +/// `route_parameters:` argument. pub(in crate::symbol_map::extraction) fn emit_drupal_route_span( index: usize, argument_list: &ArgumentList<'_>, ctx: &mut ExtractionCtx<'_>, ) { - let route = - argument_list - .arguments - .iter() - .enumerate() - .find_map(|(at, argument)| match argument { - Argument::Positional(positional) if at == index => Some(positional.value), - Argument::Named(named) if named.name.value == b"route_name" => Some(named.value), - _ => None, - }); - if let Some(route) = route { - push_laravel_string_span( - StringKeyKind::DrupalRoute, - false, - false, - route, - ctx.content, - &mut ctx.spans, - ); + let Some(route) = argument_at(argument_list, index, b"route_name") else { + return; + }; + push_laravel_string_span( + StringKeyKind::DrupalRoute, + false, + false, + route, + ctx.content, + &mut ctx.spans, + ); + let Some((route, _, _)) = resolved_string_literal(route, ctx.content) else { + return; + }; + let Some(parameters) = argument_at(argument_list, index + 1, b"route_parameters") else { + return; + }; + for (key, start, end) in parameter_keys(parameters, ctx.content) { + ctx.spans.push(SymbolSpan { + start, + end, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: crate::drupal::routing::route_parameter_key(route, key), + is_write: false, + is_optional: false, + }, + }); } } +/// Record the route parameters a call on a typed receiver passes after the +/// route name it reads (see [`crate::drupal::route_parameter_call`]), for +/// the type engine to confirm against its receiver like the name. +pub(in crate::symbol_map::extraction) fn record_drupal_route_parameter_sites( + member_name: &str, + argument_list: &ArgumentList<'_>, + ctx: &mut ExtractionCtx<'_>, +) { + if !ctx.drupal { + return; + } + let Some((receiver, parameter)) = crate::drupal::route_parameter_call(member_name) else { + return; + }; + // The route name is read only when it is written first, as the + // receiver's own sites read it. + let Some(Argument::Positional(route)) = argument_list.arguments.first() else { + return; + }; + let Some((route, _, _)) = resolved_string_literal(route.value, ctx.content) else { + return; + }; + let Some(parameters) = argument_at(argument_list, 1, parameter.as_bytes()) else { + return; + }; + for (key, start, end) in parameter_keys(parameters, ctx.content) { + ctx.typed_receiver_sites.push(TypedReceiverSite { + start, + end, + key: crate::drupal::routing::route_parameter_key(route, key), + is_write: false, + is_optional: false, + guards: 0, + receiver, + }); + } +} + +/// The argument a call passes its parameter `index` in: positional, or +/// named `name`. +fn argument_at<'a, 'arena>( + argument_list: &'a ArgumentList<'arena>, + index: usize, + name: &[u8], +) -> Option<&'a Expression<'arena>> { + argument_list + .arguments + .iter() + .enumerate() + .find_map(|(at, argument)| match argument { + Argument::Positional(positional) if at == index => Some(positional.value), + Argument::Named(named) if named.name.value == name => Some(named.value), + _ => None, + }) +} + +/// The keys of an array literal of route parameters that are written out, +/// each with the byte range of its text. +fn parameter_keys<'arena>( + parameters: &Expression<'arena>, + content: &str, +) -> Vec<(&'arena str, u32, u32)> { + let Some(entries) = crate::parser::array_literal_elements(parameters) else { + return Vec::new(); + }; + entries + .iter() + .filter_map(|entry| match entry { + ArrayElement::KeyValue(entry) => resolved_string_literal(entry.key, content), + _ => None, + }) + .filter(|(key, _, _)| !key.is_empty()) + .map(|(key, start, end)| (key, start as u32, end as u32)) + .collect() +} + /// The class a `handlers` array gives the handler `name`, as a class /// constant or a string. fn handler_class(handlers: &Expression<'_>, name: &str, ctx: &ExtractionCtx<'_>) -> Option { @@ -855,6 +1184,220 @@ abstract class Base { ); } + /// The route parameter keys `php`'s static calls pass, sorted, and the + /// ones its method calls leave for the type engine to confirm, with the + /// receiver they need. + fn route_parameters( + php: &str, + ) -> (Vec, Vec<(String, crate::symbol_map::ReceiverClass)>) { + 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: true, + drupal: true, + twig: false, + }, + None, + ); + let mut passed: Vec = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key, + is_write: false, + .. + } => { + assert_eq!( + key.rsplit('#').next(), + php.get(span.start as usize..span.end as usize) + ); + Some(key.clone()) + } + _ => None, + }) + .collect(); + passed.sort(); + let sites = map + .typed_receiver_sites + .iter() + .filter(|site| { + site.receiver + .targets() + .iter() + .any(|(_, kind)| *kind == StringKeyKind::DrupalRouteParameter) + }) + .map(|site| (site.key.clone(), site.receiver)) + .collect(); + (passed, sites) + } + + #[test] + fn route_calls_pass_the_parameters_of_their_route() { + let php = r#" $id, 'mode' => 'full', 0 => 'x']); + new Url('acme.note', route_parameters: ['acme_note' => 1]); + Link::createFromRoute('Note', 'acme.note', ['acme_note' => 2]); + Url::fromRoute($id, ['acme_note' => 3]); + $form_state->setRedirect('acme.note', ['acme_note' => 4]); + $urls->generateFromRoute('acme.note', parameters: ['mode' => 'teaser']); + $form_state->setRedirect('acme.note', 'not_an_array'); + $form_state->other('acme.note', ['nope' => 1]); + } +} +"#; + let (passed, sites) = route_parameters(php); + assert_eq!( + passed, + [ + "acme.note#acme_note", + "acme.note#acme_note", + "acme.note#acme_note", + "acme.note#mode", + ] + ); + assert_eq!( + sites, + [ + ( + "acme.note#acme_note".to_string(), + crate::symbol_map::ReceiverClass::FormRedirectParameters + ), + ( + "acme.note#mode".to_string(), + crate::symbol_map::ReceiverClass::UrlGeneratorParameters + ), + ] + ); + } + + /// A call's parameters are confirmed by a receiver of the same classes + /// as its route name, so neither can be read without the other. + #[test] + fn route_parameter_receivers_reach_the_classes_of_the_route_name() { + let classes = |receiver: crate::symbol_map::ReceiverClass, wanted: StringKeyKind| { + receiver + .targets() + .iter() + .filter(|(_, kind)| *kind == wanted) + .map(|(class, _)| *class) + .collect::>() + }; + for method in [ + "setRedirect", + "redirect", + "generateFromRoute", + "generate", + "checkNamedRoute", + ] { + let (names, _) = crate::symbol_map::extraction::symfony_receiver_method(method, 0) + .unwrap_or_else(|| panic!("{method} names no route")); + let (parameters, _) = crate::drupal::route_parameter_call(method) + .unwrap_or_else(|| panic!("{method} passes no parameters")); + assert_eq!( + classes(names, StringKeyKind::DrupalRoute), + classes(parameters, StringKeyKind::DrupalRouteParameter), + "{method}" + ); + } + } + + #[test] + fn route_attributes_declare_their_parameters_and_routes() { + let php = r#" ['acme_note' => ['type' => 'entity:acme_note']]])] + public function view($acme_note, string $mode): array { return []; } +} +#[Route('/about/{node}', name: 'acme.about', defaults: ['_entity_view' => 'node.full'])] +final class About { + public function __invoke(): array { return []; } +} +"#; + 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: true, + drupal: true, + twig: false, + }, + None, + ); + let declared: Vec<(&str, &str)> = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key, + is_write: true, + .. + } => Some((&php[span.start as usize..span.end as usize], key.as_str())), + _ => None, + }) + .collect(); + assert_eq!( + declared, + [ + ("acme_note", "acme.notes.view#acme_note"), + ("mode", "acme.notes.view#mode"), + ("node", "acme.about#node"), + ] + ); + assert_eq!( + map.drupal_routes, + [ + DrupalRouteSite { + name: "acme.notes.view".to_string(), + path: "/notes/{acme_note}/view/{mode}".to_string(), + controller: Some("Drupal\\acme\\Controller\\Notes::view".to_string()), + title_callback: None, + parameters: vec![DeclaredParameter { + name: "acme_note".to_string(), + ty: Some("entity:acme_note".to_string()), + converted_otherwise: false, + }], + defaults: Vec::new(), + entity_type: None, + }, + DrupalRouteSite { + name: "acme.about".to_string(), + path: "/about/{node}".to_string(), + controller: Some("Drupal\\acme\\Controller\\About::__invoke".to_string()), + title_callback: None, + parameters: Vec::new(), + defaults: Vec::new(), + entity_type: Some("node".to_string()), + }, + ] + ); + } + #[test] fn a_file_without_symfony_routes_builds_none() { let php = "add('acme.thing', 1); }\n}\n"; diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index 766671950..311ada797 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -488,6 +488,7 @@ fn extract_method_call<'a>( let member_name = crate::atom::atom_bytes(ident.value); emit_laravel_method_call_spans(object, &member_name, argument_list, ctx); record_symfony_receiver_sites(object, &member_name, argument_list, ctx); + record_drupal_route_parameter_sites(&member_name, argument_list, ctx); record_yii_view_sites(object, &member_name, argument_list, ctx); emit_mock_method_spans(object, &member_name, argument_list, ctx); // Recorded with `->` for both spellings so callable resolution diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index ebb79caef..3b2ec56fa 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -106,6 +106,8 @@ struct ExtractionCtx<'a> { entity_types: Vec, drupal_route_patterns: Vec, drupal_route_aliases: Vec, + /// The Drupal routes the file's `#[Route]` attributes declare. + drupal_routes: Vec, /// Current conditional nesting depth (if/else, switch, while, for, etc.). /// Incremented when entering a conditional block, decremented when leaving. cond_nesting_depth: u16, @@ -481,6 +483,7 @@ fn extract_symbol_map_inner( entity_types: Vec::new(), drupal_route_patterns: Vec::new(), drupal_route_aliases: Vec::new(), + drupal_routes: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, @@ -676,6 +679,7 @@ fn extract_symbol_map_inner( entity_types: ctx.entity_types, drupal_route_patterns: ctx.drupal_route_patterns, drupal_route_aliases: ctx.drupal_route_aliases, + drupal_routes: ctx.drupal_routes, routes: ctx.routes, declares_twig_loader: ctx.declares_twig_loader, route_imports: ctx diff --git a/src/symbol_map/extraction/symfony.rs b/src/symbol_map/extraction/symfony.rs index b1ee6a610..84c1c324b 100644 --- a/src/symbol_map/extraction/symfony.rs +++ b/src/symbol_map/extraction/symfony.rs @@ -1436,7 +1436,7 @@ pub(in crate::symbol_map::extraction) fn emit_template_attribute_span( /// Whether `class_name`, as resolved, is Symfony's `#[Route]`, under its /// current name or the `Annotation` one older applications import. -fn is_route_attribute(class_name: &str) -> bool { +pub(in crate::symbol_map::extraction) fn is_route_attribute(class_name: &str) -> bool { let class_name = class_name.trim_start_matches('\\'); ROUTE_ATTRIBUTES .iter() diff --git a/src/symbol_map/map.rs b/src/symbol_map/map.rs index de3a3736f..841a102c9 100644 --- a/src/symbol_map/map.rs +++ b/src/symbol_map/map.rs @@ -317,6 +317,9 @@ pub(crate) struct SymbolMap { /// which no text of the file spells out. Empty for every file that /// declares none. pub drupal_route_aliases: Vec, + /// The Drupal routes the file declares, in source order. Empty for + /// every file that declares none. + pub drupal_routes: Vec, /// The routes the file declares, in source order. Empty for every file /// that declares none. pub routes: Vec, diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index e7eca05ea..0ac08edd7 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -166,6 +166,12 @@ pub(crate) enum StringKeyKind { /// the URL generator, access manager, and route provider methods that /// take one. DrupalRoute, + /// A parameter of a Drupal route, keyed by the route as well as the + /// name (see `drupal::routing::route_parameter_key`): a `{node}` only + /// means something under its route. Declared by each placeholder of + /// the route's path, and read by the keys of the parameters a route + /// call or a links file's `route_parameters:` passes. + DrupalRouteParameter, } impl StringKeyKind { @@ -212,7 +218,9 @@ impl StringKeyKind { | Self::TwigVariable => KeyFramework::Twig, Self::YiiView | Self::YiiRoute | Self::YiiRouteArgument => KeyFramework::Yii, Self::WordPressHook => KeyFramework::WordPress, - Self::DrupalEntityType | Self::DrupalRoute => KeyFramework::Drupal, + Self::DrupalEntityType | Self::DrupalRoute | Self::DrupalRouteParameter => { + KeyFramework::Drupal + } } } @@ -663,6 +671,48 @@ pub(crate) struct RouteSite { pub handler: Option, } +/// A route a Drupal routing file or `#[Route]` declares, with what Drupal +/// reads from it besides its name to upcast its parameters (see +/// `drupal::upcasting`): the path, the controller and title callback, the +/// parameters its `options` declare, the values its `defaults` give, and +/// the entity type an `_entity_view` or `_entity_form` default names. +/// +/// Kept beside the spans, which hold the name and the path's parameters, +/// since only the type engine and hover read the rest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DrupalRouteSite { + pub name: String, + pub path: String, + /// The method the route dispatches to, as `Class::method` without the + /// leading `\`: its `_form`'s `buildForm()`, else its `_controller` + /// (a class alone is its `__invoke()`). `None` for a controller + /// written as `service:method`, whose class only the container knows. + pub controller: Option, + /// The `_title_callback`, spelled like [`Self::controller`]. + pub title_callback: Option, + /// Each parameter `options.parameters` declares, with or without a + /// `type`. + pub parameters: Vec, + /// Each `defaults` key that is not one of Drupal's own (`_controller`, + /// `_title`, …), with whether its value is `NULL`. Drupal converts a + /// parameter only when the path or a default gives it a value. + pub defaults: Vec<(String, bool)>, + /// The entity type the `_entity_view` default names, else the + /// `_entity_form` one. + pub entity_type: Option, +} + +/// A parameter a route's `options.parameters` declares. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeclaredParameter { + pub name: String, + /// The `type` it declares, as written (`entity:node`). + pub ty: Option, + /// Whether it names a `converter` or a `tempstore`, which convert it + /// some other way than Drupal's entity converter. + pub converted_otherwise: bool, +} + /// One `{name}` in a route's path. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RoutePlaceholder { @@ -847,6 +897,18 @@ pub(crate) enum ReceiverClass { /// Drupal's route provider, whose `getRouteByName()` takes a route /// name. RouteLookup, + /// A Drupal form state again, for the route parameters its + /// `setRedirect()` takes after the route name. + FormRedirectParameters, + /// What has Drupal's `redirect()` shortcut again, for the route + /// parameters it takes after the route name. + RouteRedirectParameters, + /// Drupal's URL generator, for the route parameters its + /// `generateFromRoute()` and `generate()` take after the route name. + UrlGeneratorParameters, + /// Drupal's access manager, for the route parameters its + /// `checkNamedRoute()` takes after the route name. + AccessCheckParameters, } impl ReceiverClass { @@ -943,6 +1005,23 @@ impl ReceiverClass { ], Self::AccessCheck => &[(DRUPAL_ACCESS_MANAGER, StringKeyKind::DrupalRoute)], Self::RouteLookup => &[(DRUPAL_ROUTE_PROVIDER, StringKeyKind::DrupalRoute)], + Self::FormRedirectParameters => { + &[(DRUPAL_FORM_STATE, StringKeyKind::DrupalRouteParameter)] + } + Self::RouteRedirectParameters => &[ + (DRUPAL_CONTROLLER_BASE, StringKeyKind::DrupalRouteParameter), + (DRUPAL_FORM_BASE, StringKeyKind::DrupalRouteParameter), + ( + DRUPAL_ENTITY_CONTROLLER, + StringKeyKind::DrupalRouteParameter, + ), + ], + Self::UrlGeneratorParameters => { + &[(DRUPAL_URL_GENERATOR, StringKeyKind::DrupalRouteParameter)] + } + Self::AccessCheckParameters => { + &[(DRUPAL_ACCESS_MANAGER, StringKeyKind::DrupalRouteParameter)] + } } } diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 0f89daa0d..f72a38f66 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -79,7 +79,8 @@ pub(crate) fn resolve_laravel_string_key( | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute | StringKeyKind::DrupalEntityType - | StringKeyKind::DrupalRoute => backend.symfony_declaration_locations(*kind, key), + | StringKeyKind::DrupalRoute + | StringKeyKind::DrupalRouteParameter => backend.symfony_declaration_locations(*kind, 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), @@ -316,7 +317,8 @@ pub(crate) fn find_laravel_string_key_references( | StringKeyKind::MessengerBus | StringKeyKind::YiiRoute | StringKeyKind::DrupalEntityType - | StringKeyKind::DrupalRoute => { + | StringKeyKind::DrupalRoute + | StringKeyKind::DrupalRouteParameter => { return find_string_key_usages(kind, key, backend, snapshot, include_declaration); } // The catalogues named after a domain declare it, and the paths of diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index bfe9dcd4c..c3e7327ee 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -695,3 +695,143 @@ async fn test_code_and_unread_projects_are_not_judged() { .is_empty() ); } + +const PARAMETERS: &str = r#" 'World']); + new Url('acme.greeting', ['name' => 'World']); + Link::createFromRoute('Hello', 'acme.greeting', ['name' => 'World']); + $form_state->setRedirect('acme.greeting', ['name' => 'World']); + $this->redirect('acme.greeting', ['name' => 'World']); + $this->urls->generateFromRoute('acme.greeting', ['name' => 'World']); + $this->access->checkNamedRoute('acme.greeting', ['name' => 'World']); + $other->setRedirect('acme.greeting', ['name' => 'World']); + } +} + +final class Redirect { + public function setRedirect(string $path, array $query = []): void {} +} +"#; + +const PARAMETERS_PATH: &str = "web/modules/custom/acme/src/Controller/Parameters.php"; + +const MENU: &str = "acme.greeting:\n title: 'Greeting'\n route_parameters:\n name: 'World'\n route_name: acme.greeting\n"; +const MENU_PATH: &str = "web/modules/custom/acme/acme.links.menu.yml"; + +/// A key of the parameters a route call or a links file passes goes to +/// the placeholder of the route's path, which lists every one of them. +#[tokio::test] +async fn route_parameters_go_to_the_path_that_declares_them() { + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (PARAMETERS_PATH, PARAMETERS), + (MENU_PATH, MENU), + ])) + .await; + let php = uri_for(&dir, PARAMETERS_PATH); + let yaml = uri_for(&dir, ROUTING_PATH); + let menu = uri_for(&dir, MENU_PATH); + + let found = definitions_at( + &backend, + &php, + position_in(PARAMETERS, "fromRoute('acme.greeting', ['name'", 30), + ) + .await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!( + (&found[0].uri, found[0].range.start), + (&yaml, Position::new(1, 25)) + ); + let found = definitions_at(&backend, &menu, Position::new(3, 5)).await; + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!((&found[0].uri, found[0].range.start.line), (&yaml, 1)); + + let found = references_at(&backend, &yaml, Position::new(1, 26)).await; + // Every call but the other class's `setRedirect()`. + let mut expected: Vec<_> = (17..=23).map(|line| (php.clone(), line)).collect(); + expected.push((yaml, 1)); + expected.push((menu, 3)); + expected.sort(); + assert_eq!(found, expected); + + let position = position_in(PARAMETERS, "fromRoute('acme.greeting', ['name'", 30); + let hover = hover_text_at(&backend, &php, position.line, position.character) + .await + .unwrap_or_default(); + assert!(hover.contains("Route parameter"), "{hover}"); + assert!(hover.contains("/acme/greeting/{name}"), "{hover}"); +} + +/// The keys of the parameters complete from the route's path, in every +/// call that takes a route name and its parameters. +#[tokio::test] +async fn route_parameters_complete_from_the_path() { + let consumer = PARAMETERS.replace("['name' => 'World']", "['' => 'World']"); + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (PARAMETERS_PATH, consumer.as_str()), + ])) + .await; + let php = uri_for(&dir, PARAMETERS_PATH); + for (needle, inside) in [ + ("fromRoute('acme.greeting', [''", 29), + ("new Url('acme.greeting', [''", 27), + ("'Hello', 'acme.greeting', [''", 28), + ("$form_state->setRedirect('acme.greeting', [''", 44), + ("$this->redirect('acme.greeting', [''", 35), + ("generateFromRoute('acme.greeting', [''", 37), + ("checkNamedRoute('acme.greeting', [''", 35), + ] { + let position = position_in(&consumer, needle, inside); + let labels = response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ); + assert_eq!(labels, ["name"], "{needle}"); + } + let position = position_in(&consumer, "$other->setRedirect('acme.greeting', [''", 39); + let labels = response_labels( + completion_response(&backend, &php, position.line, position.character, None).await, + ); + assert!(!labels.contains(&"name".to_string()), "{labels:?}"); +} + +/// Drupal sends a parameter no path declares as the query string, so no +/// key is reported. +#[tokio::test] +async fn route_parameters_no_path_declares_are_not_reported() { + let consumer = PARAMETERS.replace("['name' => 'World']", "['page' => 2]"); + let (backend, dir) = workspace(&files(&[ + (ROUTING_PATH, ROUTING), + (PARAMETERS_PATH, consumer.as_str()), + ])) + .await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics( + uri_for(&dir, PARAMETERS_PATH).as_str(), + &consumer, + &mut diagnostics, + ); + assert!( + diagnostics + .iter() + .all(|diagnostic| !crate::common::message_text(&diagnostic.message).contains("page")), + "{diagnostics:?}" + ); +} From fb180ed51cc87d0afb8d48e2e1b17a0f01d4e36d Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Wed, 4 Nov 2026 16:01:01 +0100 Subject: [PATCH 14/18] Type Drupal controller parameters as the entities Drupal upcasts them to A parameter of a route's controller or title callback is the entity class when Drupal's entity converter loads it: a type the route's options declare, a parameter named after an entity type and hinted with a class its entity class fits, or the entity type an _entity_view or _entity_form names. An untyped parameter nothing upcasts keeps no type. Assisted-by: claude-code/claude-opus-5-5 --- src/drupal/mod.rs | 3 + src/drupal/upcasting.rs | 289 +++++++++++++++ src/symbol_map/string_keys.rs | 25 ++ src/symfony/mod.rs | 2 + .../variable/forward_walk/param_seeding.rs | 69 ++++ tests/integration/drupal_routes.rs | 337 ++++++++++++++++++ 6 files changed, 725 insertions(+) create mode 100644 src/drupal/upcasting.rs diff --git a/src/drupal/mod.rs b/src/drupal/mod.rs index e01af5042..2be41c734 100644 --- a/src/drupal/mod.rs +++ b/src/drupal/mod.rs @@ -18,6 +18,7 @@ use parking_lot::RwLock; pub(crate) mod links; pub(crate) mod routing; pub(crate) mod types; +pub(crate) mod upcasting; use crate::Backend; use crate::atom::Atom; @@ -32,6 +33,8 @@ pub(crate) struct DrupalState { /// the workspace's `.gitignore` leaves out, so the walk that finds the /// rest of the project's YAML never reaches them. declaration_files: RwLock>, + /// The project's routes by the methods they dispatch to, once read. + route_index: RwLock>>, } /// A kind of file Drupal reads declarations from. diff --git a/src/drupal/upcasting.rs b/src/drupal/upcasting.rs new file mode 100644 index 000000000..d5ebdddb5 --- /dev/null +++ b/src/drupal/upcasting.rs @@ -0,0 +1,289 @@ +//! The entities Drupal upcasts route parameters to. +//! +//! Drupal's entity converter loads the entity a route parameter names +//! before the controller runs, for the parameters core's +//! `EntityResolverManager::setRouteOptions()` gives an `entity:` +//! type: the ones the route's `options.parameters` declare; failing that, +//! the controller's parameters that are named after an entity type and +//! type-hinted with its class or a class or interface it extends +//! (`NodeInterface $node`); and when that finds none, the parameter named +//! after the entity type an `_entity_view` or `_entity_form` default names. +//! A parameter is only converted when the route gives it a value, by its +//! path or a default, and only by the entity converter when its definition +//! names no converter or tempstore of its own. +//! +//! The routes are looked up by the method they dispatch to, in an index +//! read once until a file that declares routes changes (see +//! [`Backend::note_drupal_routes`]). + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use crate::Backend; +use crate::atom::Atom; +use crate::class_lookup::{Fqn, ResolvedClassLoader}; +use crate::php_type::{PhpType, TypeKind}; +use crate::symbol_map::{DrupalRouteSite, SymbolMap}; + +/// The project's routes, by the methods they dispatch to. +#[derive(Default)] +pub(crate) struct RouteIndex { + /// Each route whose controller or title callback is the method, + /// keyed by it as `Class::method`, lowercased. + by_handler: HashMap>>, + /// The files the routes were read from. + uris: HashSet, +} + +/// The class a native type hint names, the way Drupal's +/// `Reflection::getParameterClassName()` reads it: a single class, which +/// may be nullable, but not a union or a builtin type. +pub(crate) fn hinted_class(hint: &PhpType) -> Option<&str> { + let named = match hint.kind() { + TypeKind::Nullable(inner) => inner, + TypeKind::Union(members) if members.len() == 2 && members.iter().any(PhpType::is_null) => { + members.iter().find(|member| !member.is_null())? + } + _ => hint, + }; + named.class_name().map(|name| name.trim_start_matches('\\')) +} + +/// The entity type a declared parameter type converts to: the one an +/// `entity:` or `entity_revision:` names, written out. +fn entity_type_of_parameter_type(ty: &str) -> Option<&str> { + ty.strip_prefix("entity:") + .or_else(|| ty.strip_prefix("entity_revision:")) + .filter(|entity_type| !entity_type.is_empty() && !entity_type.contains('{')) +} + +impl Backend { + /// The routes whose controller or title callback is `class::method`. + pub(crate) fn drupal_handler_routes( + &self, + class: &str, + method: &str, + ) -> Vec> { + let index = self.drupal_route_index(); + if index.by_handler.is_empty() { + return Vec::new(); + } + let mut key = class.trim_start_matches('\\').to_ascii_lowercase(); + key.push_str("::"); + key.push_str(&method.to_ascii_lowercase()); + index.by_handler.get(&key).cloned().unwrap_or_default() + } + + /// What each parameter of `class::method` receives that every route + /// dispatching to the method, or naming it its title callback, upcasts: + /// the parameter's name without its `$`, the entity class, and whether a + /// route's default leaves it `NULL`. Empty for a method no route + /// reaches. + pub(crate) fn drupal_upcast_parameters( + &self, + class: &str, + method: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec<(String, Atom, bool)> { + if !self.is_drupal_project() { + return Vec::new(); + } + let routes = self.drupal_handler_routes(class, method); + let mut upcast: Option> = None; + for route in &routes { + let found = self.drupal_route_upcasts(route, class_loader); + upcast = Some(match upcast { + None => found, + Some(kept) => kept + .into_iter() + .filter(|parameter| found.contains(parameter)) + .collect(), + }); + } + upcast + .unwrap_or_default() + .into_iter() + .filter_map(|(parameter, entity_type)| { + let (class, _) = self.drupal_entity_types(&entity_type).into_iter().next()?; + let nullable = routes + .iter() + .any(|route| route.defaults_to_null(¶meter)); + Some((parameter, class, nullable)) + }) + .collect() + } + + /// The parameters `route` upcasts, each with the entity type it is + /// converted to, by the rules of `EntityResolverManager` and + /// `ParamConverterManager`: a parameter the route gives a value, by the + /// path or a default, whose definition ends up with an `entity:` or + /// `entity_revision:` type and no converter of its own. + pub(crate) fn drupal_route_upcasts( + &self, + route: &DrupalRouteSite, + class_loader: &ResolvedClassLoader<'_>, + ) -> Vec<(String, String)> { + // A type the routing file gives a parameter stays, and a converter + // or a tempstore it names converts it some other way. + let takes_a_type = |name: &str| { + route + .declared(name) + .is_none_or(|declared| declared.ty.is_none() && !declared.converted_otherwise) + }; + let mut upcast: Vec<(String, String)> = route + .parameters + .iter() + .filter(|declared| !declared.converted_otherwise) + .filter_map(|declared| { + entity_type_of_parameter_type(declared.ty.as_deref()?) + .map(|entity_type| (declared.name.clone(), entity_type.to_string())) + }) + .collect(); + + // A parameter named after an entity type and hinted with a class its + // entity class is takes the type. + let mut reflected = false; + if let Some((class, method)) = route + .controller + .as_deref() + .and_then(|controller| controller.split_once("::")) + && let Some(controller) = class_loader(Fqn::new(class)) + && let Some(method) = controller.get_method_ci(method) + { + for parameter in method.parameters.iter() { + let name = parameter.name.trim_start_matches('$'); + if parameter + .native_type_hint + .as_ref() + .and_then(hinted_class) + .is_some_and(|hint| self.drupal_entity_class_is(name, hint, class_loader)) + { + reflected = true; + if takes_a_type(name) { + upcast.push((name.to_string(), name.to_string())); + } + } + } + } + + // Failing that, the entity an `_entity_view` or `_entity_form` names, + // unless a parameter already has its `entity:` type. + if !reflected + && let Some(entity_type) = &route.entity_type + && crate::drupal::routing::path_parameter_names(&route.path) + .any(|name| name == entity_type.as_str()) + { + let own_type = format!("entity:{entity_type}"); + if !route + .parameters + .iter() + .any(|declared| declared.ty.as_deref() == Some(own_type.as_str())) + && takes_a_type(entity_type) + && !self.drupal_entity_types(entity_type).is_empty() + { + upcast.push((entity_type.clone(), entity_type.clone())); + } + } + + upcast.retain(|(name, _)| route.passes(name)); + upcast + } + + /// Whether the entity class of the entity type `entity_type` is the + /// class `hint`, or extends or implements it. `false` for an entity + /// type the project does not declare, or whose class is not found. + pub(crate) fn drupal_entity_class_is( + &self, + entity_type: &str, + hint: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> bool { + self.drupal_entity_types(entity_type) + .first() + .and_then(|(class, _)| class_loader(Fqn::new(class))) + .is_some_and(|class| crate::class_lookup::is_subtype_of(&class, hint, class_loader)) + } + + /// The project's routes by the methods they dispatch to, read once until + /// a file that declares routes changes (see + /// [`Backend::note_drupal_routes`]). + fn drupal_route_index(&self) -> Arc { + if let Some(index) = self.drupal.route_index.read().as_ref() { + return Arc::clone(index); + } + let mut index = RouteIndex::default(); + self.visit_declaring_symbol_maps(|uri, map| { + if !map.drupal_routes.is_empty() { + index.uris.insert(uri.to_string()); + } + for route in &map.drupal_routes { + let route = Arc::new(route.clone()); + for handler in [&route.controller, &route.title_callback] + .into_iter() + .flatten() + { + index + .by_handler + .entry(handler.to_ascii_lowercase()) + .or_default() + .push(Arc::clone(&route)); + } + } + }); + let index = Arc::new(index); + *self.drupal.route_index.write() = Some(Arc::clone(&index)); + index + } + + /// Forget what [`Backend::drupal_route_index`] read when the routes of + /// `uri` may have changed: `map`, the map just published for it, declares + /// some, or the index read some from it. `None` for a file that is gone. + pub(crate) fn note_drupal_routes(&self, uri: &str, map: Option<&SymbolMap>) { + let stale = match self.drupal.route_index.read().as_ref() { + None => false, + Some(index) => { + map.is_some_and(|map| !map.drupal_routes.is_empty()) || index.uris.contains(uri) + } + }; + if stale { + *self.drupal.route_index.write() = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_hint_names_a_class_when_it_is_one_maybe_nullable() { + let named = |name: &str| PhpType::named(crate::atom::atom(name)); + assert_eq!( + hinted_class(&named("Drupal\\node\\NodeInterface")), + Some("Drupal\\node\\NodeInterface") + ); + assert_eq!( + hinted_class(&PhpType::nullable(named("Drupal\\node\\Node"))), + Some("Drupal\\node\\Node") + ); + assert_eq!(hinted_class(&named("string")), None); + assert_eq!( + hinted_class(&PhpType::union(vec![ + named("Drupal\\node\\Node"), + named("string") + ])), + None + ); + } + + #[test] + fn declared_types_convert_to_an_entity_type_written_out() { + assert_eq!(entity_type_of_parameter_type("entity:node"), Some("node")); + assert_eq!( + entity_type_of_parameter_type("entity_revision:node"), + Some("node") + ); + assert_eq!(entity_type_of_parameter_type("entity:{entity_type}"), None); + assert_eq!(entity_type_of_parameter_type("tempstore"), None); + } +} diff --git a/src/symbol_map/string_keys.rs b/src/symbol_map/string_keys.rs index 0ac08edd7..c01b36923 100644 --- a/src/symbol_map/string_keys.rs +++ b/src/symbol_map/string_keys.rs @@ -713,6 +713,31 @@ pub(crate) struct DeclaredParameter { pub converted_otherwise: bool, } +impl DrupalRouteSite { + /// What `options.parameters` declares for the parameter `name`. + pub(crate) fn declared(&self, name: &str) -> Option<&DeclaredParameter> { + self.parameters + .iter() + .find(|parameter| parameter.name == name) + } + + /// Whether Drupal gives the parameter `name` a value to convert: the + /// path declares it, or a default gives it one. + pub(crate) fn passes(&self, name: &str) -> bool { + crate::drupal::routing::path_parameter_names(&self.path) + .any(|placeholder| placeholder == name) + || self.defaults.iter().any(|(default, _)| default == name) + } + + /// Whether a default gives the parameter `name` the value `NULL`, which + /// Drupal leaves as it is. + pub(crate) fn defaults_to_null(&self, name: &str) -> bool { + self.defaults + .iter() + .any(|(default, is_null)| *is_null && default == name) + } +} + /// One `{name}` in a route's path. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RoutePlaceholder { diff --git a/src/symfony/mod.rs b/src/symfony/mod.rs index ee65d40c8..dfa35c444 100644 --- a/src/symfony/mod.rs +++ b/src/symfony/mod.rs @@ -212,6 +212,7 @@ impl Backend { .lock() .iter() .any(|prefix| uri.starts_with(prefix.as_str())); + self.note_drupal_routes(uri, Some(map)); // Only take the write lock when the answer changes. if self.symfony.declaring_uris.read().contains(uri) == declares { return; @@ -226,6 +227,7 @@ impl Backend { /// Forget a file that no longer exists. pub(crate) fn forget_symfony_declarations(&self, uri: &str) { + self.note_drupal_routes(uri, None); if self.symfony.declaring_uris.read().contains(uri) { self.symfony.declaring_uris.write().remove(uri); } diff --git a/src/type_engine/variable/forward_walk/param_seeding.rs b/src/type_engine/variable/forward_walk/param_seeding.rs index b4a69f89d..d18e513d3 100644 --- a/src/type_engine/variable/forward_walk/param_seeding.rs +++ b/src/type_engine/variable/forward_walk/param_seeding.rs @@ -97,6 +97,9 @@ pub(crate) fn seed_params<'b>( }); let trait_prototype = trait_prototype_method(method_name, ctx); + // The parameters Drupal upcasts to an entity before a controller runs, + // looked up once, when there is a parameter it could be. + let mut drupal_upcasts: Option> = None; for (index, param) in parameters.enumerate() { let pname = bytes_to_str(param.variable.name).to_string(); @@ -161,6 +164,18 @@ pub(crate) fn seed_params<'b>( ctx, ); + if let Some(entity) = drupal_upcast_entity( + &pname, + native_type.as_ref(), + crate::parser::param_default_is_null(param), + method_name, + &mut drupal_upcasts, + ctx, + ) { + scope.seed(&pname, ctx.resolved_types_for(entity)); + continue; + } + // A variadic parameter collects the arguments into an array // rather than taking the one at its index, so the call site's // types don't line up with it. @@ -183,6 +198,60 @@ pub(crate) fn seed_params<'b>( } } +/// The type a Drupal route's controller receives for the parameter `name` +/// (written with its `$`) of the method being seeded, when Drupal upcasts +/// it (see [`crate::drupal::upcasting`]) and its native hint, if any, +/// accepts the entity: the entity class, or `null` too when the hint is +/// nullable, the parameter defaults to `null`, or a route's default leaves +/// it `NULL`. `upcasts` caches the method's upcast parameters. +fn drupal_upcast_entity( + name: &str, + native_type: Option<&PhpType>, + defaults_to_null: bool, + method_name: Option<&str>, + upcasts: &mut Option>, + ctx: &ForwardWalkCtx<'_>, +) -> Option { + let method = method_name?; + let name = name.strip_prefix('$').unwrap_or(name); + let backend = ctx + .file + .backend + .filter(|backend| backend.is_drupal_project())?; + let (entity, route_null) = upcasts + .get_or_insert_with(|| { + backend.drupal_upcast_parameters( + &ctx.current_class.fqn(), + method, + ctx.file.class_loader, + ) + }) + .iter() + .find(|(parameter, _, _)| parameter.as_str() == name) + .map(|(_, entity, route_null)| (*entity, *route_null))?; + let mut nullable = route_null || defaults_to_null; + if let Some(written) = native_type { + // A builtin or union hint takes no entity, nor a class the entity's + // is not. + let resolved = ctx.written_names().resolve_hint(written); + let hint = crate::drupal::upcasting::hinted_class(&resolved)?; + let fits = + (ctx.file.class_loader)(crate::class_lookup::Fqn::new(&entity)).is_some_and(|class| { + crate::class_lookup::is_subtype_of(&class, hint, ctx.file.class_loader) + }); + if !fits { + return None; + } + nullable |= resolved.non_null_type().is_some(); + } + let entity = PhpType::named(entity); + Some(if nullable { + PhpType::nullable(entity) + } else { + entity + }) +} + /// The scope entry a parameter gets from the argument the call site /// passed it, or `None` when the declaration already says as much. /// diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index c3e7327ee..c6eea0c3d 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -835,3 +835,340 @@ async fn route_parameters_no_path_declares_are_not_reported() { "{diagnostics:?}" ); } + +const NOTE: &str = r#" Vec<(&'static str, &'static str)> { + files(&[ + (ENTITY_INTERFACE_PATH, ENTITY_INTERFACE), + (CONTENT_ENTITY_INTERFACE_PATH, CONTENT_ENTITY_INTERFACE), + (USER_INTERFACE_PATH, USER_INTERFACE), + (NOTE_PATH, NOTE), + ("web/modules/custom/acme/acme.routing.yml", NOTE_ROUTING), + (PAGE_CONTROLLER_PATH, PAGE_CONTROLLER), + (NOTE_FORM_PATH, NOTE_FORM), + ROUTE_ATTRIBUTE, + (ATTRIBUTE_NOTE_CONTROLLER_PATH, ATTRIBUTE_NOTE_CONTROLLER), + ]) +} + +/// What hovering the variable just before `marker` in `content` shows. +async fn hover_before( + backend: &phpantom_lsp::Backend, + uri: &tower_lsp_server::gen_lsp_types::Uri, + content: &str, + marker: &str, +) -> String { + let position = position_in(content, marker, 2); + hover_text_at(backend, uri, position.line, position.character) + .await + .unwrap_or_default() +} + +/// A controller's parameter is the entity Drupal loads for it only where +/// Drupal upcasts it: a type the route declares, a hint the entity class +/// fits, or the entity type an `_entity_view` or `_entity_form` names, for +/// a parameter the path or a default gives a value, and its title callback +/// receives the same. A parameter another converter takes, one nothing +/// upcasts, and one a route reaching the method leaves alone keep their +/// declared type. +#[tokio::test] +async fn controller_parameters_are_the_entities_drupal_upcasts() { + let (backend, dir) = workspace(¬e_files()).await; + let php = uri_for(&dir, PAGE_CONTROLLER_PATH); + for (marker, expected) in [ + ("$acme_note; // view", "$acme_note = Note\n"), + ("$acme_note; // title", "$acme_note = Note\n"), + ("$note; // edit", "$note = Note\n"), + ("$acme_note; // page", "$acme_note = Note\n"), + ("$acme_note; // deleteTitle", "$acme_note = Note\n"), + ("$acme_note; // revisionTitle", "$acme_note = Note\n"), + ( + "$acme_note_revision; // revisionTitle revision", + "$acme_note_revision = Note\n", + ), + ("$note; // latest", "$note = Note\n"), + ("$acme_note; // content", "$acme_note = Note\n"), + // A hint the entity class does not fit blocks upcasting, and a + // method no route reaches keeps its hint. + ("$acme_note; // blocked", "$acme_note = UserInterface\n"), + ("$acme_note; // unrouted", "$acme_note = EntityInterface\n"), + ] { + let text = hover_before(&backend, &php, PAGE_CONTROLLER, marker).await; + assert!(text.contains(expected), "{marker}: {text}"); + } + // A route's `NULL` default leaves the parameter `NULL` when the path + // gives it no value. + let text = hover_before(&backend, &php, PAGE_CONTROLLER, "$acme_note; // optional").await; + assert!( + text.contains("$acme_note = ?Note\n") || text.contains("$acme_note = Note|null\n"), + "{text}" + ); + // Untyped and left alone: by nothing, by a tempstore's own converter, + // by a route that gives it no value, or by one of the routes that + // reach the method. + for (marker, variable) in [ + ("$acme_note; // raw", "$acme_note"), + ("$note; // draft", "$note"), + ("$note; // missing", "$note"), + ("$note; // shared", "$note"), + ] { + let text = hover_before(&backend, &php, PAGE_CONTROLLER, marker).await; + assert!( + text.contains(&format!(" Date: Wed, 4 Nov 2026 16:02:21 +0100 Subject: [PATCH 15/18] Report a Drupal route parameter whose type hint blocks upcasting A controller or title callback parameter named after an entity type and hinted with a class its entity class is not receives the raw ID, which every route that reaches it passes, so it is reported. Untyped and builtin-typed parameters, and those a route declares in its options, are not. Assisted-by: claude-code/claude-opus-5-5 --- src/diagnostics/collect.rs | 4 + src/diagnostics/drupal_route_parameters.rs | 187 ++++++++++ src/diagnostics/mod.rs | 1 + src/drupal/routing.rs | 5 +- tests/integration/drupal_routes.rs | 404 ++++++++++++++++++++- 5 files changed, 595 insertions(+), 6 deletions(-) create mode 100644 src/diagnostics/drupal_route_parameters.rs diff --git a/src/diagnostics/collect.rs b/src/diagnostics/collect.rs index 43262e8dd..042d0e6a5 100644 --- a/src/diagnostics/collect.rs +++ b/src/diagnostics/collect.rs @@ -158,6 +158,10 @@ static SLOW_COLLECTORS: &[SlowCollector] = &[ "invalid_member_name", Backend::collect_dynamic_member_name_diagnostics_with_context, ), + always( + "invalid_drupal_route_parameter", + Backend::collect_drupal_route_parameter_diagnostics, + ), SlowCollector { name: "invalid_string_key", collect: Backend::collect_invalid_laravel_string_key_diagnostics, diff --git a/src/diagnostics/drupal_route_parameters.rs b/src/diagnostics/drupal_route_parameters.rs new file mode 100644 index 000000000..c8f814ac0 --- /dev/null +++ b/src/diagnostics/drupal_route_parameters.rs @@ -0,0 +1,187 @@ +//! Drupal route parameters whose type hint rejects the raw ID Drupal +//! passes. +//! +//! Drupal loads the entity a parameter stands for only when the route +//! upcasts it (see [`crate::drupal::upcasting`]). A controller parameter +//! named after an entity type and hinted with a class unrelated to its +//! entity class is not upcast, so it is passed the raw ID, which the hint +//! rejects: +//! +//! ```php +//! // node.view: path: '/node/{node}' +//! public function view(UserInterface $node): array // receives '42' +//! ``` +//! +//! The warning gives a verdict only where it is sure, and stays silent +//! otherwise: on title callbacks, on controllers PHPantom cannot reflect +//! the way core does, on parameters the route's `options` declare or an +//! `_entity_*` default may upcast, and on hints whose class hierarchy it +//! cannot load. A parameter is reported only when every route that gives +//! it a value, by its path or a default, passes it the raw ID. + +use tower_lsp_server::gen_lsp_types::*; + +use crate::Backend; +use crate::atom::{Atom, AtomSet}; +use crate::class_lookup::{Fqn, ResolvedClassLoader, is_subtype_of}; +use crate::symbol_map::{DrupalRouteSite, SymbolKind}; +use crate::text_position::offset_to_position; +use crate::types::ClassInfo; + +use super::helpers::{FileDiagnosticContext, make_diagnostic}; + +/// Diagnostic code used for a route parameter whose hint blocks upcasting. +pub(crate) const INVALID_DRUPAL_ROUTE_PARAMETER_CODE: &str = "invalid_drupal_route_parameter"; + +impl Backend { + /// Report each parameter of a route's controller whose class hint + /// rejects the raw ID every route that reaches the method passes it. + pub(crate) fn collect_drupal_route_parameter_diagnostics( + &self, + ctx: &FileDiagnosticContext, + uri: &str, + content: &str, + out: &mut Vec, + ) { + if !self.is_drupal_project() || self.is_drupal_example_or_test_code(uri) { + return; + } + let class_loader = self.resolved_class_loader(&ctx.file); + for class in &ctx.file.classes { + let fqn = class.fqn(); + for method in class.methods.iter() { + let routes = self.drupal_handler_routes(&fqn, &method.name); + if routes.is_empty() { + continue; + } + let handler = format!("{}::{}", fqn.trim_start_matches('\\'), method.name); + for parameter in method.parameters.iter() { + let name = parameter.name.trim_start_matches('$'); + let Some(hint) = parameter + .native_type_hint + .as_ref() + .and_then(crate::drupal::upcasting::hinted_class) + else { + continue; + }; + let mut passing = routes.iter().filter(|route| route.passes(name)).peekable(); + if passing.peek().is_none() + || !passing.all(|route| { + self.passes_rejected_id(route, &handler, name, hint, &class_loader) + }) + { + continue; + } + let Some((start, end)) = parameter_span(ctx, method.name_offset, name) else { + continue; + }; + out.push(make_diagnostic( + Range { + start: offset_to_position(content, start as usize), + end: offset_to_position(content, end as usize), + }, + DiagnosticSeverity::Warning, + INVALID_DRUPAL_ROUTE_PARAMETER_CODE, + format!( + "Drupal passes `${name}` the raw ID, which the `{hint}` hint rejects: no route that reaches this method upcasts it" + ), + )); + } + } + } + } + + /// Whether `route`, which passes the parameter `name` of `handler` + /// (`Class::method`), surely passes it the raw ID its class hint `hint` + /// rejects. `false` whenever that is not certain. + fn passes_rejected_id( + &self, + route: &DrupalRouteSite, + handler: &str, + name: &str, + hint: &str, + class_loader: &ResolvedClassLoader<'_>, + ) -> bool { + // Core reflects only on the method the route dispatches to, which + // `controller` names only when it is a class's. + if !route + .controller + .as_deref() + .is_some_and(|controller| controller.eq_ignore_ascii_case(handler)) + { + return false; + } + // A declared parameter is converted as declared, and an `_entity_*` + // default may upcast it. + if route.declared(name).is_some() || route.entity_type.is_some() { + return false; + } + // A `NULL` default the path does not override is passed as it is. + if route.defaults_to_null(name) + && !crate::drupal::routing::path_parameter_names(&route.path) + .any(|placeholder| placeholder == name) + { + return false; + } + let Some(hinted) = + class_loader(Fqn::new(hint)).filter(|hinted| hierarchy_loads(hinted, class_loader)) + else { + return false; + }; + let entities = self.drupal_entity_types(name); + !entities.is_empty() + && entities.iter().all(|(entity, _)| { + class_loader(Fqn::new(entity)) + .filter(|class| hierarchy_loads(class, class_loader)) + // A subclass is a bundle class, which core's reflection + // does not upcast to, but `hook_entity_type_alter()` can + // make it the entity class. + .is_some_and(|class| { + !is_subtype_of(&class, hint, class_loader) + && !is_subtype_of(&hinted, entity, class_loader) + }) + }) + } +} + +/// Whether every class and interface `class` extends or implements loads, +/// so that subtyping against it is decided rather than cut short. A +/// keyword type is no class to load: PHP's stubs declare `\Traversable` +/// to extend `iterable`, which every content entity reaches. +fn hierarchy_loads(class: &ClassInfo, class_loader: &ResolvedClassLoader<'_>) -> bool { + let mut pending: Vec = class + .parent_class + .into_iter() + .chain(class.interfaces.iter().copied()) + .collect(); + let mut seen = AtomSet::default(); + while let Some(name) = pending.pop() { + if !seen.insert(name) || !crate::php_type::is_class_like_name(name.trim_start_matches('\\')) + { + continue; + } + let Some(ancestor) = class_loader(Fqn::new(&name)) else { + return false; + }; + pending.extend( + ancestor + .parent_class + .into_iter() + .chain(ancestor.interfaces.iter().copied()), + ); + } + true +} + +/// The byte range of the parameter `name` of the method whose name starts +/// at `name_offset`: the first `$name` after it. +fn parameter_span(ctx: &FileDiagnosticContext, name_offset: u32, name: &str) -> Option<(u32, u32)> { + let spans = &ctx.symbol_map.spans; + let from = spans.partition_point(|span| span.start <= name_offset); + spans[from..].iter().find_map(|span| match &span.kind { + SymbolKind::Variable { name: variable } if variable.as_str() == name => { + Some((span.start, span.end)) + } + _ => None, + }) +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index f5b3d9ae2..1a8c59e72 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -261,6 +261,7 @@ mod deprecated; mod destructuring_offsets; mod discarded_results; mod docblock_native_mismatch; +mod drupal_route_parameters; mod dynamic_member_names; mod enum_errors; pub(crate) mod existence_guards; diff --git a/src/drupal/routing.rs b/src/drupal/routing.rs index 330d32531..c479094c1 100644 --- a/src/drupal/routing.rs +++ b/src/drupal/routing.rs @@ -223,7 +223,8 @@ pub(crate) fn routing_routes(content: &str) -> (Vec, Vec controller = handler_of(&value), @@ -269,7 +270,7 @@ pub(crate) fn routing_routes(content: &str) -> (Vec, Vec bool { matches!(value, "" | "~" | "null" | "Null" | "NULL") } diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index c6eea0c3d..06a7185b7 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -840,19 +840,26 @@ const NOTE: &str = r#" Vec<(&'static str, &'static str)> { files(&[ (ENTITY_INTERFACE_PATH, ENTITY_INTERFACE), (CONTENT_ENTITY_INTERFACE_PATH, CONTENT_ENTITY_INTERFACE), + (CONTENT_ENTITY_BASE_PATH, CONTENT_ENTITY_BASE), + (PHP_ITERATION_PATH, PHP_ITERATION), (USER_INTERFACE_PATH, USER_INTERFACE), (NOTE_PATH, NOTE), ("web/modules/custom/acme/acme.routing.yml", NOTE_ROUTING), @@ -1172,3 +1181,390 @@ async fn controller_parameters_are_the_entities_drupal_upcasts() { .await; assert!(text.contains("$acme_note = Note\n"), "{text}"); } + +const TITLE_ROUTING: &str = r#"acme_extra.converted: + path: '/extra/{acme_note}/converted' + defaults: + _controller: '\Drupal\acme_extra\Controller\TitleController::converted' + options: + parameters: + acme_note: + converter: acme_extra.note_converter +acme_extra.titled: + path: '/extra/{acme_note}/titled' + defaults: + _controller: '\Drupal\acme_extra\Controller\TitleController::titledView' + _title_callback: '\Drupal\acme_extra\Controller\TitleController::titledTitle' +acme_extra.declared: + path: '/extra/{note}/declared' + defaults: + _controller: '\Drupal\acme_extra\Controller\TitleController::declaredView' + _title_callback: '\Drupal\acme_extra\Controller\TitleController::declaredTitle' + options: + parameters: + note: + type: entity:acme_note +"#; +const TITLE_ROUTING_PATH: &str = "web/modules/custom/acme_extra/acme_extra.routing.yml"; + +const TITLE_CONTROLLER: &str = r#" Vec { + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri_for(dir, path).as_str(), content, &mut diagnostics); + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(tower_lsp_server::gen_lsp_types::Code::String(code)) + if code == "invalid_drupal_route_parameter" + ) + }) + .map(|diagnostic| diagnostic.range.start) + .collect() +} + +/// A hint the entity class does not fit leaves Drupal passing the raw ID, +/// which is reported; an untyped parameter, one no route declares, and a +/// method no route reaches are not. +#[tokio::test] +async fn a_hint_that_blocks_upcasting_is_reported() { + let (backend, dir) = workspace(¬e_files()).await; + assert_eq!( + blocked_upcasts(&backend, &dir, PAGE_CONTROLLER_PATH, PAGE_CONTROLLER).await, + [position_in(PAGE_CONTROLLER, "UserInterface $acme_note", 14)] + ); +} + +/// A parameter with its own converter, and a title callback, are not +/// judged. +#[tokio::test] +async fn converters_and_title_callbacks_are_not_judged() { + let mut files = note_files(); + files.extend([ + (TITLE_ROUTING_PATH, TITLE_ROUTING), + (TITLE_CONTROLLER_PATH, TITLE_CONTROLLER), + ]); + let (backend, dir) = workspace(&files).await; + assert_eq!( + blocked_upcasts(&backend, &dir, TITLE_CONTROLLER_PATH, TITLE_CONTROLLER).await, + Vec::::new() + ); +} + +const MEMO: &str = " String { + format!( + " (phpantom_lsp::Backend, tempfile::TempDir) { + workspace(&files(&[ + (ENTITY_INTERFACE_PATH, ENTITY_INTERFACE), + (CONTENT_ENTITY_INTERFACE_PATH, CONTENT_ENTITY_INTERFACE), + (CONTENT_ENTITY_BASE_PATH, CONTENT_ENTITY_BASE), + (PHP_ITERATION_PATH, PHP_ITERATION), + (USER_INTERFACE_PATH, USER_INTERFACE), + (NOTE_PATH, NOTE), + (MEMO_PATH, MEMO), + (BROKEN_PATH, BROKEN), + ROUTE_ATTRIBUTE, + (CHECK_ROUTING_PATH, routing), + (path, php), + ])) + .await +} + +/// The warnings the diagnostics of `CheckController`, holding `methods`, +/// report with `routing` declaring its routes. +async fn check_warnings(routing: &str, methods: &str) -> (String, Vec) { + let php = check_controller(methods); + let (backend, dir) = check_workspace(routing, CHECK_CONTROLLER_PATH, &php).await; + let found = blocked_upcasts(&backend, &dir, CHECK_CONTROLLER_PATH, &php).await; + (php, found) +} + +/// What hovering `$acme_note` before `marker` in `CheckController`, +/// holding `methods`, shows with `routing` declaring its routes. +async fn check_hover(routing: &str, methods: &str, marker: &str) -> String { + let php = check_controller(methods); + let (backend, dir) = check_workspace(routing, CHECK_CONTROLLER_PATH, &php).await; + hover_before( + &backend, + &uri_for(&dir, CHECK_CONTROLLER_PATH), + &php, + marker, + ) + .await +} + +/// Whether `hover` shows `$acme_note` as a `Note` or `null`. +fn is_nullable_note(hover: &str) -> bool { + hover.contains("$acme_note = ?Note\n") || hover.contains("$acme_note = Note|null\n") +} + +/// A `Class::method` controller hinting an entity-type-named parameter +/// with an interface unrelated to the entity class gets the raw ID. +#[tokio::test] +async fn a_controller_hint_unrelated_to_the_entity_is_reported() { + let routing = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n"; + let (php, found) = check_warnings( + routing, + " public function view(UserInterface $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, [position_in(&php, "UserInterface $acme_note", 14)]); +} + +/// A `_form` class's `buildForm()` is reflected like a controller. +#[tokio::test] +async fn a_form_hint_unrelated_to_the_entity_is_reported() { + let routing = "acme_check.form:\n path: '/check/{acme_note}/form'\n defaults:\n _form: '\\Drupal\\acme_check\\Form\\CheckForm'\n"; + let path = "web/modules/custom/acme_check/src/Form/CheckForm.php"; + let php = "::new()); +} + +/// A declared `entity:` type converts the parameter as declared, whatever +/// its hint. +#[tokio::test] +async fn a_declared_parameter_with_an_unrelated_hint_is_not_judged() { + let routing = "acme_check.unrelated:\n path: '/check/{acme_note}/unrelated'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::unrelated'\n options:\n parameters:\n acme_note:\n type: entity:acme_note\n"; + let (_, found) = check_warnings( + routing, + " public function unrelated(UserInterface $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A bundle class hint is not reported, since an alter hook can make it +/// the entity class. +#[tokio::test] +async fn a_bundle_class_hint_is_not_reported() { + let routing = "acme_check.bundle:\n path: '/check/{acme_note}/bundle'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::bundle'\n"; + let (_, found) = check_warnings( + routing, + " public function bundle(Memo $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A title callback is never reported, nor reflected by core. +#[tokio::test] +async fn a_title_callback_is_not_reported() { + let routing = "acme_check.titled:\n path: '/check/{acme_note}/titled'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let (_, found) = check_warnings( + routing, + " public function view($acme_note): array { return []; }\n public function title(UserInterface $acme_note): string { return ''; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A title callback on a route whose controller is a service's method is +/// not reported either. +#[tokio::test] +async fn a_title_callback_beside_a_service_controller_is_not_reported() { + let routing = "acme_check.service:\n path: '/check/{acme_note}/service'\n defaults:\n _controller: 'acme_check.pages:show'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let (_, found) = check_warnings( + routing, + " public function title(UserInterface $acme_note): string { return ''; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A function controller is not judged. +#[tokio::test] +async fn a_function_controller_is_not_judged() { + let routing = "acme_check.function:\n path: '/check/{acme_note}/function'\n defaults:\n _controller: 'acme_check_page'\n"; + let path = "web/modules/custom/acme_check/acme_check.module"; + let php = "::new() + ); +} + +/// A route whose controller class is not found gives its title callback +/// no verdict. +#[tokio::test] +async fn a_route_whose_controller_is_not_found_is_not_judged() { + let routing = "acme_check.missing:\n path: '/check/{acme_note}/missing'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\Missing::view'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let (_, found) = check_warnings( + routing, + " public function title(UserInterface $acme_note): string { return ''; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A hint whose ancestors are not all found is not judged. +#[tokio::test] +async fn a_hint_with_missing_ancestors_is_not_judged() { + let routing = "acme_check.broken:\n path: '/check/{acme_note}/broken'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::broken'\n"; + let (_, found) = check_warnings( + routing, + " public function broken(Broken $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +/// A `NULL` default the path does not override is passed as it is. +#[tokio::test] +async fn a_null_default_outside_the_path_is_not_judged() { + let routing = "acme_check.null:\n path: '/check/null'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n acme_note: NULL\n"; + let (_, found) = check_warnings( + routing, + " public function view(UserInterface $acme_note): array { return []; }\n", + ) + .await; + assert_eq!(found, Vec::::new()); +} + +const CHECK_VIEW_ROUTING: &str = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n"; + +/// A nullable hint keeps `null` in the upcast type. +#[tokio::test] +async fn an_upcast_parameter_with_a_nullable_hint_may_be_null() { + let text = check_hover( + CHECK_VIEW_ROUTING, + " public function view(?Note $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A parameter defaulting to `NULL` keeps `null` in the upcast type. +#[tokio::test] +async fn an_upcast_parameter_defaulting_to_null_may_be_null() { + let text = check_hover( + CHECK_VIEW_ROUTING, + " public function view(Note $acme_note = NULL): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A routing file's `NULL` default for a path parameter keeps `null` in +/// the upcast type. +#[tokio::test] +async fn an_upcast_parameter_a_route_defaults_to_null_may_be_null() { + let routing = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n acme_note: NULL\n"; + let text = check_hover( + routing, + " public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A `#[Route]`'s `NULL` default keeps `null` in the upcast type. +#[tokio::test] +async fn an_upcast_parameter_a_route_attribute_defaults_to_null_may_be_null() { + let text = check_hover( + "", + " #[Route(path: '/check/{acme_note}', name: 'acme_check.view', defaults: ['acme_note' => NULL])]\n public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(is_nullable_note(&text), "{text}"); +} + +/// A quoted `'~'`, `'null'`, `"null"` or `''` default is a string, not +/// `NULL`. +#[tokio::test] +async fn a_quoted_null_default_is_a_string() { + for value in ["'~'", "'null'", "\"null\"", "''"] { + let routing = format!( + "acme_check.view:\n path: '/check/{{acme_note}}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n acme_note: {value}\n" + ); + let text = check_hover( + &routing, + " public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n", + "$acme_note; // view", + ) + .await; + assert!(text.contains("$acme_note = Note\n"), "{value}: {text}"); + } +} From 961c76fc86b87aaf1131444b2859e7debfc7c0c2 Mon Sep 17 00:00:00 2001 From: "Giuseppe.Esposito" Date: Wed, 4 Nov 2026 16:04:10 +0100 Subject: [PATCH 16/18] Read the Drupal route parameters a template's path() passes The keys of the hash a Twig path() or url() passes after a Drupal route name go to the placeholders of that route's path and count among their references. Assisted-by: claude-code/claude-opus-5-5 --- src/drupal/routing.rs | 117 +++++++++++++++++++++++++++++ src/drupal/tests.rs | 41 ++++++++++ src/resource_navigation.rs | 14 +++- tests/integration/drupal_routes.rs | 38 ++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) diff --git a/src/drupal/routing.rs b/src/drupal/routing.rs index c479094c1..8be113bb3 100644 --- a/src/drupal/routing.rs +++ b/src/drupal/routing.rs @@ -76,6 +76,100 @@ pub(crate) fn path_parameter_names(path: &str) -> impl Iterator { .map(move |(start, end)| &path[start..end]) } +/// The spans of the route parameters a Twig template's `path()` and +/// `url()` pass: the keys of the hash after each route name `routes` +/// holds, as `{{ path('entity.node.canonical', {node: 1, 'mode': 'x'}) }}` +/// writes them. +pub(crate) fn twig_parameter_spans(content: &str, routes: &[SymbolSpan]) -> Vec { + let bytes = content.as_bytes(); + let skip_space = |mut at: usize| { + while bytes.get(at).is_some_and(u8::is_ascii_whitespace) { + at += 1; + } + at + }; + let mut spans = Vec::new(); + for route in routes { + let SymbolKind::StringKey { + kind: StringKeyKind::DrupalRoute, + key: name, + .. + } = &route.kind + else { + continue; + }; + // Past the route name's closing quote, a comma and a hash. + let comma = skip_space(route.end as usize + 1); + if bytes.get(comma) != Some(&b',') { + continue; + } + let open = skip_space(comma + 1); + if bytes.get(open) != Some(&b'{') { + continue; + } + let mut at = open + 1; + let mut depth = 1usize; + let mut wants_key = true; + while at < bytes.len() && depth > 0 { + if depth == 1 && wants_key { + at = skip_space(at); + let (start, end) = match bytes.get(at) { + Some(quote @ (b'\'' | b'"')) => { + let Some(len) = memchr::memchr(*quote, &bytes[at + 1..]) else { + break; + }; + (at + 1, at + 1 + len) + } + Some(byte) if byte.is_ascii_alphabetic() || *byte == b'_' => { + let len = bytes[at..] + .iter() + .take_while(|byte| byte.is_ascii_alphanumeric() || **byte == b'_') + .count(); + (at, at + len) + } + // A computed key, or the end of the hash. + _ => break, + }; + let after = if bytes[at] == b'\'' || bytes[at] == b'"' { + end + 1 + } else { + end + }; + let colon = skip_space(after); + if bytes.get(colon) != Some(&b':') || start == end { + break; + } + spans.push(SymbolSpan { + start: start as u32, + end: end as u32, + kind: SymbolKind::StringKey { + kind: StringKeyKind::DrupalRouteParameter, + key: route_parameter_key(name, &content[start..end]), + is_write: false, + is_optional: false, + }, + }); + wants_key = false; + at = colon + 1; + continue; + } + match bytes[at] { + quote @ (b'\'' | b'"') => { + at = memchr::memchr(quote, &bytes[at + 1..]) + .map_or(bytes.len(), |len| at + len + 2); + continue; + } + b'{' | b'[' | b'(' => depth += 1, + b'}' | b']' | b')' => depth -= 1, + b',' if depth == 1 => wants_key = true, + _ => {} + } + at += 1; + } + } + spans +} + /// The method a `_controller`, `_form`, or `_title_callback` value names, /// as [`DrupalRouteSite::controller`] spells it: `Class::method` as it is, /// and a class alone as its `__invoke()`. A `service:method` is `None`. @@ -369,6 +463,29 @@ mod tests { ); } + #[test] + fn twig_hashes_pass_the_parameters_of_their_route() { + let twig = "{{ path('acme.note', {acme_note: note.id, 'mode': {a: 1}|first, other: x(1, 2)}) }}\n{{ url('acme.note') }}{{ path('acme.note', vars) }}"; + let routes = crate::symfony::routes::twig_route_spans(twig, StringKeyKind::DrupalRoute); + let found: Vec<(&str, String)> = twig_parameter_spans(twig, &routes) + .iter() + .map(|span| { + let SymbolKind::StringKey { key, .. } = &span.kind else { + unreachable!() + }; + (&twig[span.start as usize..span.end as usize], key.clone()) + }) + .collect(); + assert_eq!( + found, + [ + ("acme_note", "acme.note#acme_note".to_string()), + ("mode", "acme.note#mode".to_string()), + ("other", "acme.note#other".to_string()), + ] + ); + } + fn names(path: &str) -> Vec<&str> { path_parameter_names(path).collect() } diff --git a/src/drupal/tests.rs b/src/drupal/tests.rs index f1ddc411c..9d3da8564 100644 --- a/src/drupal/tests.rs +++ b/src/drupal/tests.rs @@ -347,3 +347,44 @@ fn route_patterns_match_any_run_for_each_star() { assert!(matches_route_pattern("exact", "exact")); assert!(!matches_route_pattern("exact", "exactly")); } + +/// The key of the hash a Drupal template's `path()` passes is a parameter +/// of the route, never a Twig variable, also in a project that reads +/// Twig's variables; the value after it stays a variable. +#[test] +fn a_path_hash_key_is_a_route_parameter_not_a_variable() { + let (backend, dir) = drupal_backend(&[]); + backend.set_is_twig(true); + let content = "{{ path('entity.node.canonical', {node: node.id}) }}\n"; + let template = uri( + dir.path(), + "web/modules/custom/acme/templates/acme.html.twig", + ); + backend.update_resource_symbol_index(&template, content); + let map = backend + .symbol_map_for(&template) + .expect("the template's map"); + let kinds_at = |offset: usize| -> Vec<(StringKeyKind, String)> { + map.spans + .iter() + .filter(|span| span.start as usize == offset) + .filter_map(|span| match &span.kind { + crate::symbol_map::SymbolKind::StringKey { kind, key, .. } => { + Some((*kind, key.clone())) + } + _ => None, + }) + .collect() + }; + assert_eq!( + kinds_at(content.find("node:").unwrap()), + [( + StringKeyKind::DrupalRouteParameter, + "entity.node.canonical#node".to_string() + )] + ); + assert_eq!( + kinds_at(content.find("node.id").unwrap()), + [(StringKeyKind::TwigVariable, "node".to_string())] + ); +} diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index 6dde1b982..aaef76e43 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -257,7 +257,13 @@ impl Backend { if self.is_twig_project() { self.twig_template_spans(uri, content, is_symfony) } else if self.is_drupal_project() { - crate::symfony::routes::twig_route_spans(content, StringKeyKind::DrupalRoute) + let mut spans = crate::symfony::routes::twig_route_spans( + content, + StringKeyKind::DrupalRoute, + ); + let parameters = crate::drupal::routing::twig_parameter_spans(content, &spans); + spans.extend(parameters); + spans } else { Vec::new() } @@ -418,7 +424,11 @@ impl Backend { None }; if let Some(kind) = route_kind { - spans.extend(crate::symfony::routes::twig_route_spans(content, kind)); + let routes = crate::symfony::routes::twig_route_spans(content, kind); + spans.extend(crate::drupal::routing::twig_parameter_spans( + content, &routes, + )); + spans.extend(routes); } if is_symfony { spans.extend(crate::symfony::translations::twig_translation_spans( diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index 06a7185b7..b9dabe289 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -1568,3 +1568,41 @@ async fn a_quoted_null_default_is_a_string() { assert!(text.contains("$acme_note = Note\n"), "{value}: {text}"); } } + +const BOOK_CONTROLLER: &str = r#" Date: Wed, 4 Nov 2026 16:05:06 +0100 Subject: [PATCH 17/18] Show Drupal route parameters and upcasting in the playground and the changelog The playground's routing file, controllers, links file, and template carry Try: sites for route parameters and an upcast controller parameter, with runtime checks, and DR5 leaves the backlog. Assisted-by: claude-code/claude-opus-5-5 --- docs/CHANGELOG.md | 2 +- docs/todo.md | 3 +- docs/todo/drupal.md | 51 +++++++------------ examples/drupal/README.md | 15 ++++++ examples/drupal/assertions.php | 29 +++++++++++ .../modules/custom/acme/acme.links.menu.yml | 2 +- .../web/modules/custom/acme/acme.routing.yml | 12 +++++ .../src/Controller/GreetingController.php | 4 +- .../acme/src/Controller/NoteController.php | 35 +++++++++++++ .../web/modules/custom/acme/src/CoreLinks.php | 30 +++++++++++ .../acme/templates/acme-greeting.html.twig | 3 +- 11 files changed, 146 insertions(+), 40 deletions(-) create mode 100644 examples/drupal/web/modules/custom/acme/src/Controller/NoteController.php create mode 100644 examples/drupal/web/modules/custom/acme/src/CoreLinks.php diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cd91f8617..c10393384 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -48,7 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Symfony services that bundles and the container builder declare.** The service IDs and parameters an installed bundle declares in its own configuration complete, jump to their declaration, and type `$container->get()`, and so do the ones a kernel, an extension, or a compiler pass declares with `register()`, `setAlias()`, `setDefinition()`, `setParameter()`, or `getKernelParameters()`. A parameter set under a name built at runtime, like `'app.' . $key`, leaves the names it can produce unreported. A configurator's `parent()` and the `%name%` placeholders in its arguments are read as the service and parameter they name. - **Drupal services.** In a Drupal project, the service IDs and container parameters that core, contributed, and custom modules declare in their `*.services.yml` complete, jump to their declaration, and list their references wherever a services file, `$container->get()`, or `\Drupal::service()` names them, and a lookup is typed by the class the service is built from, or by the class or interface a lookup names by `::class`. Rename leaves core and contributed extensions alone, as it does vendor code. Contributed by @giuseppe-esposito-87. - **Drupal entity types.** In a Drupal project, an entity type ID declared by an entity class's attribute or annotation completes, jumps to its declaration, and lists its references wherever the entity type manager is asked for it, and `getStorage()` is typed as that entity type's storage, so `load()`, `loadMultiple()`, `create()`, and the other loads return the entity class. An entity query started by `\Drupal::entityQuery()` or a storage's `getQuery()` gives the IDs of that entity type, or their number after `count()`. Contributed by @giuseppe-esposito-87. -- **Drupal route names.** In a Drupal project, a route declared by a routing file, a `#[Route]` attribute, or a route builder completes, jumps to its declaration, and lists its references wherever code, a links file, or a template names it, from `Url::fromRoute()`, `$this->redirect()`, and `$form_state->setRedirect()` to `route_name:` and `path()`. A route name nothing declares is reported. Contributed by @giuseppe-esposito-87. +- **Drupal route names.** In a Drupal project, a route declared by a routing file, a `#[Route]` attribute, or a route builder completes, jumps to its declaration, and lists its references wherever code, a links file, or a template names it, from `Url::fromRoute()`, `$this->redirect()`, and `$form_state->setRedirect()` to `route_name:` and `path()`. A route name nothing declares is reported. The parameter keys those calls, links files, and templates pass jump to the placeholder of the route's path and complete from it, and a controller's parameter is typed as the entity Drupal upcasts it to, with a warning when a controller's type hint unrelated to the entity keeps Drupal from upcasting it. Contributed by @giuseppe-esposito-87. - **Symfony route names.** In a Symfony project, a route name declared by a `#[Route]` attribute, the routing YAML or XML, or a PHP routing configurator completes, jumps to its declaration, and lists its references wherever a controller or a Twig template builds a URL to it, from `generateUrl()` and `redirectToRoute()` to `path()` and `url()`. Names are followed through the imports that load them, bundles' own routing files included, along with the ones Symfony derives from a route, such as `contact.en` for a localized one. A route name nothing declares is reported, unless the router is configured to load its routes some other way. Contributed by @sidux. - **Symfony translations.** In a Symfony project, a translation key declared in a YAML or XLIFF catalogue completes, jumps to its entry in every locale, and lists its references wherever PHP or a Twig template translates it, from `trans()`, `TranslatableMessage`, and `t()` to the `trans` filter. A key is looked up in the domain the call names, and a domain jumps to its catalogues. A key spelled like one (`demo.title`) that nothing declares is reported, in a domain the project's own catalogues hold and no installed bundle ships. Contributed by @sidux. - **Symfony Twig templates.** In a Symfony project, a template name completes, jumps to its file, and lists its references wherever a controller, an email, or another template names it, from `render()` and `#[Template]` to `extends` and `include`. A template the application's `templates/` directory does not hold is reported, with a quick fix that creates it, unless the project has a Twig loader of its own that could serve it. Contributed by @sidux. diff --git a/docs/todo.md b/docs/todo.md index 07761e386..f7a66d611 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -33,7 +33,6 @@ contributor even though it's short. | # | Item | Impact | Complexity | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- | | DR9 | [Drupal directories outside `contrib` and `custom`](todo/drupal.md#dr9-drupal-directories-outside-contrib-and-custom) | Medium-High | Low-Medium | -| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium | | DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium | | DR8 | [Permission and configuration names](todo/drupal.md#dr8-permission-and-configuration-names) | Low-Medium | Medium | | DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High | @@ -127,7 +126,6 @@ ships, remove every row that names it. | DR11 | [Form and render callbacks](todo/drupal.md#dr11-form-and-render-callbacks) | Low-Medium | Low-Medium | | 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 | -| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium | | DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium | | DR10 | [Plugin IDs](todo/drupal.md#dr10-plugin-ids) | Medium | Medium | | DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium | @@ -138,6 +136,7 @@ ships, remove every row that names it. | DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium | | DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium | | DR18 | [Warn on unknown entity type IDs](todo/drupal.md#dr18-warn-on-unknown-entity-type-ids) | Medium | Low-Medium | +| DR20 | [Complete route names built in code](todo/drupal.md#dr20-complete-route-names-built-in-code) | Low-Medium | Medium | | DR21 | [Return types core's docblocks leave wide](todo/drupal.md#dr21-return-types-cores-docblocks-leave-wide) | Medium | Low-Medium | ## Yii diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 4bcb985a5..5fcaf8850 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -54,40 +54,6 @@ names and report a misspelt one. --- -## DR5. Route names - -**Impact: Medium-High · Complexity: Medium** - -```php -Url::fromRoute('acme.order_view', ['order' => $id]); -$form_state->setRedirect('acme.order_list'); -``` - -```yaml -acme.order_view: - path: '/order/{order}' - defaults: - _controller: '\Drupal\acme\Controller\OrderController::view' -``` - -Routes are declared in `.routing.yml`. Complete, navigate, and -find references for route names in `Url::fromRoute()`, -`Link::createFromRoute()`, `$this->redirect()`, -`$form_state->setRedirect()`, and the menu, tab, and action YAML files, -and report a name nothing declares. The `_controller`, `_form`, and -`_entity_form` values may already navigate through the resource symbol -maps that read `Class::method` strings in YAML; check before building -anything for them. The parameter keys are the same problem as Symfony's -[F27](lsp-features.md#f27-symfony-route-parameters). - -A path parameter named after an entity type (`/node/{node}`) is upcast -by Drupal's parameter converters, so the controller method's `$node` -receives a `NodeInterface`, not the ID. Type an untyped controller -parameter from the route that names it, and check a typed one against -it. - ---- - ## DR6. Hooks **Impact: Medium-High · Complexity: Medium** @@ -343,6 +309,23 @@ site where an implementation of that hook may add one stays silent. --- +## DR20. Complete route names built in code + +**Impact: Low-Medium · Complexity: Medium** + +```php +Url::fromRoute('entity.node.canonical', ['node' => $nid]); +Url::fromRoute('view.content.page_1'); +``` + +Entity routes and Views routes are accepted without a warning, but +neither completes nor goes to a definition, since no routing file declares +them. Read them from where Drupal builds them: each entity type's link +templates (`entity..`) and each view's displays +(`view..`). + +--- + ## DR21. Return types core's docblocks leave wide **Impact: Medium · Complexity: Low-Medium** diff --git a/examples/drupal/README.md b/examples/drupal/README.md index 64166e174..daf94f117 100644 --- a/examples/drupal/README.md +++ b/examples/drupal/README.md @@ -49,6 +49,21 @@ Run `composer install` here first. Composer puts core and contrib into `templates/acme-greeting.html.twig` included. Complete a route name inside `redirect('')`, `Url::fromRoute('')`, or after `route_name:` in a links file; core's routes are offered too. A route name nothing declares is + reported, except one of the shapes core builds routes in: in + `web/modules/custom/acme/src/CoreLinks.php`, `'entity.node.canonical'`, + which core's entity route provider builds, and `'view.content.page_1'`, + which Views builds for the content view's page, are not. +- Route parameters navigate from the keys that pass them to the placeholder + of the route's path. In `worldLink()`, go to the definition of `'name'`, + which lands on `{name}` in `acme.routing.yml`, or complete a key inside the + parameters array; the `route_parameters:` of `acme.links.menu.yml` and the + `path()` hash in `templates/acme-greeting.html.twig` go there too. +- A controller's parameter is typed as the entity Drupal upcasts it to. In + `web/modules/custom/acme/src/Controller/NoteController.php`, hover + `$acme_note` in `view()`, named after the `acme_note` entity type and + hinted with an interface `Note` implements, or in the untyped `title()`, + the same route's title callback: both are a `Note`. A controller hint + unrelated to the entity class, which leaves Drupal passing the raw ID, is reported. ## What the Symfony support already reads diff --git a/examples/drupal/assertions.php b/examples/drupal/assertions.php index d4367ba9e..dfa82a607 100644 --- a/examples/drupal/assertions.php +++ b/examples/drupal/assertions.php @@ -14,6 +14,7 @@ require_once __DIR__ . '/vendor/autoload.php'; use Drupal\acme\Controller\GreetingController; +use Drupal\acme\Controller\NoteController; use Drupal\acme\Entity\Note; use Drupal\acme\Greeter; use Drupal\acme\Hook\AcmeHooks; @@ -28,6 +29,7 @@ use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\ContentEntityTypeInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManager; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\Extension\InfoParser; @@ -136,6 +138,21 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string check("the menu link's parameters are the ones the route's path declares", array_keys($menuLinks['acme.greeting']['route_parameters'] ?? []) === ['name']); preg_match_all("/path\\('([^']+)'/", (string) file_get_contents("$module/templates/acme-greeting.html.twig"), $namedInTwig); check('the acme_greeting template links to a declared route', $namedInTwig[1] !== [] && array_diff($namedInTwig[1], array_keys($routes)) === []); +check("the template passes the name parameter acme.greeting's path declares", str_contains((string) file_get_contents("$module/templates/acme-greeting.html.twig"), "path('acme.greeting', {'name': 'World'})")); +check('worldLink() passes the name parameter the path declares', str_contains($controllerSource, "Url::fromRoute('acme.greeting', ['name' => 'World'])")); + +// The routes CoreLinks names are built by core, not declared in a routing file. +$coreLinksSource = (string) file_get_contents("$module/src/CoreLinks.php"); +$nodeEntitySource = (string) file_get_contents("$web/core/modules/node/src/Entity/Node.php"); +$nodeRoutesSource = (string) file_get_contents("$web/core/modules/node/src/Entity/NodeRouteProvider.php"); +$entityRoutesSource = (string) file_get_contents("$web/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php"); +$contentView = Yaml::decode((string) file_get_contents("$web/core/modules/node/config/optional/views.view.content.yml")); +check('CoreLinks passes entity.node.canonical its node parameter', str_contains($coreLinksSource, "Url::fromRoute('entity.node.canonical', ['node' => 1])")); +check("DefaultHtmlRouteProvider builds entity..canonical from the entity type's ID", str_contains($entityRoutesSource, '$collection->add("entity.{$entity_type_id}.canonical", $canonical_route);')); +check("node's html route provider is NodeRouteProvider, a DefaultHtmlRouteProvider", str_contains($nodeEntitySource, "'html' => NodeRouteProvider::class") && str_contains($nodeRoutesSource, 'class NodeRouteProvider extends DefaultHtmlRouteProvider')); +check("node's canonical link takes the {node} parameter", str_contains($nodeEntitySource, "'canonical' => '/node/{node}'")); +check('CoreLinks names view.content.page_1', str_contains($coreLinksSource, "Url::fromRoute('view.content.page_1')")); +check('node ships the content view with the page_1 page display', ($contentView['id'] ?? '') === 'content' && ($contentView['display']['page_1']['display_plugin'] ?? '') === 'page'); // ─── Entity types ─────────────────────────────────────────────────────────── @@ -156,6 +173,18 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string 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');")); +// 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()). +[$noteControllerClass, $noteControllerMethod] = explode('::', ltrim($routes['acme.note']['defaults']['_controller'] ?? '', '\\')) + [1 => '']; +$noteView = new ReflectionMethod(NoteController::class, 'view'); +$noteParameter = $noteView->getParameters()[0]; +check('acme.note points at NoteController::view()', $noteControllerClass === NoteController::class && $noteControllerMethod === 'view'); +check('acme.note declares the {acme_note} parameter', str_contains($routes['acme.note']['path'], '{acme_note}')); +check('NoteController::view() names its parameter after the acme_note entity type', $noteParameter->getName() === ($noteType['id'] ?? null)); +check('NoteController::view() hints the parameter with an interface Note implements, so Drupal upcasts it', $noteParameter->getType()?->getName() === EntityInterface::class && is_subclass_of(Note::class, EntityInterface::class)); +check("acme.note's title callback is NoteController::title(), which receives the same parameter", ltrim($routes['acme.note']['defaults']['_title_callback'] ?? '', '\\') === NoteController::class . '::title' && (new ReflectionMethod(NoteController::class, 'title'))->getParameters()[0]->getName() === 'acme_note'); + // ─── Hooks and theme hooks ────────────────────────────────────────────────── require_once "$module/acme.module"; diff --git a/examples/drupal/web/modules/custom/acme/acme.links.menu.yml b/examples/drupal/web/modules/custom/acme/acme.links.menu.yml index b455fb654..7a9c2b436 100644 --- a/examples/drupal/web/modules/custom/acme/acme.links.menu.yml +++ b/examples/drupal/web/modules/custom/acme/acme.links.menu.yml @@ -1,5 +1,5 @@ # Try: go to the definition of acme.greeting, or complete a route name after -# route_name:. +# route_name:. The name under route_parameters: goes to the route's {name}. acme.greeting: title: 'Greeting' route_name: acme.greeting diff --git a/examples/drupal/web/modules/custom/acme/acme.routing.yml b/examples/drupal/web/modules/custom/acme/acme.routing.yml index 0d7af79ee..7f9f9f3f1 100644 --- a/examples/drupal/web/modules/custom/acme/acme.routing.yml +++ b/examples/drupal/web/modules/custom/acme/acme.routing.yml @@ -1,4 +1,6 @@ acme.greeting: + # Try: find the references of {name}, the parameter this path declares, + # to see the calls, the menu link, and the template that pass it. path: '/acme/greeting/{name}' defaults: # Try: go to the definition of GreetingController, or of greet after the @@ -8,3 +10,13 @@ acme.greeting: _title: 'Greeting' requirements: _permission: 'access content' + +acme.note: + path: '/acme/note/{acme_note}' + defaults: + # Try: hover $acme_note in NoteController::view() and title(). Drupal + # loads the Note entity for the {acme_note} parameter before either runs. + _controller: '\Drupal\acme\Controller\NoteController::view' + _title_callback: '\Drupal\acme\Controller\NoteController::title' + requirements: + _permission: 'access content' 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 f4a88dd3a..2d84da338 100644 --- a/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php +++ b/examples/drupal/web/modules/custom/acme/src/Controller/GreetingController.php @@ -51,7 +51,9 @@ public function greetTheWorld(): RedirectResponse public function worldLink(): Link { // Try: find the references of 'acme.greeting' to see every place that - // links or redirects to it, the links and Twig files included. + // links or redirects to it, the links and Twig files included. Go to + // the definition of 'name', which lands on {name} in the route's path, + // or complete a parameter key inside the array. $url = Url::fromRoute('acme.greeting', ['name' => 'World']); return Link::createFromRoute($this->t('Greet the world'), 'acme.greeting', $url->getRouteParameters()); diff --git a/examples/drupal/web/modules/custom/acme/src/Controller/NoteController.php b/examples/drupal/web/modules/custom/acme/src/Controller/NoteController.php new file mode 100644 index 000000000..e140e4749 --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/Controller/NoteController.php @@ -0,0 +1,35 @@ + + */ + public function view(EntityInterface $acme_note): array + { + // Try: hover $acme_note. The parameter is named after the acme_note + // entity type and hinted with an interface Note implements, so Drupal + // upcasts the ID in the path to the Note itself. + return ['#markup' => $acme_note->label()]; + } + + /** + * The title of the note page. + */ + public function title($acme_note): string + { + // Try: hover $acme_note. The title callback receives the same Note, + // though nothing types it here. + return (string) $acme_note->label(); + } +} diff --git a/examples/drupal/web/modules/custom/acme/src/CoreLinks.php b/examples/drupal/web/modules/custom/acme/src/CoreLinks.php new file mode 100644 index 000000000..e2466269e --- /dev/null +++ b/examples/drupal/web/modules/custom/acme/src/CoreLinks.php @@ -0,0 +1,30 @@ + + */ + public function urls(): array + { + // Try: neither route name is reported, though no routing file + // declares it. Core's entity route provider builds + // entity..canonical for every entity type with a canonical + // link, and Views builds view.. for a page display, + // here the page_1 display of node's content view. A name of neither + // shape, such as 'entity.nod', is reported. + return [ + Url::fromRoute('entity.node.canonical', ['node' => 1]), + Url::fromRoute('view.content.page_1'), + ]; + } +} diff --git a/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig b/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig index 7ef3e48cf..745899960 100644 --- a/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig +++ b/examples/drupal/web/modules/custom/acme/templates/acme-greeting.html.twig @@ -10,6 +10,7 @@ * acme_theme adds in preprocess. */ #} -{# Try: go to the definition of 'acme.greeting' in path(). #} +{# Try: go to the definition of 'acme.greeting' in path(), or of 'name' in the + parameters after it, which lands on {name} in acme.routing.yml. #} {{ greeting }}

{{ 'Greet the world'|t }} From 248e052135ad1bb4cf4fbbe8a2d93f2f50540f1e Mon Sep 17 00:00:00 2001 From: Anders Jenbo Date: Thu, 5 Nov 2026 16:18:01 +0100 Subject: [PATCH 18/18] Fix Drupal route review findings: renamed entity routes, bundle-class docblocks Read the routes an entity route provider renames with $routes->add() even when its file never imports Symfony's routing classes, so core's node.add and node.add_page are declared, and count a route returned by a call as added. Keep a declared parameter type that already names a subclass of the upcast entity class, such as a bundle class in a docblock, instead of widening it to the entity class. Sort the template spans of a Drupal project that is not a Twig project. Note in DR20 the route names the shape rules leave unjudged. Co-Authored-By: Claude Opus 5.5 --- docs/todo/drupal.md | 9 ++++ src/resource_navigation.rs | 1 + src/symbol_map/extraction/drupal.rs | 44 ++++++++++++++++--- .../variable/forward_walk/param_seeding.rs | 20 ++++++++- tests/integration/drupal_routes.rs | 12 +++++ 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/docs/todo/drupal.md b/docs/todo/drupal.md index 5fcaf8850..c2c926c9b 100644 --- a/docs/todo/drupal.md +++ b/docs/todo/drupal.md @@ -324,6 +324,15 @@ them. Read them from where Drupal builds them: each entity type's link templates (`entity..`) and each view's displays (`view..`). +The same reading would narrow what goes unreported. Any `view..` +is accepted by its shape alone, so a misspelt view or display is not +reported. And a route builder that adds a route under a name held in a +variable or returned by a call (`$collection->add($route_name, $route)`, +as Views and JSON:API do) leaves every name under its extension +(`views.*`, `jsonapi.*`) unjudged. Following the variable to the string +the method assigns it (`"view.$view_id.$display_id"`) would keep only that +shape. + --- ## DR21. Return types core's docblocks leave wide diff --git a/src/resource_navigation.rs b/src/resource_navigation.rs index aaef76e43..2194eee3b 100644 --- a/src/resource_navigation.rs +++ b/src/resource_navigation.rs @@ -263,6 +263,7 @@ impl Backend { ); let parameters = crate::drupal::routing::twig_parameter_spans(content, &spans); spans.extend(parameters); + spans.sort_by_key(|span| span.start); spans } else { Vec::new() diff --git a/src/symbol_map/extraction/drupal.rs b/src/symbol_map/extraction/drupal.rs index b5f80183d..e31fb87c8 100644 --- a/src/symbol_map/extraction/drupal.rs +++ b/src/symbol_map/extraction/drupal.rs @@ -457,7 +457,8 @@ enum BuiltName { /// Record the routes a route builder adds: a class or trait whose file /// uses Symfony's routing component, as the classes a routing file's /// `route_callbacks` name, route subscribers, entity route providers, and -/// the traits they share do. `$collection->add(NAME, $route)`, +/// the traits they share do, or core's entity routing, as an entity route +/// provider that extends core's does. `$collection->add(NAME, $route)`, /// `$routes[NAME] = new Route(…)` (or a variable holding one), and an /// array literal's `NAME => new Route(…)` add the route `NAME`: written /// out, it is declared; built from parts, its shape is kept as a pattern. @@ -469,7 +470,13 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( name_offset: u32, ctx: &mut ExtractionCtx<'_>, ) { - if !ctx.drupal || !ctx.content.contains("Symfony\\Component\\Routing\\") { + // An entity route provider that extends core's renames the routes it + // inherits with `$routes->add('node.add', $route)`, and needs no + // Symfony import to do it. + if !ctx.drupal + || !(ctx.content.contains("Symfony\\Component\\Routing\\") + || ctx.content.contains("Drupal\\Core\\Entity\\Routing\\")) + { return; } let mut found = BuilderSites::default(); @@ -501,13 +508,18 @@ pub(in crate::symbol_map::extraction) fn emit_drupal_route_builder_spans( _ => false, }; // A route collection's `add()` takes the route second: a `new Route`, - // or a variable holding one, a parameter included, which the file - // alone cannot type. Any other `add()` adds something else. + // or a variable or a call holding one (`static::getEntryPointRoute()`), + // a parameter included, which the file alone cannot type. Any other + // `add()` adds something else. let mut names: Vec<&Expression<'_>> = found .added .into_iter() .filter(|(_, route)| { - holds_route(route) || matches!(route, Expression::Variable(Variable::Direct(_))) + holds_route(route) + || matches!( + route, + Expression::Variable(Variable::Direct(_)) | Expression::Call(_) + ) }) .map(|(name, _)| name) .collect(); @@ -1062,6 +1074,7 @@ class SearchPageRoutes { $routes["search.view_$id"] = new Route('/search/' . $id); $routes['search.help_' . $id] = new Route('/help'); $collection->add('search.literal', new Route('/x')); + $collection->add('search.called', static::route($id)); $collection->add("{$name}.bc", new Route('/y')); $collection->add($name, new Route('/z')); $routes['not_a_route'] = new \ArrayObject(); @@ -1075,7 +1088,7 @@ class SearchPageRoutes { let (mut declared, mut patterns) = built(php); declared.sort(); patterns.sort(); - assert_eq!(declared, ["search.literal", "search.view"]); + assert_eq!(declared, ["search.called", "search.literal", "search.view"]); // `"{$name}.bc"` and `$name` start with no written-out text, so // they stand for the extension's own routes. assert_eq!(patterns, ["search.*", "search.help_*", "search.view_*"]); @@ -1398,6 +1411,25 @@ final class About { ); } + #[test] + fn an_entity_route_provider_declares_the_routes_it_renames() { + let php = r#"get('entity.node.add_form'); + $routes->remove('entity.node.add_form'); + $routes->add('node.add', $addFormRoute); + return $routes; + } +} +"#; + assert_eq!(built(php), (vec!["node.add".to_string()], Vec::new())); + } + #[test] fn a_file_without_symfony_routes_builds_none() { let php = "add('acme.thing', 1); }\n}\n"; diff --git a/src/type_engine/variable/forward_walk/param_seeding.rs b/src/type_engine/variable/forward_walk/param_seeding.rs index d18e513d3..ab43252ad 100644 --- a/src/type_engine/variable/forward_walk/param_seeding.rs +++ b/src/type_engine/variable/forward_walk/param_seeding.rs @@ -167,6 +167,7 @@ pub(crate) fn seed_params<'b>( if let Some(entity) = drupal_upcast_entity( &pname, native_type.as_ref(), + ¶m_results, crate::parser::param_default_is_null(param), method_name, &mut drupal_upcasts, @@ -203,10 +204,13 @@ pub(crate) fn seed_params<'b>( /// it (see [`crate::drupal::upcasting`]) and its native hint, if any, /// accepts the entity: the entity class, or `null` too when the hint is /// nullable, the parameter defaults to `null`, or a route's default leaves -/// it `NULL`. `upcasts` caches the method's upcast parameters. +/// it `NULL`. `None` when `declared`, the type the signature or the +/// docblock gives it, already names a subclass of the entity class, such as +/// a bundle class. `upcasts` caches the method's upcast parameters. fn drupal_upcast_entity( name: &str, native_type: Option<&PhpType>, + declared: &[ResolvedType], defaults_to_null: bool, method_name: Option<&str>, upcasts: &mut Option>, @@ -229,6 +233,20 @@ fn drupal_upcast_entity( .iter() .find(|(parameter, _, _)| parameter.as_str() == name) .map(|(_, entity, route_null)| (*entity, *route_null))?; + let narrower = |resolved: &ResolvedType| { + resolved.type_string.is_null() + || resolved.class_info.as_ref().is_some_and(|class| { + class.fqn().trim_start_matches('\\') != entity.trim_start_matches('\\') + && crate::class_lookup::is_subtype_of(class, &entity, ctx.file.class_loader) + }) + }; + if declared + .iter() + .any(|resolved| !resolved.type_string.is_null()) + && declared.iter().all(narrower) + { + return None; + } let mut nullable = route_null || defaults_to_null; if let Some(written) = native_type { // A builtin or union hint takes no entity, nor a class the entity's diff --git a/tests/integration/drupal_routes.rs b/tests/integration/drupal_routes.rs index b9dabe289..659149dcc 100644 --- a/tests/integration/drupal_routes.rs +++ b/tests/integration/drupal_routes.rs @@ -1424,6 +1424,18 @@ async fn a_bundle_class_hint_is_not_reported() { assert_eq!(found, Vec::::new()); } +/// A docblock that names a bundle class of the entity says more than the +/// entity class Drupal's upcasting gives, and is kept. +#[tokio::test] +async fn a_bundle_class_docblock_wins_over_the_upcast_entity() { + let routing = "acme_check.view:\n path: '/check/{acme_note}'\n defaults:\n _controller: '\\Drupal\\acme_check\\Controller\\CheckController::view'\n _title_callback: '\\Drupal\\acme_check\\Controller\\CheckController::title'\n"; + let methods = " /** @param Memo $acme_note */\n public function view(EntityInterface $acme_note): array {\n $acme_note; // view\n return [];\n }\n /** @param Memo $acme_note */\n public function title($acme_note): string {\n $acme_note; // title\n return '';\n }\n"; + for marker in ["$acme_note; // view", "$acme_note; // title"] { + let text = check_hover(routing, methods, marker).await; + assert!(text.contains("$acme_note = Memo\n"), "{marker}: {text}"); + } +} + /// A title callback is never reported, nor reflected by core. #[tokio::test] async fn a_title_callback_is_not_reported() {