vendor/symfony/framework-bundle/Command/TranslationDebugCommand.php line 62

Open in your IDE?
  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Bundle\FrameworkBundle\Command;
  11. use Symfony\Component\Console\Attribute\AsCommand;
  12. use Symfony\Component\Console\Command\Command;
  13. use Symfony\Component\Console\Completion\CompletionInput;
  14. use Symfony\Component\Console\Completion\CompletionSuggestions;
  15. use Symfony\Component\Console\Exception\InvalidArgumentException;
  16. use Symfony\Component\Console\Input\InputArgument;
  17. use Symfony\Component\Console\Input\InputInterface;
  18. use Symfony\Component\Console\Input\InputOption;
  19. use Symfony\Component\Console\Output\OutputInterface;
  20. use Symfony\Component\Console\Style\SymfonyStyle;
  21. use Symfony\Component\HttpKernel\KernelInterface;
  22. use Symfony\Component\Translation\Catalogue\MergeOperation;
  23. use Symfony\Component\Translation\DataCollectorTranslator;
  24. use Symfony\Component\Translation\Extractor\ExtractorInterface;
  25. use Symfony\Component\Translation\LoggingTranslator;
  26. use Symfony\Component\Translation\MessageCatalogue;
  27. use Symfony\Component\Translation\Reader\TranslationReaderInterface;
  28. use Symfony\Component\Translation\Translator;
  29. use Symfony\Contracts\Translation\TranslatorInterface;
  30. /**
  31.  * Helps finding unused or missing translation messages in a given locale
  32.  * and comparing them with the fallback ones.
  33.  *
  34.  * @author Florian Voutzinos <florian@voutzinos.com>
  35.  *
  36.  * @final
  37.  */
  38. #[AsCommand(name'debug:translation'description'Display translation messages information')]
  39. class TranslationDebugCommand extends Command
  40. {
  41.     public const EXIT_CODE_GENERAL_ERROR 64;
  42.     public const EXIT_CODE_MISSING 65;
  43.     public const EXIT_CODE_UNUSED 66;
  44.     public const EXIT_CODE_FALLBACK 68;
  45.     public const MESSAGE_MISSING 0;
  46.     public const MESSAGE_UNUSED 1;
  47.     public const MESSAGE_EQUALS_FALLBACK 2;
  48.     private TranslatorInterface $translator;
  49.     private TranslationReaderInterface $reader;
  50.     private ExtractorInterface $extractor;
  51.     private ?string $defaultTransPath;
  52.     private ?string $defaultViewsPath;
  53.     private array $transPaths;
  54.     private array $codePaths;
  55.     private array $enabledLocales;
  56.     public function __construct(TranslatorInterface $translatorTranslationReaderInterface $readerExtractorInterface $extractorstring $defaultTransPath nullstring $defaultViewsPath null, array $transPaths = [], array $codePaths = [], array $enabledLocales = [])
  57.     {
  58.         parent::__construct();
  59.         $this->translator $translator;
  60.         $this->reader $reader;
  61.         $this->extractor $extractor;
  62.         $this->defaultTransPath $defaultTransPath;
  63.         $this->defaultViewsPath $defaultViewsPath;
  64.         $this->transPaths $transPaths;
  65.         $this->codePaths $codePaths;
  66.         $this->enabledLocales $enabledLocales;
  67.     }
  68.     /**
  69.      * {@inheritdoc}
  70.      */
  71.     protected function configure()
  72.     {
  73.         $this
  74.             ->setDefinition([
  75.                 new InputArgument('locale'InputArgument::REQUIRED'The locale'),
  76.                 new InputArgument('bundle'InputArgument::OPTIONAL'The bundle name or directory where to load the messages'),
  77.                 new InputOption('domain'nullInputOption::VALUE_OPTIONAL'The messages domain'),
  78.                 new InputOption('only-missing'nullInputOption::VALUE_NONE'Display only missing messages'),
  79.                 new InputOption('only-unused'nullInputOption::VALUE_NONE'Display only unused messages'),
  80.                 new InputOption('all'nullInputOption::VALUE_NONE'Load messages from all registered bundles'),
  81.             ])
  82.             ->setHelp(<<<'EOF'
  83. The <info>%command.name%</info> command helps finding unused or missing translation
  84. messages and comparing them with the fallback ones by inspecting the
  85. templates and translation files of a given bundle or the default translations directory.
  86. You can display information about bundle translations in a specific locale:
  87.   <info>php %command.full_name% en AcmeDemoBundle</info>
  88. You can also specify a translation domain for the search:
  89.   <info>php %command.full_name% --domain=messages en AcmeDemoBundle</info>
  90. You can only display missing messages:
  91.   <info>php %command.full_name% --only-missing en AcmeDemoBundle</info>
  92. You can only display unused messages:
  93.   <info>php %command.full_name% --only-unused en AcmeDemoBundle</info>
  94. You can display information about application translations in a specific locale:
  95.   <info>php %command.full_name% en</info>
  96. You can display information about translations in all registered bundles in a specific locale:
  97.   <info>php %command.full_name% --all en</info>
  98. EOF
  99.             )
  100.         ;
  101.     }
  102.     /**
  103.      * {@inheritdoc}
  104.      */
  105.     protected function execute(InputInterface $inputOutputInterface $output): int
  106.     {
  107.         $io = new SymfonyStyle($input$output);
  108.         $locale $input->getArgument('locale');
  109.         $domain $input->getOption('domain');
  110.         $exitCode self::SUCCESS;
  111.         /** @var KernelInterface $kernel */
  112.         $kernel $this->getApplication()->getKernel();
  113.         // Define Root Paths
  114.         $transPaths $this->getRootTransPaths();
  115.         $codePaths $this->getRootCodePaths($kernel);
  116.         // Override with provided Bundle info
  117.         if (null !== $input->getArgument('bundle')) {
  118.             try {
  119.                 $bundle $kernel->getBundle($input->getArgument('bundle'));
  120.                 $bundleDir $bundle->getPath();
  121.                 $transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' $bundleDir.'/translations'];
  122.                 $codePaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' $bundleDir.'/templates'];
  123.                 if ($this->defaultTransPath) {
  124.                     $transPaths[] = $this->defaultTransPath;
  125.                 }
  126.                 if ($this->defaultViewsPath) {
  127.                     $codePaths[] = $this->defaultViewsPath;
  128.                 }
  129.             } catch (\InvalidArgumentException) {
  130.                 // such a bundle does not exist, so treat the argument as path
  131.                 $path $input->getArgument('bundle');
  132.                 $transPaths = [$path.'/translations'];
  133.                 $codePaths = [$path.'/templates'];
  134.                 if (!is_dir($transPaths[0])) {
  135.                     throw new InvalidArgumentException(sprintf('"%s" is neither an enabled bundle nor a directory.'$transPaths[0]));
  136.                 }
  137.             }
  138.         } elseif ($input->getOption('all')) {
  139.             foreach ($kernel->getBundles() as $bundle) {
  140.                 $bundleDir $bundle->getPath();
  141.                 $transPaths[] = is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' $bundle->getPath().'/translations';
  142.                 $codePaths[] = is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' $bundle->getPath().'/templates';
  143.             }
  144.         }
  145.         // Extract used messages
  146.         $extractedCatalogue $this->extractMessages($locale$codePaths);
  147.         // Load defined messages
  148.         $currentCatalogue $this->loadCurrentMessages($locale$transPaths);
  149.         // Merge defined and extracted messages to get all message ids
  150.         $mergeOperation = new MergeOperation($extractedCatalogue$currentCatalogue);
  151.         $allMessages $mergeOperation->getResult()->all($domain);
  152.         if (null !== $domain) {
  153.             $allMessages = [$domain => $allMessages];
  154.         }
  155.         // No defined or extracted messages
  156.         if (empty($allMessages) || null !== $domain && empty($allMessages[$domain])) {
  157.             $outputMessage sprintf('No defined or extracted messages for locale "%s"'$locale);
  158.             if (null !== $domain) {
  159.                 $outputMessage .= sprintf(' and domain "%s"'$domain);
  160.             }
  161.             $io->getErrorStyle()->warning($outputMessage);
  162.             return self::EXIT_CODE_GENERAL_ERROR;
  163.         }
  164.         // Load the fallback catalogues
  165.         $fallbackCatalogues $this->loadFallbackCatalogues($locale$transPaths);
  166.         // Display header line
  167.         $headers = ['State''Domain''Id'sprintf('Message Preview (%s)'$locale)];
  168.         foreach ($fallbackCatalogues as $fallbackCatalogue) {
  169.             $headers[] = sprintf('Fallback Message Preview (%s)'$fallbackCatalogue->getLocale());
  170.         }
  171.         $rows = [];
  172.         // Iterate all message ids and determine their state
  173.         foreach ($allMessages as $domain => $messages) {
  174.             foreach (array_keys($messages) as $messageId) {
  175.                 $value $currentCatalogue->get($messageId$domain);
  176.                 $states = [];
  177.                 if ($extractedCatalogue->defines($messageId$domain)) {
  178.                     if (!$currentCatalogue->defines($messageId$domain)) {
  179.                         $states[] = self::MESSAGE_MISSING;
  180.                         if (!$input->getOption('only-unused')) {
  181.                             $exitCode $exitCode self::EXIT_CODE_MISSING;
  182.                         }
  183.                     }
  184.                 } elseif ($currentCatalogue->defines($messageId$domain)) {
  185.                     $states[] = self::MESSAGE_UNUSED;
  186.                     if (!$input->getOption('only-missing')) {
  187.                         $exitCode $exitCode self::EXIT_CODE_UNUSED;
  188.                     }
  189.                 }
  190.                 if (!\in_array(self::MESSAGE_UNUSED$states) && $input->getOption('only-unused')
  191.                     || !\in_array(self::MESSAGE_MISSING$states) && $input->getOption('only-missing')
  192.                 ) {
  193.                     continue;
  194.                 }
  195.                 foreach ($fallbackCatalogues as $fallbackCatalogue) {
  196.                     if ($fallbackCatalogue->defines($messageId$domain) && $value === $fallbackCatalogue->get($messageId$domain)) {
  197.                         $states[] = self::MESSAGE_EQUALS_FALLBACK;
  198.                         $exitCode $exitCode self::EXIT_CODE_FALLBACK;
  199.                         break;
  200.                     }
  201.                 }
  202.                 $row = [$this->formatStates($states), $domain$this->formatId($messageId), $this->sanitizeString($value)];
  203.                 foreach ($fallbackCatalogues as $fallbackCatalogue) {
  204.                     $row[] = $this->sanitizeString($fallbackCatalogue->get($messageId$domain));
  205.                 }
  206.                 $rows[] = $row;
  207.             }
  208.         }
  209.         $io->table($headers$rows);
  210.         return $exitCode;
  211.     }
  212.     public function complete(CompletionInput $inputCompletionSuggestions $suggestions): void
  213.     {
  214.         if ($input->mustSuggestArgumentValuesFor('locale')) {
  215.             $suggestions->suggestValues($this->enabledLocales);
  216.             return;
  217.         }
  218.         /** @var KernelInterface $kernel */
  219.         $kernel $this->getApplication()->getKernel();
  220.         if ($input->mustSuggestArgumentValuesFor('bundle')) {
  221.             $availableBundles = [];
  222.             foreach ($kernel->getBundles() as $bundle) {
  223.                 $availableBundles[] = $bundle->getName();
  224.                 if ($extension $bundle->getContainerExtension()) {
  225.                     $availableBundles[] = $extension->getAlias();
  226.                 }
  227.             }
  228.             $suggestions->suggestValues($availableBundles);
  229.             return;
  230.         }
  231.         if ($input->mustSuggestOptionValuesFor('domain')) {
  232.             $locale $input->getArgument('locale');
  233.             $mergeOperation = new MergeOperation(
  234.                 $this->extractMessages($locale$this->getRootCodePaths($kernel)),
  235.                 $this->loadCurrentMessages($locale$this->getRootTransPaths())
  236.             );
  237.             $suggestions->suggestValues($mergeOperation->getDomains());
  238.         }
  239.     }
  240.     private function formatState(int $state): string
  241.     {
  242.         if (self::MESSAGE_MISSING === $state) {
  243.             return '<error> missing </error>';
  244.         }
  245.         if (self::MESSAGE_UNUSED === $state) {
  246.             return '<comment> unused </comment>';
  247.         }
  248.         if (self::MESSAGE_EQUALS_FALLBACK === $state) {
  249.             return '<info> fallback </info>';
  250.         }
  251.         return $state;
  252.     }
  253.     private function formatStates(array $states): string
  254.     {
  255.         $result = [];
  256.         foreach ($states as $state) {
  257.             $result[] = $this->formatState($state);
  258.         }
  259.         return implode(' '$result);
  260.     }
  261.     private function formatId(string $id): string
  262.     {
  263.         return sprintf('<fg=cyan;options=bold>%s</>'$id);
  264.     }
  265.     private function sanitizeString(string $stringint $length 40): string
  266.     {
  267.         $string trim(preg_replace('/\s+/'' '$string));
  268.         if (false !== $encoding mb_detect_encoding($stringnulltrue)) {
  269.             if (mb_strlen($string$encoding) > $length) {
  270.                 return mb_substr($string0$length 3$encoding).'...';
  271.             }
  272.         } elseif (\strlen($string) > $length) {
  273.             return substr($string0$length 3).'...';
  274.         }
  275.         return $string;
  276.     }
  277.     private function extractMessages(string $locale, array $transPaths): MessageCatalogue
  278.     {
  279.         $extractedCatalogue = new MessageCatalogue($locale);
  280.         foreach ($transPaths as $path) {
  281.             if (is_dir($path) || is_file($path)) {
  282.                 $this->extractor->extract($path$extractedCatalogue);
  283.             }
  284.         }
  285.         return $extractedCatalogue;
  286.     }
  287.     private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue
  288.     {
  289.         $currentCatalogue = new MessageCatalogue($locale);
  290.         foreach ($transPaths as $path) {
  291.             if (is_dir($path)) {
  292.                 $this->reader->read($path$currentCatalogue);
  293.             }
  294.         }
  295.         return $currentCatalogue;
  296.     }
  297.     /**
  298.      * @return MessageCatalogue[]
  299.      */
  300.     private function loadFallbackCatalogues(string $locale, array $transPaths): array
  301.     {
  302.         $fallbackCatalogues = [];
  303.         if ($this->translator instanceof Translator || $this->translator instanceof DataCollectorTranslator || $this->translator instanceof LoggingTranslator) {
  304.             foreach ($this->translator->getFallbackLocales() as $fallbackLocale) {
  305.                 if ($fallbackLocale === $locale) {
  306.                     continue;
  307.                 }
  308.                 $fallbackCatalogue = new MessageCatalogue($fallbackLocale);
  309.                 foreach ($transPaths as $path) {
  310.                     if (is_dir($path)) {
  311.                         $this->reader->read($path$fallbackCatalogue);
  312.                     }
  313.                 }
  314.                 $fallbackCatalogues[] = $fallbackCatalogue;
  315.             }
  316.         }
  317.         return $fallbackCatalogues;
  318.     }
  319.     private function getRootTransPaths(): array
  320.     {
  321.         $transPaths $this->transPaths;
  322.         if ($this->defaultTransPath) {
  323.             $transPaths[] = $this->defaultTransPath;
  324.         }
  325.         return $transPaths;
  326.     }
  327.     private function getRootCodePaths(KernelInterface $kernel): array
  328.     {
  329.         $codePaths $this->codePaths;
  330.         $codePaths[] = $kernel->getProjectDir().'/src';
  331.         if ($this->defaultViewsPath) {
  332.             $codePaths[] = $this->defaultViewsPath;
  333.         }
  334.         return $codePaths;
  335.     }
  336. }