TranslationDebugCommand.php 13 KB

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