diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 5586e6653f62a..b5f325ff3e146 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -165,6 +165,7 @@ use Symfony\Component\String\LazyString; use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\Translation\Bridge as TranslationBridge; +use Symfony\Component\Translation\Command\TranslationLintCommand as BaseTranslationLintCommand; use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand; use Symfony\Component\Translation\Extractor\PhpAstExtractor; use Symfony\Component\Translation\LocaleSwitcher; @@ -245,6 +246,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->removeDefinition('console.command.yaml_lint'); } + if (!class_exists(BaseTranslationLintCommand::class)) { + $container->removeDefinition('console.command.translation_lint'); + } + if (!class_exists(DebugCommand::class)) { $container->removeDefinition('console.command.dotenv_debug'); } @@ -1413,6 +1418,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $container->removeDefinition('console.command.translation_extract'); $container->removeDefinition('console.command.translation_pull'); $container->removeDefinition('console.command.translation_push'); + $container->removeDefinition('console.command.translation_lint'); return; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index b4f7dfcf3ea5e..9df82e20e2c28 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -54,6 +54,7 @@ use Symfony\Component\Messenger\Command\StopWorkersCommand; use Symfony\Component\Scheduler\Command\DebugCommand as SchedulerDebugCommand; use Symfony\Component\Serializer\Command\DebugCommand as SerializerDebugCommand; +use Symfony\Component\Translation\Command\TranslationLintCommand; use Symfony\Component\Translation\Command\TranslationPullCommand; use Symfony\Component\Translation\Command\TranslationPushCommand; use Symfony\Component\Translation\Command\XliffLintCommand; @@ -317,6 +318,13 @@ ->set('console.command.yaml_lint', YamlLintCommand::class) ->tag('console.command') + ->set('console.command.translation_lint', TranslationLintCommand::class) + ->args([ + service('translator'), + param('kernel.enabled_locales'), + ]) + ->tag('console.command') + ->set('console.command.form_debug', \Symfony\Component\Form\Command\DebugCommand::class) ->args([ service('form.registry'), diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 5c18dde5d19f4..7eb45d39652b2 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.2 +--- + + * Add `lint:translations` command + 7.1 --- diff --git a/src/Symfony/Component/Translation/Command/TranslationLintCommand.php b/src/Symfony/Component/Translation/Command/TranslationLintCommand.php new file mode 100644 index 0000000000000..ba4a6c1a69191 --- /dev/null +++ b/src/Symfony/Component/Translation/Command/TranslationLintCommand.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Translation\Exception\ExceptionInterface; +use Symfony\Component\Translation\TranslatorBagInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Lint translations files syntax and outputs encountered errors. + * + * @author Hugo Alliaume + */ +#[AsCommand(name: 'lint:translations', description: 'Lint translations files syntax and outputs encountered errors')] +class TranslationLintCommand extends Command +{ + private SymfonyStyle $io; + + public function __construct( + private TranslatorInterface&TranslatorBagInterface $translator, + private array $enabledLocales = [], + ) { + parent::__construct(); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('locales')) { + $suggestions->suggestValues($this->enabledLocales); + } + } + + protected function configure(): void + { + $this + ->setDefinition([ + new InputOption('locales', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Specify the locales to lint.', $this->enabledLocales), + ]) + ->setHelp(<<<'EOF' +The %command.name% command lint translations. + + php %command.full_name% +EOF + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->io = new SymfonyStyle($input, $output); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $locales = $input->getOption('locales'); + + /** @var array> $errors */ + $errors = []; + $domainsByLocales = []; + + foreach ($locales as $locale) { + $messageCatalogue = $this->translator->getCatalogue($locale); + + foreach ($domainsByLocales[$locale] = $messageCatalogue->getDomains() as $domain) { + foreach ($messageCatalogue->all($domain) as $id => $translation) { + try { + $this->translator->trans($id, [], $domain, $messageCatalogue->getLocale()); + } catch (ExceptionInterface $e) { + $errors[$locale][$domain][$id] = $e; + } + } + } + } + + if (!$domainsByLocales) { + $this->io->error('No translation files were found.'); + + return Command::SUCCESS; + } + + $this->io->table( + ['Locale', 'Domains', 'Valid?'], + array_map( + static fn (string $locale, array $domains) => [ + $locale, + implode(', ', $domains), + !\array_key_exists($locale, $errors) ? 'Yes' : 'No', + ], + array_keys($domainsByLocales), + $domainsByLocales + ), + ); + + if ($errors) { + foreach ($errors as $locale => $domains) { + foreach ($domains as $domain => $domainsErrors) { + $this->io->section(sprintf('Errors for locale "%s" and domain "%s"', $locale, $domain)); + + foreach ($domainsErrors as $id => $error) { + $this->io->text(sprintf('Translation key "%s" is invalid:', $id)); + $this->io->error($error->getMessage()); + } + } + } + + return Command::FAILURE; + } + + $this->io->success('All translations are valid.'); + + return Command::SUCCESS; + } +} diff --git a/src/Symfony/Component/Translation/Tests/Command/TranslationLintCommandTest.php b/src/Symfony/Component/Translation/Tests/Command/TranslationLintCommandTest.php new file mode 100644 index 0000000000000..c2b81c9d92ff0 --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Command/TranslationLintCommandTest.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Translation\Command\TranslationLintCommand; +use Symfony\Component\Translation\Loader\ArrayLoader; +use Symfony\Component\Translation\Translator; + +final class TranslationLintCommandTest extends TestCase +{ + public function testLintCorrectTranslations() + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', ['hello' => 'Hello!'], 'en', 'messages'); + $translator->addResource('array', [ + 'hello_name' => 'Hello {name}!', + 'num_of_apples' => <<addResource('array', ['hello' => 'Bonjour !'], 'fr', 'messages'); + $translator->addResource('array', [ + 'hello_name' => 'Bonjour {name} !', + 'num_of_apples' => <<createCommand($translator, ['en', 'fr']); + $commandTester = new CommandTester($command); + + $commandTester->execute([], ['decorated' => false]); + + $commandTester->assertCommandIsSuccessful(); + + $display = $this->getNormalizedDisplay($commandTester); + $this->assertStringContainsString('[OK] All translations are valid.', $display); + } + + public function testLintMalformedIcuTranslations() + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', ['hello' => 'Hello!'], 'en', 'messages'); + $translator->addResource('array', [ + 'hello_name' => 'Hello {name}!', + // Missing "other" case + 'num_of_apples' => <<addResource('array', ['hello' => 'Bonjour !'], 'fr', 'messages'); + $translator->addResource('array', [ + // Missing "}" + 'hello_name' => 'Bonjour {name !', + // "other" is translated + 'num_of_apples' => <<createCommand($translator, ['en', 'fr']); + $commandTester = new CommandTester($command); + + $this->assertSame(1, $commandTester->execute([], ['decorated' => false])); + + $display = $this->getNormalizedDisplay($commandTester); + $this->assertStringContainsString(<<assertStringContainsString(<<assertStringContainsString(<<add($command); + + return $command; + } + + /** + * Normalize the CommandTester display, by removing trailing spaces for each line. + */ + private function getNormalizedDisplay(CommandTester $commandTester): string + { + return implode(\PHP_EOL, array_map(fn (string $line) => rtrim($line), explode(\PHP_EOL, $commandTester->getDisplay(true)))); + } +}