From dba79330db30481ff6d3660e5dcf27141255b02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 21 Aug 2026 14:36:17 +0200 Subject: [PATCH] Register the missing extra callables in MissingExtensionSuggestor --- extra/twig-extra-bundle/Extensions.php | 12 +-- .../Tests/ExtensionsTest.php | 88 +++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 extra/twig-extra-bundle/Tests/ExtensionsTest.php diff --git a/extra/twig-extra-bundle/Extensions.php b/extra/twig-extra-bundle/Extensions.php index 306de75a3..79e2357eb 100644 --- a/extra/twig-extra-bundle/Extensions.php +++ b/extra/twig-extra-bundle/Extensions.php @@ -36,8 +36,8 @@ final class Extensions 'class' => HtmlExtension::class, 'class_name' => 'HtmlExtension', 'package' => 'twig/html-extra', - 'filters' => ['data_uri'], - 'functions' => ['html_classes'], + 'filters' => ['data_uri', 'html_attr_merge', 'html_attr_type'], + 'functions' => ['html_classes', 'html_cva', 'html_attr'], 'tags' => [], ], 'markdown' => [ @@ -57,9 +57,11 @@ final class Extensions 'filters' => ['country_name', 'currency_name', 'currency_symbol', 'language_name', 'locale_name', 'timezone_name', 'format_currency', 'format_number', 'format_decimal_number', 'format_currency_number', 'format_percent_number', 'format_scientific_number', 'format_spellout_number', 'format_ordinal_number', - 'format_duration_number', 'format_date', 'format_datetime', 'format_time', + 'format_duration_number', 'format_date', 'format_datetime', 'format_time', 'format_list', + ], + 'functions' => ['country_timezones', 'language_names', 'script_names', 'country_names', + 'locale_names', 'currency_names', 'timezone_names', ], - 'functions' => ['country_timezones'], 'tags' => [], ], 'cssinliner' => [ @@ -85,7 +87,7 @@ final class Extensions 'class' => StringExtension::class, 'class_name' => 'StringExtension', 'package' => 'twig/string-extra', - 'filters' => ['u'], + 'filters' => ['u', 'slug', 'plural', 'singular'], 'functions' => [], 'tags' => [], ], diff --git a/extra/twig-extra-bundle/Tests/ExtensionsTest.php b/extra/twig-extra-bundle/Tests/ExtensionsTest.php new file mode 100644 index 000000000..dde619844 --- /dev/null +++ b/extra/twig-extra-bundle/Tests/ExtensionsTest.php @@ -0,0 +1,88 @@ +loadExtension($entry); + $twig = new Environment(new ArrayLoader()); + $twig->addExtension($extension); + + foreach ($entry['filters'] as $name) { + $this->assertNotNull($twig->getFilter($name), \sprintf('The "%s" filter is listed for "%s" but the extension does not declare it, so the suggestor points to a filter that does not exist.', $name, $entry['package'])); + } + + foreach ($entry['functions'] as $name) { + $this->assertNotNull($twig->getFunction($name), \sprintf('The "%s" function is listed for "%s" but the extension does not declare it, so the suggestor points to a function that does not exist.', $name, $entry['package'])); + } + + $tags = array_map(static fn ($parser) => $parser->getTag(), $extension->getTokenParsers()); + + foreach ($entry['tags'] as $name) { + $this->assertContains($name, $tags, \sprintf('The "%s" tag is listed for "%s" but the extension does not declare it, so the suggestor points to a tag that does not exist.', $name, $entry['package'])); + } + } + + /** + * @dataProvider provideCatalog + */ + public function testCatalogListsEveryDeclaredCallable(array $entry): void + { + $extension = $this->loadExtension($entry); + + $this->assertCatalogCovers($entry['filters'], array_map(static fn ($filter) => $filter->getName(), $extension->getFilters()), 'filter', $entry['package']); + $this->assertCatalogCovers($entry['functions'], array_map(static fn ($function) => $function->getName(), $extension->getFunctions()), 'function', $entry['package']); + $this->assertCatalogCovers($entry['tags'], array_map(static fn ($parser) => $parser->getTag(), $extension->getTokenParsers()), 'tag', $entry['package']); + } + + public static function provideCatalog(): iterable + { + // The catalog duplicates names owned by seven separate packages, so read it back from the constant. + foreach ((new \ReflectionClass(Extensions::class))->getConstants()['EXTENSIONS'] as $name => $entry) { + yield $name => [$entry]; + } + } + + private function assertCatalogCovers(array $listed, array $declared, string $kind, string $package): void + { + foreach ($declared as $name) { + if (str_contains($name, '*')) { + // A dynamic name cannot be enumerated; the catalog spells out the variants worth suggesting. + $this->assertNotEmpty(preg_grep('/^'.str_replace('\*', '\w+', preg_quote($name, '/')).'$/', $listed), \sprintf('The "%s" %s of "%s" is dynamic but the catalog lists none of its variants.', $name, $kind, $package)); + + continue; + } + + $this->assertContains($name, $listed, \sprintf('The "%s" %s is declared by "%s" but missing from the catalog, so nothing is suggested when a template uses it without the package installed.', $name, $kind, $package)); + } + } + + private function loadExtension(array $entry): ExtensionInterface + { + if (!class_exists($entry['class'])) { + $this->markTestSkipped(\sprintf('"%s" is not installed.', $entry['package'])); + } + + return new $entry['class'](); + } +}