-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Translation] Add lint:translations
command
#57101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,11 @@ | ||
CHANGELOG | ||
========= | ||
|
||
7.2 | ||
--- | ||
|
||
* Add `lint:translations` command | ||
|
||
7.1 | ||
--- | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
<?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\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 <hugo@alliau.me> | ||
*/ | ||
#[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 <info>%command.name%</> command lint translations. | ||
|
||
<info>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<string, array<string, array<string, \Throwable>> $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) { | ||
Check failure on line 81 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php
|
||
try { | ||
$this->translator->trans($id, [], $domain, $messageCatalogue->getLocale()); | ||
Check failure on line 83 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php
|
||
} catch (ExceptionInterface $e) { | ||
$errors[$locale][$domain][$id] = $e; | ||
Check failure on line 85 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php
|
||
} | ||
} | ||
} | ||
} | ||
|
||
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) ? '<info>Yes</>' : '<error>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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
<?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\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' => <<<ICU | ||
{apples, plural, | ||
=0 {There are no apples} | ||
=1 {There is one apple...} | ||
other {There are # apples!} | ||
} | ||
ICU, | ||
], 'en', 'messages+intl-icu'); | ||
$translator->addResource('array', ['hello' => 'Bonjour !'], 'fr', 'messages'); | ||
$translator->addResource('array', [ | ||
'hello_name' => 'Bonjour {name} !', | ||
'num_of_apples' => <<<ICU | ||
{apples, plural, | ||
=0 {Il n'y a pas de pommes} | ||
=1 {Il y a une pomme} | ||
other {Il y a # pommes !} | ||
} | ||
ICU, | ||
], 'fr', 'messages+intl-icu'); | ||
|
||
$command = $this->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' => <<<ICU | ||
{apples, plural, | ||
=0 {There are no apples} | ||
=1 {There is one apple...} | ||
} | ||
ICU, | ||
], 'en', 'messages+intl-icu'); | ||
$translator->addResource('array', ['hello' => 'Bonjour !'], 'fr', 'messages'); | ||
$translator->addResource('array', [ | ||
// Missing "}" | ||
'hello_name' => 'Bonjour {name !', | ||
// "other" is translated | ||
'num_of_apples' => <<<ICU | ||
{apples, plural, | ||
=0 {Il n'y a pas de pommes} | ||
=1 {Il y a une pomme} | ||
autre {Il y a # pommes !} | ||
} | ||
ICU, | ||
], 'fr', 'messages+intl-icu'); | ||
|
||
$command = $this->createCommand($translator, ['en', 'fr']); | ||
$commandTester = new CommandTester($command); | ||
|
||
$this->assertSame(1, $commandTester->execute([], ['decorated' => false])); | ||
|
||
$display = $this->getNormalizedDisplay($commandTester); | ||
$this->assertStringContainsString(<<<EOF | ||
-------- ---------- -------- | ||
Locale Domains Valid? | ||
-------- ---------- -------- | ||
en messages No | ||
fr messages No | ||
-------- ---------- -------- | ||
EOF, $display); | ||
$this->assertStringContainsString(<<<EOF | ||
Errors for locale "en" and domain "messages" | ||
-------------------------------------------- | ||
|
||
Translation key "num_of_apples" is invalid: | ||
|
||
[ERROR] Invalid message format (error #65807): msgfmt_create: message formatter creation failed: | ||
U_DEFAULT_KEYWORD_MISSING | ||
EOF, $display); | ||
$this->assertStringContainsString(<<<EOF | ||
Errors for locale "fr" and domain "messages" | ||
-------------------------------------------- | ||
|
||
Translation key "hello_name" is invalid: | ||
|
||
[ERROR] Invalid message format (error #65799): pattern syntax error (parse error at offset 9, after "Bonjour {", before | ||
or at "name !"): U_PATTERN_SYNTAX_ERROR | ||
|
||
Translation key "num_of_apples" is invalid: | ||
|
||
[ERROR] Invalid message format (error #65807): msgfmt_create: message formatter creation failed: | ||
U_DEFAULT_KEYWORD_MISSING | ||
EOF, $display); | ||
} | ||
|
||
private function createCommand(Translator $translator, array $enabledLocales): Command | ||
{ | ||
$command = new TranslationLintCommand($translator, $enabledLocales); | ||
|
||
$application = new Application(); | ||
$application->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)))); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
InputOption::VALUE_OPTIONAL
does not make sense here IMO. This would allow passing the option asbin/console lint:translations --locales
, without passing a value for it.Making the value of an option optional is a rare case and is not what you want in most commands.
Also, I think
locale
would be a better name for the option aslint:translations --locale fr --locale en
makes more sense thanlint:translations --locales fr --locales en
(InputOption::VALUE_IS_ARRAY
works by repeating the option in the command, but each occurrence provides a single value)