diff --git a/UPGRADE-6.2.md b/UPGRADE-6.2.md index 90af24307a7cb..63f296a5eea02 100644 --- a/UPGRADE-6.2.md +++ b/UPGRADE-6.2.md @@ -96,6 +96,12 @@ Serializer * Change the signature of `AttributeMetadataInterface::setSerializedName()` to `setSerializedName(?string)` * Change the signature of `ClassMetadataInterface::setClassDiscriminatorMapping()` to `setClassDiscriminatorMapping(?ClassDiscriminatorMapping)` +Translation +----------- + + * Deprecate `PhpExtractor` in favor of `PhpAstExtractor` + * Add `PhpAstExtractor` (requires [nikic/php-parser](https://github.com/nikic/php-parser) to be installed) + Validator --------- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 80c9d728a9e79..f127f32b49952 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -89,6 +89,7 @@ class UnusedTagsPass implements CompilerPassInterface 'texter.transport_factory', 'translation.dumper', 'translation.extractor', + 'translation.extractor.visitor', 'translation.loader', 'translation.provider_factory', 'twig.extension', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 41755c4cb525b..cd78fd6abe49f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -17,6 +17,7 @@ use Http\Client\HttpClient; use phpDocumentor\Reflection\DocBlockFactoryInterface; use phpDocumentor\Reflection\Types\ContextFactory; +use PhpParser\Parser; use PHPStan\PhpDocParser\Parser\PhpDocParser; use Psr\Cache\CacheItemPoolInterface; use Psr\Container\ContainerInterface as PsrContainerInterface; @@ -218,6 +219,7 @@ use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProviderFactory; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; +use Symfony\Component\Translation\Extractor\PhpAstExtractor; use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\PseudoLocalizationTranslator; use Symfony\Component\Translation\Translator; @@ -1335,6 +1337,14 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $container->removeDefinition('translation.locale_switcher'); } + if (ContainerBuilder::willBeAvailable('nikic/php-parser', Parser::class, ['symfony/translation']) + && ContainerBuilder::willBeAvailable('symfony/translation', PhpAstExtractor::class, ['symfony/framework-bundle']) + ) { + $container->removeDefinition('translation.extractor.php'); + } else { + $container->removeDefinition('translation.extractor.php_ast'); + } + $loader->load('translation_providers.php'); // Use the "real" translator instead of the identity default diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php index fadaff6d2b596..265fb6da336a0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.php @@ -26,7 +26,11 @@ use Symfony\Component\Translation\Dumper\YamlFileDumper; use Symfony\Component\Translation\Extractor\ChainExtractor; use Symfony\Component\Translation\Extractor\ExtractorInterface; +use Symfony\Component\Translation\Extractor\PhpAstExtractor; use Symfony\Component\Translation\Extractor\PhpExtractor; +use Symfony\Component\Translation\Extractor\Visitor\ConstraintVisitor; +use Symfony\Component\Translation\Extractor\Visitor\TranslatableMessageVisitor; +use Symfony\Component\Translation\Extractor\Visitor\TransMethodVisitor; use Symfony\Component\Translation\Formatter\MessageFormatter; use Symfony\Component\Translation\Loader\CsvFileLoader; use Symfony\Component\Translation\Loader\IcuDatFileLoader; @@ -151,6 +155,19 @@ ->set('translation.extractor.php', PhpExtractor::class) ->tag('translation.extractor', ['alias' => 'php']) + ->set('translation.extractor.php_ast', PhpAstExtractor::class) + ->args([tagged_iterator('translation.extractor.visitor')]) + ->tag('translation.extractor', ['alias' => 'php']) + + ->set('translation.extractor.visitor.trans_method', TransMethodVisitor::class) + ->tag('translation.extractor.visitor') + + ->set('translation.extractor.visitor.translatable_message', TranslatableMessageVisitor::class) + ->tag('translation.extractor.visitor') + + ->set('translation.extractor.visitor.constraint', ConstraintVisitor::class) + ->tag('translation.extractor.visitor') + ->set('translation.reader', TranslationReader::class) ->alias(TranslationReaderInterface::class, 'translation.reader') diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 720298d8e06c2..c35040ad18189 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +6.2 +--- + + * Deprecate `PhpExtractor` in favor of `PhpAstExtractor` + * Add `PhpAstExtractor` (requires [nikic/php-parser](https://github.com/nikic/php-parser) to be installed) + 6.1 --- diff --git a/src/Symfony/Component/Translation/DependencyInjection/TranslatorPass.php b/src/Symfony/Component/Translation/DependencyInjection/TranslatorPass.php index be79cdaf02550..4b50958eb593b 100644 --- a/src/Symfony/Component/Translation/DependencyInjection/TranslatorPass.php +++ b/src/Symfony/Component/Translation/DependencyInjection/TranslatorPass.php @@ -49,6 +49,19 @@ public function process(ContainerBuilder $container) ->replaceArgument(3, $loaders) ; + if ($container->hasDefinition('validator') && $container->hasDefinition('translation.extractor.visitor.constraint')) { + $constraintVisitorDefinition = $container->getDefinition('translation.extractor.visitor.constraint'); + $constraintClassNames = []; + + foreach ($container->findTaggedServiceIds('validator.constraint_validator', true) as $id => $attributes) { + $serviceDefinition = $container->getDefinition($id); + // Extraction of the constraint class name from the Constraint Validator FQCN + $constraintClassNames[] = str_replace('Validator', '', substr(strrchr($serviceDefinition->getClass(), '\\'), 1)); + } + + $constraintVisitorDefinition->setArgument(0, $constraintClassNames); + } + if (!$container->hasParameter('twig.default_path')) { return; } diff --git a/src/Symfony/Component/Translation/Extractor/PhpAstExtractor.php b/src/Symfony/Component/Translation/Extractor/PhpAstExtractor.php new file mode 100644 index 0000000000000..d0bbf0e083847 --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/PhpAstExtractor.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Extractor; + +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\Parser; +use PhpParser\ParserFactory; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Translation\Extractor\Visitor\AbstractVisitor; +use Symfony\Component\Translation\MessageCatalogue; + +/** + * PhpAstExtractor extracts translation messages from a PHP AST. + * + * @author Mathieu Santostefano + */ +final class PhpAstExtractor extends AbstractFileExtractor implements ExtractorInterface +{ + private Parser $parser; + + public function __construct( + /** + * @param iterable $visitors + */ + private readonly iterable $visitors, + private string $prefix = '', + ) { + if (!class_exists(ParserFactory::class)) { + throw new \LogicException(sprintf('You cannot use "%s" as the "nikic/php-parser" package is not installed. Try running "composer require nikic/php-parser".', static::class)); + } + + $this->parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + } + + public function extract(iterable|string $resource, MessageCatalogue $catalogue): void + { + foreach ($this->extractFiles($resource) as $file) { + $traverser = new NodeTraverser(); + /** @var AbstractVisitor&NodeVisitor $visitor */ + foreach ($this->visitors as $visitor) { + $visitor->initialize($catalogue, $file, $this->prefix); + $traverser->addVisitor($visitor); + } + + $nodes = $this->parser->parse(file_get_contents($file)); + $traverser->traverse($nodes); + } + } + + public function setPrefix(string $prefix): void + { + $this->prefix = $prefix; + } + + protected function canBeExtracted(string $file): bool + { + return 'php' === pathinfo($file, \PATHINFO_EXTENSION) && $this->isFile($file); + } + + protected function extractFromDirectory(array|string $resource): iterable|Finder + { + if (!class_exists(Finder::class)) { + throw new \LogicException(sprintf('You cannot use "%s" as the "symfony/finder" package is not installed. Try running "composer require symfony/finder".', static::class)); + } + + return (new Finder())->files()->name('*.php')->in($resource); + } +} diff --git a/src/Symfony/Component/Translation/Extractor/PhpExtractor.php b/src/Symfony/Component/Translation/Extractor/PhpExtractor.php index 1e24d6a6600e9..329d619293bb6 100644 --- a/src/Symfony/Component/Translation/Extractor/PhpExtractor.php +++ b/src/Symfony/Component/Translation/Extractor/PhpExtractor.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Translation\Extractor; +trigger_deprecation('symfony/translation', '6.2', '"%s" is deprecated, use "%s" instead.', PhpExtractor::class, PhpAstExtractor::class); + use Symfony\Component\Finder\Finder; use Symfony\Component\Translation\MessageCatalogue; @@ -18,6 +20,8 @@ * PhpExtractor extracts translation messages from a PHP template. * * @author Michel Salib + * + * @deprecated since Symfony 6.2, use the PhpAstExtractor instead */ class PhpExtractor extends AbstractFileExtractor implements ExtractorInterface { diff --git a/src/Symfony/Component/Translation/Extractor/Visitor/AbstractVisitor.php b/src/Symfony/Component/Translation/Extractor/Visitor/AbstractVisitor.php new file mode 100644 index 0000000000000..8d5bd8e64eb4b --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/Visitor/AbstractVisitor.php @@ -0,0 +1,118 @@ + + * + * 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 Symfony\Component\Translation\MessageCatalogue; + +/** + * @author Mathieu Santostefano + */ +abstract class AbstractVisitor +{ + private MessageCatalogue $catalogue; + private \SplFileInfo $file; + private string $messagePrefix; + + public function initialize(MessageCatalogue $catalogue, \SplFileInfo $file, string $messagePrefix): void + { + $this->catalogue = $catalogue; + $this->file = $file; + $this->messagePrefix = $messagePrefix; + } + + protected function addMessageToCatalogue(string $message, ?string $domain, int $line): void + { + $domain ??= 'messages'; + $this->catalogue->set($message, $this->messagePrefix.$message, $domain); + $metadata = $this->catalogue->getMetadata($message, $domain) ?? []; + $normalizedFilename = preg_replace('{[\\\\/]+}', '/', $this->file); + $metadata['sources'][] = $normalizedFilename.':'.$line; + $this->catalogue->setMetadata($message, $metadata, $domain); + } + + protected function getStringArguments(Node\Expr\CallLike|Node\Attribute|Node\Expr\New_ $node, int|string $index, bool $indexIsRegex = false): array + { + $args = $node instanceof Node\Expr\CallLike ? $node->getArgs() : $node->args; + + if (\is_string($index)) { + return $this->getStringNamedArguments($node, $index, $indexIsRegex); + } + + if (\count($args) < $index) { + return []; + } + + /** @var Node\Arg $arg */ + $arg = $args[$index]; + if (!$result = $this->getStringValue($arg->value)) { + return []; + } + + return [$result]; + } + + protected function hasNodeNamedArguments(Node\Expr\CallLike|Node\Attribute|Node\Expr\New_ $node): bool + { + $args = $node instanceof Node\Expr\CallLike ? $node->getArgs() : $node->args; + + /** @var Node\Arg $arg */ + foreach ($args as $arg) { + if (null !== $arg->name) { + return true; + } + } + + return false; + } + + private 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 = []; + + foreach ($args as $arg) { + if (!$isArgumentNamePattern && $arg->name?->toString() === $argumentName) { + $argumentValues[] = $this->getStringValue($arg->value); + } elseif ($isArgumentNamePattern && preg_match($argumentName, $arg->name?->toString() ?? '') > 0) { + $argumentValues[] = $this->getStringValue($arg->value); + } + } + + return array_filter($argumentValues); + } + + private 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; + } + + if ($node instanceof Node\Expr\Assign && $node->expr instanceof Node\Scalar\String_) { + return $node->expr->value; + } + + return null; + } +} diff --git a/src/Symfony/Component/Translation/Extractor/Visitor/ConstraintVisitor.php b/src/Symfony/Component/Translation/Extractor/Visitor/ConstraintVisitor.php new file mode 100644 index 0000000000000..6552a66d5a32d --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/Visitor/ConstraintVisitor.php @@ -0,0 +1,113 @@ + + * + * 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; + +/** + * @author Mathieu Santostefano + * + * Code mostly comes from https://github.com/php-translation/extractor/blob/master/src/Visitor/Php/Symfony/Constraint.php + */ +final class ConstraintVisitor extends AbstractVisitor implements NodeVisitor +{ + private const CONSTRAINT_VALIDATION_MESSAGE_PATTERN = '/[a-zA-Z]*message/i'; + + public function __construct( + private readonly array $constraintClassNames = [] + ) { + } + + public function beforeTraverse(array $nodes): ?Node + { + return null; + } + + public function enterNode(Node $node): ?Node + { + if (!$node instanceof Node\Expr\New_ && !$node instanceof Node\Attribute) { + return null; + } + + $className = $node instanceof Node\Attribute ? $node->name : $node->class; + if (!$className instanceof Node\Name) { + return null; + } + + $parts = $className->parts; + $isConstraintClass = false; + + foreach ($parts as $part) { + if (\in_array($part, $this->constraintClassNames, true)) { + $isConstraintClass = true; + + break; + } + } + + if (!$isConstraintClass) { + return null; + } + + $arg = $node->args[0] ?? null; + if (!$arg instanceof Node\Arg) { + return null; + } + + if ($this->hasNodeNamedArguments($node)) { + $messages = $this->getStringArguments($node, self::CONSTRAINT_VALIDATION_MESSAGE_PATTERN, true); + } else { + if (!$arg->value instanceof Node\Expr\Array_) { + // There is no way to guess which argument is a message to be translated. + return null; + } + + $options = $arg->value; + + /** @var Node\Expr\ArrayItem $item */ + foreach ($options->items as $item) { + if (!$item->key instanceof Node\Scalar\String_) { + continue; + } + + if (!preg_match(self::CONSTRAINT_VALIDATION_MESSAGE_PATTERN, $item->key->value ?? '')) { + continue; + } + + if (!$item->value instanceof Node\Scalar\String_) { + continue; + } + + $messages[] = $item->value->value; + + break; + } + } + + foreach ($messages as $message) { + $this->addMessageToCatalogue($message, 'validators', $node->getStartLine()); + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + return null; + } + + public function afterTraverse(array $nodes): ?Node + { + return null; + } +} diff --git a/src/Symfony/Component/Translation/Extractor/Visitor/TransMethodVisitor.php b/src/Symfony/Component/Translation/Extractor/Visitor/TransMethodVisitor.php new file mode 100644 index 0000000000000..2c61659427b14 --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/Visitor/TransMethodVisitor.php @@ -0,0 +1,64 @@ + + * + * 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; + +/** + * @author Mathieu Santostefano + */ +final class TransMethodVisitor extends AbstractVisitor implements NodeVisitor +{ + public function beforeTraverse(array $nodes): ?Node + { + return null; + } + + public function enterNode(Node $node): ?Node + { + if (!$node instanceof Node\Expr\MethodCall && !$node instanceof Node\Expr\FuncCall) { + return null; + } + + if (!\is_string($node->name) && !$node->name instanceof Node\Identifier && !$node->name instanceof Node\Name) { + return null; + } + + $name = (string) $node->name; + + if ('trans' === $name || 't' === $name) { + $nodeHasNamedArguments = $this->hasNodeNamedArguments($node); + if (!$messages = $this->getStringArguments($node, $nodeHasNamedArguments ? 'message' : 0)) { + return null; + } + + $domain = $this->getStringArguments($node, $nodeHasNamedArguments ? 'domain' : 2)[0] ?? null; + + foreach ($messages as $message) { + $this->addMessageToCatalogue($message, $domain, $node->getStartLine()); + } + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + return null; + } + + public function afterTraverse(array $nodes): ?Node + { + return null; + } +} diff --git a/src/Symfony/Component/Translation/Extractor/Visitor/TranslatableMessageVisitor.php b/src/Symfony/Component/Translation/Extractor/Visitor/TranslatableMessageVisitor.php new file mode 100644 index 0000000000000..423982c82ce66 --- /dev/null +++ b/src/Symfony/Component/Translation/Extractor/Visitor/TranslatableMessageVisitor.php @@ -0,0 +1,65 @@ + + * + * 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; + +/** + * @author Mathieu Santostefano + */ +final class TranslatableMessageVisitor extends AbstractVisitor implements NodeVisitor +{ + public function beforeTraverse(array $nodes): ?Node + { + return null; + } + + public function enterNode(Node $node): ?Node + { + if (!$node instanceof Node\Expr\New_) { + return null; + } + + if (!($className = $node->class) instanceof Node\Name) { + return null; + } + + if (!\in_array('TranslatableMessage', $className->parts, true)) { + return null; + } + + $nodeHasNamedArguments = $this->hasNodeNamedArguments($node); + + if (!$messages = $this->getStringArguments($node, $nodeHasNamedArguments ? 'message' : 0)) { + return null; + } + + $domain = $this->getStringArguments($node, $nodeHasNamedArguments ? 'domain' : 2)[0] ?? null; + + foreach ($messages as $message) { + $this->addMessageToCatalogue($message, $domain, $node->getStartLine()); + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + return null; + } + + public function afterTraverse(array $nodes): ?Node + { + return null; + } +} diff --git a/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslatorPassTest.php b/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslatorPassTest.php index 05e57e87521e9..624ca3cda0323 100644 --- a/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslatorPassTest.php +++ b/src/Symfony/Component/Translation/Tests/DependencyInjection/TranslatorPassTest.php @@ -17,6 +17,13 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Translation\DependencyInjection\TranslatorPass; +use Symfony\Component\Translation\Extractor\Visitor\ConstraintVisitor; +use Symfony\Component\Validator\Constraints\Isbn; +use Symfony\Component\Validator\Constraints\IsbnValidator; +use Symfony\Component\Validator\Constraints\LengthValidator; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotBlankValidator; +use Symfony\Component\Validator\Validator\ValidatorInterface; class TranslatorPassTest extends TestCase { @@ -119,4 +126,24 @@ public function testCommandsViewPathsArgumentsAreIgnoredWithOldServiceDefinition $this->assertSame('templates', $debugCommand->getArgument(4)); $this->assertSame('templates', $updateCommand->getArgument(5)); } + + public function testValidPhpAstExtractorConstraintVisitorArguments() + { + $container = new ContainerBuilder(); + $container->register('translator.default') + ->setArguments([null, null, null, null]); + $container->register('validator'); + $constraintVisitor = $container->register('translation.extractor.visitor.constraint', ConstraintVisitor::class); + $container->register('validator.not_blank', NotBlankValidator::class) + ->addTag('validator.constraint_validator'); + $container->register('validator.isbn', IsbnValidator::class) + ->addTag('validator.constraint_validator'); + $container->register('validator.length', LengthValidator::class) + ->addTag('validator.constraint_validator'); + + $pass = new TranslatorPass(); + $pass->process($container); + + $this->assertSame(['NotBlank', 'Isbn', 'Length'], $constraintVisitor->getArgument(0)); + } } diff --git a/src/Symfony/Component/Translation/Tests/Extractor/PhpAstExtractorTest.php b/src/Symfony/Component/Translation/Tests/Extractor/PhpAstExtractorTest.php new file mode 100644 index 0000000000000..18f38f93fbec1 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Extractor/PhpAstExtractorTest.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Extractor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Extractor\PhpAstExtractor; +use Symfony\Component\Translation\Extractor\Visitor\ConstraintVisitor; +use Symfony\Component\Translation\Extractor\Visitor\TranslatableMessageVisitor; +use Symfony\Component\Translation\Extractor\Visitor\TransMethodVisitor; +use Symfony\Component\Translation\MessageCatalogue; + +final class PhpAstExtractorTest extends TestCase +{ + /** + * @dataProvider resourcesProvider + */ + public function testExtraction(iterable|string $resource) + { + $extractor = new PhpAstExtractor([ + new TransMethodVisitor(), + new TranslatableMessageVisitor(), + new ConstraintVisitor([ + 'NotBlank', + 'Isbn', + 'Length', + ], new TranslatableMessageVisitor()), + ]); + $extractor->setPrefix('prefix'); + $catalogue = new MessageCatalogue('en'); + + $extractor->extract($resource, $catalogue); + + $expectedHeredoc = << [ + 'translatable single-quoted key' => 'prefixtranslatable single-quoted key', + 'translatable double-quoted key' => 'prefixtranslatable double-quoted key', + 'translatable heredoc key' => 'prefixtranslatable heredoc key', + 'translatable nowdoc key' => 'prefixtranslatable nowdoc key', + "translatable double-quoted key with whitespace and escaped \$\n\" sequences" => "prefixtranslatable double-quoted key with whitespace and escaped \$\n\" sequences", + 'translatable single-quoted key with whitespace and nonescaped \$\n\' sequences' => 'prefixtranslatable single-quoted key with whitespace and nonescaped \$\n\' sequences', + 'translatable single-quoted key with "quote mark at the end"' => 'prefixtranslatable single-quoted key with "quote mark at the end"', + 'translatable '.$expectedHeredoc => 'prefixtranslatable '.$expectedHeredoc, + 'translatable '.$expectedNowdoc => 'prefixtranslatable '.$expectedNowdoc, + 'translatable concatenated message with heredoc and nowdoc' => 'prefixtranslatable concatenated message with heredoc and nowdoc', + 'translatable default domain' => 'prefixtranslatable default domain', + 'translatable-fqn single-quoted key' => 'prefixtranslatable-fqn single-quoted key', + 'translatable-fqn double-quoted key' => 'prefixtranslatable-fqn double-quoted key', + 'translatable-fqn heredoc key' => 'prefixtranslatable-fqn heredoc key', + 'translatable-fqn nowdoc key' => 'prefixtranslatable-fqn nowdoc key', + "translatable-fqn double-quoted key with whitespace and escaped \$\n\" sequences" => "prefixtranslatable-fqn double-quoted key with whitespace and escaped \$\n\" sequences", + 'translatable-fqn single-quoted key with whitespace and nonescaped \$\n\' sequences' => 'prefixtranslatable-fqn single-quoted key with whitespace and nonescaped \$\n\' sequences', + 'translatable-fqn single-quoted key with "quote mark at the end"' => 'prefixtranslatable-fqn single-quoted key with "quote mark at the end"', + 'translatable-fqn '.$expectedHeredoc => 'prefixtranslatable-fqn '.$expectedHeredoc, + 'translatable-fqn '.$expectedNowdoc => 'prefixtranslatable-fqn '.$expectedNowdoc, + 'translatable-fqn concatenated message with heredoc and nowdoc' => 'prefixtranslatable-fqn concatenated message with heredoc and nowdoc', + 'translatable-fqn default domain' => 'prefixtranslatable-fqn default domain', + 'translatable-short single-quoted key' => 'prefixtranslatable-short single-quoted key', + 'translatable-short double-quoted key' => 'prefixtranslatable-short double-quoted key', + 'translatable-short heredoc key' => 'prefixtranslatable-short heredoc key', + 'translatable-short nowdoc key' => 'prefixtranslatable-short nowdoc key', + "translatable-short double-quoted key with whitespace and escaped \$\n\" sequences" => "prefixtranslatable-short double-quoted key with whitespace and escaped \$\n\" sequences", + 'translatable-short single-quoted key with whitespace and nonescaped \$\n\' sequences' => 'prefixtranslatable-short single-quoted key with whitespace and nonescaped \$\n\' sequences', + 'translatable-short single-quoted key with "quote mark at the end"' => 'prefixtranslatable-short single-quoted key with "quote mark at the end"', + 'translatable-short '.$expectedHeredoc => 'prefixtranslatable-short '.$expectedHeredoc, + 'translatable-short '.$expectedNowdoc => 'prefixtranslatable-short '.$expectedNowdoc, + 'translatable-short concatenated message with heredoc and nowdoc' => 'prefixtranslatable-short concatenated message with heredoc and nowdoc', + 'translatable-short default domain' => 'prefixtranslatable-short default domain', + 'single-quoted key' => 'prefixsingle-quoted key', + 'double-quoted key' => 'prefixdouble-quoted key', + 'heredoc key' => 'prefixheredoc key', + 'nowdoc key' => 'prefixnowdoc key', + "double-quoted key with whitespace and escaped \$\n\" sequences" => "prefixdouble-quoted key with whitespace and escaped \$\n\" sequences", + 'single-quoted key with whitespace and nonescaped \$\n\' sequences' => 'prefixsingle-quoted key with whitespace and nonescaped \$\n\' sequences', + 'single-quoted key with "quote mark at the end"' => 'prefixsingle-quoted key with "quote mark at the end"', + $expectedHeredoc => 'prefix'.$expectedHeredoc, + $expectedNowdoc => 'prefix'.$expectedNowdoc, + 'concatenated message with heredoc and nowdoc' => 'prefixconcatenated message with heredoc and nowdoc', + 'default domain' => 'prefixdefault domain', + ], + 'not_messages' => [ + 'translatable other-domain-test-no-params-short-array' => 'prefixtranslatable other-domain-test-no-params-short-array', + 'translatable other-domain-test-no-params-long-array' => 'prefixtranslatable other-domain-test-no-params-long-array', + 'translatable other-domain-test-params-short-array' => 'prefixtranslatable other-domain-test-params-short-array', + 'translatable other-domain-test-params-long-array' => 'prefixtranslatable other-domain-test-params-long-array', + 'translatable typecast' => 'prefixtranslatable typecast', + 'translatable-fqn other-domain-test-no-params-short-array' => 'prefixtranslatable-fqn other-domain-test-no-params-short-array', + 'translatable-fqn other-domain-test-no-params-long-array' => 'prefixtranslatable-fqn other-domain-test-no-params-long-array', + 'translatable-fqn other-domain-test-params-short-array' => 'prefixtranslatable-fqn other-domain-test-params-short-array', + 'translatable-fqn other-domain-test-params-long-array' => 'prefixtranslatable-fqn other-domain-test-params-long-array', + 'translatable-fqn typecast' => 'prefixtranslatable-fqn typecast', + 'translatable-short other-domain-test-no-params-short-array' => 'prefixtranslatable-short other-domain-test-no-params-short-array', + 'translatable-short other-domain-test-no-params-long-array' => 'prefixtranslatable-short other-domain-test-no-params-long-array', + 'translatable-short other-domain-test-params-short-array' => 'prefixtranslatable-short other-domain-test-params-short-array', + 'translatable-short other-domain-test-params-long-array' => 'prefixtranslatable-short other-domain-test-params-long-array', + 'translatable-short typecast' => 'prefixtranslatable-short typecast', + 'other-domain-test-no-params-short-array' => 'prefixother-domain-test-no-params-short-array', + 'other-domain-test-no-params-long-array' => 'prefixother-domain-test-no-params-long-array', + 'other-domain-test-params-short-array' => 'prefixother-domain-test-params-short-array', + 'other-domain-test-params-long-array' => 'prefixother-domain-test-params-long-array', + 'typecast' => 'prefixtypecast', + 'ordered-named-arguments-in-trans-method' => 'prefixordered-named-arguments-in-trans-method', + 'disordered-named-arguments-in-trans-method' => 'prefixdisordered-named-arguments-in-trans-method', + 'variable-assignation-inlined-in-trans-method-call1' => 'prefixvariable-assignation-inlined-in-trans-method-call1', + 'variable-assignation-inlined-in-trans-method-call2' => 'prefixvariable-assignation-inlined-in-trans-method-call2', + 'variable-assignation-inlined-in-trans-method-call3' => 'prefixvariable-assignation-inlined-in-trans-method-call3', + 'variable-assignation-inlined-with-named-arguments-in-trans-method' => 'prefixvariable-assignation-inlined-with-named-arguments-in-trans-method', + ], + 'validators' => [ + 'message-in-constraint-attribute' => 'prefixmessage-in-constraint-attribute', +// 'custom Isbn message from attribute' => 'prefixcustom Isbn message from attribute', + 'custom Isbn message from attribute with options as array' => 'prefixcustom Isbn message from attribute with options as array', + 'custom Length exact message from attribute from named argument' => 'prefixcustom Length exact message from attribute from named argument', + 'custom Length exact message from attribute from named argument 1/2' => 'prefixcustom Length exact message from attribute from named argument 1/2', + 'custom Length min message from attribute from named argument 2/2' => 'prefixcustom Length min message from attribute from named argument 2/2', +// 'custom Isbn message' => 'prefixcustom Isbn message', + 'custom Isbn message with options as array' => 'prefixcustom Isbn message with options as array', + 'custom Isbn message from named argument' => 'prefixcustom Isbn message from named argument', + 'custom Length exact message from named argument' => 'prefixcustom Length exact message from named argument', + 'custom Length exact message from named argument 1/2' => 'prefixcustom Length exact message from named argument 1/2', + 'custom Length min message from named argument 2/2' => 'prefixcustom Length min message from named argument 2/2', + ], + ]; + $actualCatalogue = $catalogue->all(); + + $this->assertEquals($expectedCatalogue, $actualCatalogue); + + $filename = str_replace(\DIRECTORY_SEPARATOR, '/', __DIR__).'/../fixtures/extractor-ast/translatable.html.php'; + $this->assertEquals(['sources' => [$filename.':2']], $catalogue->getMetadata('translatable single-quoted key')); + $this->assertEquals(['sources' => [$filename.':37']], $catalogue->getMetadata('translatable other-domain-test-no-params-short-array', 'not_messages')); + + $filename = str_replace(\DIRECTORY_SEPARATOR, '/', __DIR__).'/../fixtures/extractor-ast/translatable-fqn.html.php'; + $this->assertEquals(['sources' => [$filename.':2']], $catalogue->getMetadata('translatable-fqn single-quoted key')); + $this->assertEquals(['sources' => [$filename.':37']], $catalogue->getMetadata('translatable-fqn other-domain-test-no-params-short-array', 'not_messages')); + + $filename = str_replace(\DIRECTORY_SEPARATOR, '/', __DIR__).'/../fixtures/extractor-ast/translatable-short.html.php'; + $this->assertEquals(['sources' => [$filename.':2']], $catalogue->getMetadata('translatable-short single-quoted key')); + $this->assertEquals(['sources' => [$filename.':37']], $catalogue->getMetadata('translatable-short other-domain-test-no-params-short-array', 'not_messages')); + + $filename = str_replace(\DIRECTORY_SEPARATOR, '/', __DIR__).'/../fixtures/extractor-ast/translation.html.php'; + $this->assertEquals(['sources' => [$filename.':2']], $catalogue->getMetadata('single-quoted key')); + $this->assertEquals(['sources' => [$filename.':37']], $catalogue->getMetadata('other-domain-test-no-params-short-array', 'not_messages')); + } + + public function testExtractionFromIndentedHeredocNowdoc() + { + $catalogue = new MessageCatalogue('en'); + + $extractor = new PhpAstExtractor([ + new TransMethodVisitor(), + new TranslatableMessageVisitor(), + new ConstraintVisitor([ + 'NotBlank', + 'Isbn', + 'Length', + ], new TranslatableMessageVisitor()), + ]); + $extractor->setPrefix('prefix'); + $extractor->extract(__DIR__.'/../fixtures/extractor-7.3/translation.html.php', $catalogue); + + $expectedCatalogue = [ + 'messages' => [ + "heredoc\nindented\n further" => "prefixheredoc\nindented\n further", + "nowdoc\nindented\n further" => "prefixnowdoc\nindented\n further", + ], + ]; + + $this->assertEquals($expectedCatalogue, $catalogue->all()); + } + + public function resourcesProvider(): array + { + $directory = __DIR__.'/../fixtures/extractor-ast/'; + $phpFiles = []; + $splFiles = []; + foreach (new \DirectoryIterator($directory) as $fileInfo) { + if ($fileInfo->isDot()) { + continue; + } + if (\in_array($fileInfo->getBasename(), ['translatable.html.php', 'translatable-fqn.html.php', 'translatable-short.html.php', 'translation.html.php', 'validator-constraints.php'], true)) { + $phpFiles[] = $fileInfo->getPathname(); + } + $splFiles[] = $fileInfo->getFileInfo(); + } + + return [ + [$directory], + [$phpFiles], + [glob($directory.'*')], + [$splFiles], + [new \ArrayObject(glob($directory.'*'))], + [new \ArrayObject($splFiles)], + ]; + } +} diff --git a/src/Symfony/Component/Translation/Tests/Extractor/PhpExtractorTest.php b/src/Symfony/Component/Translation/Tests/Extractor/PhpExtractorTest.php index a8eacdd6fbece..3fad20324305f 100644 --- a/src/Symfony/Component/Translation/Tests/Extractor/PhpExtractorTest.php +++ b/src/Symfony/Component/Translation/Tests/Extractor/PhpExtractorTest.php @@ -15,6 +15,9 @@ use Symfony\Component\Translation\Extractor\PhpExtractor; use Symfony\Component\Translation\MessageCatalogue; +/** + * @group legacy + */ class PhpExtractorTest extends TestCase { /** diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/resource.format.engine b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/resource.format.engine new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/this.is.a.template.format.engine b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/this.is.a.template.format.engine new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable-fqn.html.php b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable-fqn.html.php new file mode 100644 index 0000000000000..87a64c42f1eec --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable-fqn.html.php @@ -0,0 +1,47 @@ +This template is used for translation message extraction tests + + + + + + + + + + + + + + + + + + 'bar'], 'not_messages'); ?> + + 'bar'], 'not_messages'); ?> + + (int) '123'], 'not_messages'); ?> + + diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable-short.html.php b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable-short.html.php new file mode 100644 index 0000000000000..d8842b97f1ada --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable-short.html.php @@ -0,0 +1,47 @@ +This template is used for translation message extraction tests + + + + + + + + + + + + + + + + + + 'bar'], 'not_messages'); ?> + + 'bar'], 'not_messages'); ?> + + (int) '123'], 'not_messages'); ?> + + diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable.html.php b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable.html.php new file mode 100644 index 0000000000000..828707e26ed02 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translatable.html.php @@ -0,0 +1,47 @@ +This template is used for translation message extraction tests + + + + + + + + + + + + + + + + + + 'bar'], 'not_messages'); ?> + + 'bar'], 'not_messages'); ?> + + (int) '123'], 'not_messages'); ?> + + diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translation.html.php b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translation.html.php new file mode 100644 index 0000000000000..781da00b64f9e --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/translation.html.php @@ -0,0 +1,56 @@ +This template is used for translation message extraction tests +trans('single-quoted key'); ?> +trans('double-quoted key'); ?> +trans(<< +trans(<<<'EOF' +nowdoc key +EOF +); ?> +trans( + "double-quoted key with whitespace and escaped \$\n\" sequences" +); ?> +trans( + 'single-quoted key with whitespace and nonescaped \$\n\' sequences' +); ?> +trans(<< +trans(<<<'EOF' +nowdoc key with whitespace and nonescaped \$\n sequences +EOF +); ?> + +trans('single-quoted key with "quote mark at the end"'); ?> + +trans('concatenated'.' message'.<< + +trans('other-domain-test-no-params-short-array', [], 'not_messages'); ?> + +trans('other-domain-test-no-params-long-array', [], 'not_messages'); ?> + +trans('other-domain-test-params-short-array', ['foo' => 'bar'], 'not_messages'); ?> + +trans('other-domain-test-params-long-array', ['foo' => 'bar'], 'not_messages'); ?> + +trans('typecast', ['a' => (int) '123'], 'not_messages'); ?> + +trans('default domain', [], null); ?> + +trans(message: 'ordered-named-arguments-in-trans-method', parameters: [], domain: 'not_messages'); ?> +trans(domain: 'not_messages', message: 'disordered-named-arguments-in-trans-method', parameters: []); ?> + +trans($key = 'variable-assignation-inlined-in-trans-method-call1', $parameters = [], $domain = 'not_messages'); ?> +trans('variable-assignation-inlined-in-trans-method-call2', $parameters = [], $domain = 'not_messages'); ?> +trans('variable-assignation-inlined-in-trans-method-call3', [], $domain = 'not_messages'); ?> + +trans(domain: $domain = 'not_messages', message: $key = 'variable-assignation-inlined-with-named-arguments-in-trans-method', parameters: $parameters = []); ?> diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/validator-constraints.php b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/validator-constraints.php new file mode 100644 index 0000000000000..091d251b63a0d --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/fixtures/extractor-ast/validator-constraints.php @@ -0,0 +1,40 @@ +This template is used for translation message extraction tests + 'isbn10', + 'message' => 'custom Isbn message from attribute with options as array', + ])] + public string $isbn2; +} + +class Foo2 +{ + public function index() + { + $constraint1 = new Assert\Isbn('isbn10', 'custom Isbn message'); // no way to handle those arguments (not named, not in associative array). + $constraint2 = new Assert\Isbn([ + 'type' => 'isbn10', + 'message' => 'custom Isbn message with options as array', + ]); + $constraint3 = new Assert\Isbn(message: 'custom Isbn message from named argument'); + $constraint4 = new Assert\Length(exactMessage: 'custom Length exact message from named argument'); + $constraint5 = new Assert\Length(exactMessage: 'custom Length exact message from named argument 1/2', minMessage: 'custom Length min message from named argument 2/2'); + } +} diff --git a/src/Symfony/Component/Translation/Tests/fixtures/extractor/translation.html.php b/src/Symfony/Component/Translation/Tests/fixtures/extractor/translation.html.php index a6adda0565628..5fc867ffc3e0e 100644 --- a/src/Symfony/Component/Translation/Tests/fixtures/extractor/translation.html.php +++ b/src/Symfony/Component/Translation/Tests/fixtures/extractor/translation.html.php @@ -45,3 +45,4 @@ trans('typecast', ['a' => (int) '123'], 'not_messages'); ?> trans('default domain', [], null); ?> + diff --git a/src/Symfony/Component/Translation/composer.json b/src/Symfony/Component/Translation/composer.json index e25f09ef76054..d75df651b0bc4 100644 --- a/src/Symfony/Component/Translation/composer.json +++ b/src/Symfony/Component/Translation/composer.json @@ -21,6 +21,7 @@ "symfony/translation-contracts": "^2.3|^3.0" }, "require-dev": { + "nikic/php-parser": "^4.13", "symfony/config": "^5.4|^6.0", "symfony/console": "^5.4|^6.0", "symfony/dependency-injection": "^5.4|^6.0", @@ -46,6 +47,7 @@ "symfony/translation-implementation": "2.3|3.0" }, "suggest": { + "nikic/php-parser": "To use PhpAstExtractor", "symfony/config": "", "symfony/yaml": "", "psr/log-implementation": "To use logging capability in translator" pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy