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

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