Skip to content

[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

Merged
merged 1 commit into from
May 31, 2024
Merged
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 @@ -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;
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/Translation/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.2
---

* Add `lint:translations` command

7.1
---

Expand Down
129 changes: 129 additions & 0 deletions src/Symfony/Component/Translation/Command/TranslationLintCommand.php
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),
Copy link
Member

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 as bin/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 as lint:translations --locale fr --locale en makes more sense than lint:translations --locales fr --locales en (InputOption::VALUE_IS_ARRAY works by repeating the option in the command, but each occurrence provides a single value)

])
->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

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Translation/Command/TranslationLintCommand.php:81:49: InvalidArgument: Argument 1 of Symfony\Component\Translation\MessageCatalogueInterface::all expects null|string, but array<array-key, mixed> provided (see https://psalm.dev/004)

Check failure on line 81 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Translation/Command/TranslationLintCommand.php:81:49: InvalidArgument: Argument 1 of Symfony\Component\Translation\MessageCatalogueInterface::all expects null|string, but array<array-key, mixed> provided (see https://psalm.dev/004)
try {
$this->translator->trans($id, [], $domain, $messageCatalogue->getLocale());

Check failure on line 83 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Translation/Command/TranslationLintCommand.php:83:59: InvalidArgument: Argument 3 of Symfony\Contracts\Translation\TranslatorInterface::trans expects null|string, but array<array-key, mixed> provided (see https://psalm.dev/004)

Check failure on line 83 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Translation/Command/TranslationLintCommand.php:83:59: InvalidArgument: Argument 3 of Symfony\Contracts\Translation\TranslatorInterface::trans expects null|string, but array<array-key, mixed> provided (see https://psalm.dev/004)
} catch (ExceptionInterface $e) {
$errors[$locale][$domain][$id] = $e;

Check failure on line 85 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArrayOffset

src/Symfony/Component/Translation/Command/TranslationLintCommand.php:85:25: InvalidArrayOffset: Cannot access value on variable $errors[$locale][$domain] using a array<array-key, mixed> offset, expecting array-key (see https://psalm.dev/115)

Check failure on line 85 in src/Symfony/Component/Translation/Command/TranslationLintCommand.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArrayOffset

src/Symfony/Component/Translation/Command/TranslationLintCommand.php:85:25: InvalidArrayOffset: Cannot access value on variable $errors[$locale][$domain] using a array<array-key, mixed> offset, expecting array-key (see https://psalm.dev/115)
}
}
}
}

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))));
}
}
Loading