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

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