Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class TranslationUpdateCommandPass implements CompilerPassInterface
class TranslationExtractCommandPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
Expand Down
4 changes: 2 additions & 2 deletions src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TestServiceContainerRealRefPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TestServiceContainerWeakRefPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TranslationLintCommandPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TranslationUpdateCommandPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TranslationExtractCommandPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\UnusedTagsPass;
use Symfony\Bundle\FrameworkBundle\DependencyInjection\VirtualRequestStackPass;
use Symfony\Component\Cache\Adapter\ApcuAdapter;
Expand Down Expand Up @@ -188,7 +188,7 @@ public function build(ContainerBuilder $container): void
// must be registered after MonologBundle's LoggerChannelPass
$container->addCompilerPass(new ErrorLoggerCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32);
$container->addCompilerPass(new VirtualRequestStackPass());
$container->addCompilerPass(new TranslationUpdateCommandPass(), PassConfig::TYPE_BEFORE_REMOVING);
$container->addCompilerPass(new TranslationExtractCommandPass(), PassConfig::TYPE_BEFORE_REMOVING);
$this->addCompilerPassIfExists($container, StreamablePass::class);

if ($container->getParameter('kernel.debug')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Symfony\Component\Translation\Extractor\ExtractorInterface;
use Symfony\Component\Translation\Extractor\PhpAstExtractor;
use Symfony\Component\Translation\Extractor\Visitor\ConstraintVisitor;
use Symfony\Component\Translation\Extractor\Visitor\FormTypeVisitor;
use Symfony\Component\Translation\Extractor\Visitor\TranslatableMessageVisitor;
use Symfony\Component\Translation\Extractor\Visitor\TransMethodVisitor;
use Symfony\Component\Translation\Formatter\MessageFormatter;
Expand Down Expand Up @@ -164,6 +165,9 @@
->set('translation.extractor.visitor.constraint', ConstraintVisitor::class)
->tag('translation.extractor.visitor')

->set('translation.extractor.visitor.form_type', FormTypeVisitor::class)
->tag('translation.extractor.visitor')

->set('translation.reader', TranslationReader::class)
->alias(TranslationReaderInterface::class, 'translation.reader')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ public function process(ContainerBuilder $container): void
return;
}

$this->processLoadersAndReaders($container);
$this->processExtractorConstraintVisitor($container);
$this->processTwigPaths($container);
}

private function processLoadersAndReaders(ContainerBuilder $container): void
{
$loaders = [];
$loaderRefs = [];
foreach ($container->findTaggedServiceIds('translation.loader', true) as $id => $attributes) {
Expand All @@ -48,29 +55,38 @@ public function process(ContainerBuilder $container): void
->replaceArgument(0, ServiceLocatorTagPass::register($container, $loaderRefs))
->replaceArgument(3, $loaders)
;
}

if ($container->hasDefinition('validator') && $container->hasDefinition('translation.extractor.visitor.constraint')) {
$constraintVisitorDefinition = $container->getDefinition('translation.extractor.visitor.constraint');
$constraintClassNames = [];
private function processExtractorConstraintVisitor(ContainerBuilder $container): void
{
if (!$container->hasDefinition('validator') || !$container->hasDefinition('translation.extractor.visitor.constraint')) {
return;
}

foreach ($container->getDefinitions() as $definition) {
if (!$definition->hasTag('validator.constraint_validator')) {
continue;
}
// Resolve constraint validator FQCN even if defined as %foo.validator.class% parameter
$className = $container->getParameterBag()->resolveValue($definition->getClass());
// Extraction of the constraint class name from the Constraint Validator FQCN
$constraintClassNames[] = str_replace('Validator', '', substr(strrchr($className, '\\'), 1));
}
$constraintVisitorDefinition = $container->getDefinition('translation.extractor.visitor.constraint');
$constraintClassNames = [];

$constraintVisitorDefinition->setArgument(0, $constraintClassNames);
foreach ($container->getDefinitions() as $definition) {
if (!$definition->hasTag('validator.constraint_validator')) {
continue;
}
// Resolve constraint validator FQCN even if defined as %foo.validator.class% parameter
$className = $container->getParameterBag()->resolveValue($definition->getClass());
// Extraction of the constraint class name from the Constraint Validator FQCN
$constraintClassNames[] = str_replace('Validator', '', substr(strrchr($className, '\\'), 1));
}

$constraintVisitorDefinition->setArgument(0, $constraintClassNames);
}

private function processTwigPaths(ContainerBuilder $container): void
{
if (!$container->hasParameter('twig.default_path')) {
return;
}

$paths = array_keys($container->getDefinition('twig.template_iterator')->getArgument(1));

if ($container->hasDefinition('console.command.translation_debug')) {
$definition = $container->getDefinition('console.command.translation_debug');
$definition->replaceArgument(4, $container->getParameter('twig.default_path'));
Expand All @@ -79,6 +95,7 @@ public function process(ContainerBuilder $container): void
$definition->replaceArgument(6, $paths);
}
}

if ($container->hasDefinition('console.command.translation_extract')) {
$definition = $container->getDefinition('console.command.translation_extract');
$definition->replaceArgument(5, $container->getParameter('twig.default_path'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ protected function canBeExtracted(string $file): bool
{
return 'php' === pathinfo($file, \PATHINFO_EXTENSION)
&& $this->isFile($file)
&& preg_match('/\bt\(|->trans\(|TranslatableMessage|Symfony\\\\Component\\\\Validator\\\\Constraints/i', file_get_contents($file));
&& preg_match('/\bt\(|->trans\(|TranslatableMessage|Symfony\\\\Component\\\\Validator\\\\Constraints|Symfony\\\\Component\\\\Form\\\\AbstractType/i', file_get_contents($file));
}

protected function extractFromDirectory(array|string $resource): iterable|Finder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ protected function nodeFirstNamedArgumentIndex(Node\Expr\CallLike|Node\Attribute
return \PHP_INT_MAX;
}

private function getStringNamedArguments(Node\Expr\CallLike|Node\Attribute $node, ?string $argumentName = null, bool $isArgumentNamePattern = false): array
protected function getStringNamedArguments(Node\Expr\CallLike|Node\Attribute $node, ?string $argumentName = null, bool $isArgumentNamePattern = false): array
{
$args = $node instanceof Node\Expr\CallLike ? $node->getArgs() : $node->args;
$argumentValues = [];
Expand All @@ -97,22 +97,14 @@ private function getStringNamedArguments(Node\Expr\CallLike|Node\Attribute $node
return array_filter($argumentValues);
}

private function getStringValue(Node $node): ?string
protected function getStringValue(Node $node): ?string
{
if ($node instanceof Node\Scalar\String_) {
return $node->value;
}

if ($node instanceof Node\Expr\BinaryOp\Concat) {
if (null === $left = $this->getStringValue($node->left)) {
return null;
}

if (null === $right = $this->getStringValue($node->right)) {
return null;
}

return $left.$right;
return $this->getStringValueFromConcatNode($node);
}

if ($node instanceof Node\Expr\Assign && $node->expr instanceof Node\Scalar\String_) {
Expand All @@ -132,4 +124,17 @@ private function getStringValue(Node $node): ?string

return null;
}

private function getStringValueFromConcatNode(Node\Expr\BinaryOp\Concat $node): ?string
{
if (null === $left = $this->getStringValue($node->left)) {
return null;
}

if (null === $right = $this->getStringValue($node->right)) {
return null;
}

return $left.$right;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Translation\Extractor\Visitor;

use PhpParser\Node;
use PhpParser\NodeVisitor;
use Symfony\Contracts\Translation\TranslatableInterface;

final class BackedEnumVisitor extends AbstractVisitor implements NodeVisitor
{
/**
* Stores whether the current class is a translatable backed enum across visits of all children nodes.
*/
private bool $isBackedEnum = false;
private array $cases = [];

public function beforeTraverse(array $nodes): ?Node
{
return null;
}

public function enterNode(Node $node): ?Node
{
if (!$this->isBackedEnum($node)) {
return null;
}

// Visit all enum cases to save name and values
if ($node instanceof Node\Stmt\EnumCase) {
$this->visitEnumCase($node);
}

if ($node instanceof Node\Expr\MethodCall) {
$this->visitTransMethodCall($node);
}

return null;
}

public function leaveNode(Node $node): ?Node
{
if ($node instanceof Node\Stmt\Enum_) {
$this->isBackedEnum = false;
}

return null;
}

public function afterTraverse(array $nodes): ?Node
{
return null;
}

private function visitEnumCase(Node\Stmt\EnumCase $node): void
{
$this->cases[$node->name->name] = $node->expr->value;

Check failure on line 65 in src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedPropertyFetch

src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php:65:43: UndefinedPropertyFetch: Instance property PhpParser\Node\Expr::$value is not defined (see https://psalm.dev/039)

Check failure on line 65 in src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedPropertyFetch

src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php:65:43: UndefinedPropertyFetch: Instance property PhpParser\Node\Expr::$value is not defined (see https://psalm.dev/039)
}

private function visitTransMethodCall(Node\Expr\MethodCall $node): void
{
if (!\is_string($node->name) && !$node->name instanceof Node\Identifier && !$node->name instanceof Node\Name) {
return;
}

$name = $node->name instanceof Node\Name ? $node->name->getLast() : (string) $node->name;
if ('trans' !== $name && 't' !== $name) {
return;
}

$firstNamedArgumentIndex = $this->nodeFirstNamedArgumentIndex($node);
$nodeId = $node->getArgs()[0 < $firstNamedArgumentIndex ? 0 : 'id'];
$domain = $this->getStringArguments($node, 2 < $firstNamedArgumentIndex ? 2 : 'domain')[0] ?? null;

$messagePattern = $this->resolveMessagePattern($nodeId->value);
if (null === $messagePattern) {
return;
}

foreach ($this->cases as $name => $value) {
$message = str_replace(
['{name}', '{value}'],
[$name, $value],
$messagePattern
);

$this->addMessageToCatalogue($message, $domain, $node->getStartLine());

}
}
private function resolveMessagePattern(Node\Expr $expr): ?string
{
$parts = [];

while ($expr instanceof Node\Expr\BinaryOp\Concat) {
$parts[] = $this->resolveExprPart($expr->right);
$expr = $expr->left;
}

$parts[] = $this->resolveExprPart($expr);
// If there is only one string part and does not contains sprintf value(s), TransMethodVisitor already extracted the key.
if (1 === \count($parts) && !str_contains($parts[0], '{value}') && !str_contains($parts[0], '{name}')) {
return null;
}

$parts = array_reverse($parts);

// If any part failed to resolve, abort
if (in_array(null, $parts, true)) {
return null;
}

return implode('', $parts);
}

private function resolveExprPart(Node\Expr $expr): ?string
{
if ($expr instanceof Node\Scalar\String_) {
return $expr->value;
}

if (
$expr instanceof Node\Expr\PropertyFetch &&
$expr->var instanceof Node\Expr\Variable &&
$expr->var->name === 'this' &&
$expr->name instanceof Node\Identifier
) {
if ($expr->name->name === 'value') {
return '{value}';
}

if ($expr->name->name === 'name') {
return '{name}';
}
}

if (
$expr instanceof Node\Expr\FuncCall &&
$expr->name->name === 'sprintf'

Check failure on line 147 in src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedPropertyFetch

src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php:147:13: UndefinedPropertyFetch: Instance property PhpParser\Node\Expr::$name is not defined (see https://psalm.dev/039)

Check failure on line 147 in src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedPropertyFetch

src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php:147:13: UndefinedPropertyFetch: Instance property PhpParser\Node\Expr::$name is not defined (see https://psalm.dev/039)
) {
$args = $expr->args;
$pattern = array_shift($args)->value->value;

Check failure on line 150 in src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedPropertyFetch

src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php:150:24: UndefinedPropertyFetch: Instance property PhpParser\Node\VariadicPlaceholder::$value is not defined (see https://psalm.dev/039)

Check failure on line 150 in src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedPropertyFetch

src/Symfony/Component/Translation/Extractor/Visitor/BackedEnumVisitor.php:150:24: UndefinedPropertyFetch: Instance property PhpParser\Node\VariadicPlaceholder::$value is not defined (see https://psalm.dev/039)
array_walk($args, fn (Node\Arg &$arg) => $arg = $this->resolveExprPart($arg->value));

return vsprintf($pattern, $args);
}

return null; // unsupported part
}

private function isBackedEnum(Node $node): bool
{
if ($node instanceof Node\Stmt\Enum_) {
foreach ($node->implements as $interface) {
if (TranslatableInterface::class === $interface->name) {
$this->isBackedEnum = true;
break;
}
}
}


return $this->isBackedEnum;
}
}
Loading
Loading