2014-01-19 23:09:51 +00:00
< ? 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\Bundle\FrameworkBundle\Command ;
2017-08-07 11:35:57 +01:00
use Symfony\Component\Console\Command\Command ;
2018-05-04 00:18:14 +01:00
use Symfony\Component\Console\Exception\InvalidArgumentException ;
2014-01-19 23:09:51 +00:00
use Symfony\Component\Console\Input\InputArgument ;
2018-07-26 10:03:18 +01:00
use Symfony\Component\Console\Input\InputInterface ;
2014-01-19 23:09:51 +00:00
use Symfony\Component\Console\Input\InputOption ;
2015-04-11 17:38:38 +01:00
use Symfony\Component\Console\Output\OutputInterface ;
2018-07-26 10:03:18 +01:00
use Symfony\Component\Console\Style\SymfonyStyle ;
2017-07-22 10:58:19 +01:00
use Symfony\Component\HttpKernel\KernelInterface ;
2015-04-11 17:38:38 +01:00
use Symfony\Component\Translation\Catalogue\MergeOperation ;
2016-11-09 20:23:10 +00:00
use Symfony\Component\Translation\DataCollectorTranslator ;
2017-07-22 10:58:19 +01:00
use Symfony\Component\Translation\Extractor\ExtractorInterface ;
2016-11-09 20:23:10 +00:00
use Symfony\Component\Translation\LoggingTranslator ;
2014-01-19 23:09:51 +00:00
use Symfony\Component\Translation\MessageCatalogue ;
2017-07-25 19:07:39 +01:00
use Symfony\Component\Translation\Reader\TranslationReaderInterface ;
2014-03-03 16:28:48 +00:00
use Symfony\Component\Translation\Translator ;
2018-10-01 12:27:53 +01:00
use Symfony\Component\Translation\TranslatorInterface as LegacyTranslatorInterface ;
2018-08-16 14:45:57 +01:00
use Symfony\Contracts\Translation\TranslatorInterface ;
2014-01-19 23:09:51 +00:00
/**
* Helps finding unused or missing translation messages in a given locale
* and comparing them with the fallback ones .
*
* @ author Florian Voutzinos < florian @ voutzinos . com >
2017-07-22 10:58:19 +01:00
*
2018-02-19 12:18:43 +00:00
* @ final
2014-01-19 23:09:51 +00:00
*/
2017-08-07 11:35:57 +01:00
class TranslationDebugCommand extends Command
2014-01-19 23:09:51 +00:00
{
const MESSAGE_MISSING = 0 ;
const MESSAGE_UNUSED = 1 ;
const MESSAGE_EQUALS_FALLBACK = 2 ;
2017-08-21 09:40:46 +01:00
protected static $defaultName = 'debug:translation' ;
2017-08-06 11:59:30 +01:00
private $translator ;
2017-07-25 19:07:39 +01:00
private $reader ;
2017-08-06 11:59:30 +01:00
private $extractor ;
2017-11-21 06:48:50 +00:00
private $defaultTransPath ;
private $defaultViewsPath ;
2017-08-06 11:59:30 +01:00
2018-10-01 12:27:53 +01:00
/**
* @ param TranslatorInterface $translator
*/
public function __construct ( $translator , TranslationReaderInterface $reader , ExtractorInterface $extractor , string $defaultTransPath = null , string $defaultViewsPath = null )
2017-07-22 10:58:19 +01:00
{
2018-10-01 12:27:53 +01:00
if ( ! $translator instanceof LegacyTranslatorInterface && ! $translator instanceof TranslatorInterface ) {
throw new \TypeError ( sprintf ( 'Argument 1 passed to %s() must be an instance of %s, %s given.' , __METHOD__ , TranslatorInterface :: class , \is_object ( $translator ) ? \get_class ( $translator ) : \gettype ( $translator )));
}
2017-08-06 11:59:30 +01:00
parent :: __construct ();
2017-07-22 10:58:19 +01:00
$this -> translator = $translator ;
2017-07-25 19:07:39 +01:00
$this -> reader = $reader ;
2017-07-22 10:58:19 +01:00
$this -> extractor = $extractor ;
2017-11-21 06:48:50 +00:00
$this -> defaultTransPath = $defaultTransPath ;
$this -> defaultViewsPath = $defaultViewsPath ;
2017-07-22 10:58:19 +01:00
}
2014-01-19 23:09:51 +00:00
/**
* { @ inheritdoc }
*/
protected function configure ()
{
$this
-> setDefinition ( array (
new InputArgument ( 'locale' , InputArgument :: REQUIRED , 'The locale' ),
2015-01-18 20:33:17 +00:00
new InputArgument ( 'bundle' , InputArgument :: OPTIONAL , 'The bundle name or directory where to load the messages, defaults to app/Resources folder' ),
2014-01-19 23:09:51 +00:00
new InputOption ( 'domain' , null , InputOption :: VALUE_OPTIONAL , 'The messages domain' ),
new InputOption ( 'only-missing' , null , InputOption :: VALUE_NONE , 'Displays only missing messages' ),
new InputOption ( 'only-unused' , null , InputOption :: VALUE_NONE , 'Displays only unused messages' ),
2015-04-11 17:38:38 +01:00
new InputOption ( 'all' , null , InputOption :: VALUE_NONE , 'Load messages from all registered bundles' ),
2014-01-19 23:09:51 +00:00
))
2015-01-18 20:33:17 +00:00
-> setDescription ( 'Displays translation messages information' )
2016-10-30 09:34:06 +00:00
-> setHelp ( <<< 'EOF'
2014-03-03 16:28:48 +00:00
The < info >% command . name %</ info > command helps finding unused or missing translation
messages and comparing them with the fallback ones by inspecting the
2015-01-18 20:33:17 +00:00
templates and translation files of a given bundle or the app folder .
2014-01-19 23:09:51 +00:00
2014-03-03 16:28:48 +00:00
You can display information about bundle translations in a specific locale :
2014-01-19 23:09:51 +00:00
2015-01-04 09:52:37 +00:00
< info > php % command . full_name % en AcmeDemoBundle </ info >
2014-01-19 23:09:51 +00:00
You can also specify a translation domain for the search :
2015-01-04 09:52:37 +00:00
< info > php % command . full_name % -- domain = messages en AcmeDemoBundle </ info >
2014-01-19 23:09:51 +00:00
You can only display missing messages :
2015-01-04 09:52:37 +00:00
< info > php % command . full_name % -- only - missing en AcmeDemoBundle </ info >
2014-01-19 23:09:51 +00:00
You can only display unused messages :
2015-01-04 09:52:37 +00:00
< info > php % command . full_name % -- only - unused en AcmeDemoBundle </ info >
2014-03-13 04:05:55 +00:00
2015-01-18 20:33:17 +00:00
You can display information about app translations in a specific locale :
< info > php % command . full_name % en </ info >
2015-04-11 17:38:38 +01:00
You can display information about translations in all registered bundles in a specific locale :
< info > php % command . full_name % -- all en </ info >
2014-01-19 23:09:51 +00:00
EOF
)
;
}
/**
* { @ inheritdoc }
*/
protected function execute ( InputInterface $input , OutputInterface $output )
{
2015-12-07 12:11:35 +00:00
$io = new SymfonyStyle ( $input , $output );
2015-01-13 14:10:03 +00:00
2014-01-19 23:09:51 +00:00
$locale = $input -> getArgument ( 'locale' );
$domain = $input -> getOption ( 'domain' );
2017-07-22 10:58:19 +01:00
/** @var KernelInterface $kernel */
$kernel = $this -> getApplication () -> getKernel ();
2015-01-18 20:33:17 +00:00
2017-11-21 06:48:50 +00:00
// Define Root Paths
$transPaths = array ( $kernel -> getRootDir () . '/Resources/translations' );
if ( $this -> defaultTransPath ) {
$transPaths [] = $this -> defaultTransPath ;
}
$viewsPaths = array ( $kernel -> getRootDir () . '/Resources/views' );
if ( $this -> defaultViewsPath ) {
$viewsPaths [] = $this -> defaultViewsPath ;
}
2015-01-18 20:33:17 +00:00
// Override with provided Bundle info
if ( null !== $input -> getArgument ( 'bundle' )) {
try {
2015-06-25 13:52:11 +01:00
$bundle = $kernel -> getBundle ( $input -> getArgument ( 'bundle' ));
2017-11-21 06:48:50 +00:00
$transPaths = array ( $bundle -> getPath () . '/Resources/translations' );
if ( $this -> defaultTransPath ) {
$transPaths [] = $this -> defaultTransPath . '/' . $bundle -> getName ();
}
$transPaths [] = sprintf ( '%s/Resources/%s/translations' , $kernel -> getRootDir (), $bundle -> getName ());
$viewsPaths = array ( $bundle -> getPath () . '/Resources/views' );
if ( $this -> defaultViewsPath ) {
$viewsPaths [] = $this -> defaultViewsPath . '/bundles/' . $bundle -> getName ();
}
$viewsPaths [] = sprintf ( '%s/Resources/%s/views' , $kernel -> getRootDir (), $bundle -> getName ());
2015-01-18 20:33:17 +00:00
} catch ( \InvalidArgumentException $e ) {
// such a bundle does not exist, so treat the argument as path
2017-11-21 06:48:50 +00:00
$transPaths = array ( $input -> getArgument ( 'bundle' ) . '/Resources/translations' );
$viewsPaths = array ( $input -> getArgument ( 'bundle' ) . '/Resources/views' );
2015-01-18 20:33:17 +00:00
2015-06-25 13:52:11 +01:00
if ( ! is_dir ( $transPaths [ 0 ])) {
2018-05-04 00:18:14 +01:00
throw new InvalidArgumentException ( sprintf ( '"%s" is neither an enabled bundle nor a directory.' , $transPaths [ 0 ]));
2015-01-18 20:33:17 +00:00
}
}
2015-04-11 17:38:38 +01:00
} elseif ( $input -> getOption ( 'all' )) {
foreach ( $kernel -> getBundles () as $bundle ) {
2017-11-21 06:48:50 +00:00
$transPaths [] = $bundle -> getPath () . '/Resources/translations' ;
if ( $this -> defaultTransPath ) {
$transPaths [] = $this -> defaultTransPath . '/' . $bundle -> getName ();
}
$transPaths [] = sprintf ( '%s/Resources/%s/translations' , $kernel -> getRootDir (), $bundle -> getName ());
$viewsPaths [] = $bundle -> getPath () . '/Resources/views' ;
if ( $this -> defaultViewsPath ) {
$viewsPaths [] = $this -> defaultViewsPath . '/bundles/' . $bundle -> getName ();
}
$viewsPaths [] = sprintf ( '%s/Resources/%s/views' , $kernel -> getRootDir (), $bundle -> getName ());
2015-04-11 17:38:38 +01:00
}
2015-01-18 20:33:17 +00:00
}
2014-01-19 23:09:51 +00:00
// Extract used messages
2017-11-21 06:48:50 +00:00
$extractedCatalogue = $this -> extractMessages ( $locale , $viewsPaths );
2014-01-19 23:09:51 +00:00
// Load defined messages
2017-07-22 10:58:19 +01:00
$currentCatalogue = $this -> loadCurrentMessages ( $locale , $transPaths );
2014-01-19 23:09:51 +00:00
// Merge defined and extracted messages to get all message ids
$mergeOperation = new MergeOperation ( $extractedCatalogue , $currentCatalogue );
$allMessages = $mergeOperation -> getResult () -> all ( $domain );
if ( null !== $domain ) {
$allMessages = array ( $domain => $allMessages );
}
// No defined or extracted messages
if ( empty ( $allMessages ) || null !== $domain && empty ( $allMessages [ $domain ])) {
2015-05-09 15:43:35 +01:00
$outputMessage = sprintf ( 'No defined or extracted messages for locale "%s"' , $locale );
2014-01-19 23:09:51 +00:00
if ( null !== $domain ) {
2015-05-09 15:43:35 +01:00
$outputMessage .= sprintf ( ' and domain "%s"' , $domain );
2015-04-11 17:38:38 +01:00
}
2014-01-19 23:09:51 +00:00
2017-01-12 17:30:35 +00:00
$io -> getErrorStyle () -> warning ( $outputMessage );
2014-01-19 23:09:51 +00:00
return ;
}
2015-04-11 17:38:38 +01:00
2014-01-19 23:09:51 +00:00
// Load the fallback catalogues
2017-07-22 10:58:19 +01:00
$fallbackCatalogues = $this -> loadFallbackCatalogues ( $locale , $transPaths );
2014-01-19 23:09:51 +00:00
// Display header line
2015-04-26 11:57:37 +01:00
$headers = array ( 'State' , 'Domain' , 'Id' , sprintf ( 'Message Preview (%s)' , $locale ));
2014-01-19 23:09:51 +00:00
foreach ( $fallbackCatalogues as $fallbackCatalogue ) {
$headers [] = sprintf ( 'Fallback Message Preview (%s)' , $fallbackCatalogue -> getLocale ());
}
2015-05-09 15:43:35 +01:00
$rows = array ();
2014-01-19 23:09:51 +00:00
// Iterate all message ids and determine their state
foreach ( $allMessages as $domain => $messages ) {
foreach ( array_keys ( $messages ) as $messageId ) {
$value = $currentCatalogue -> get ( $messageId , $domain );
$states = array ();
if ( $extractedCatalogue -> defines ( $messageId , $domain )) {
if ( ! $currentCatalogue -> defines ( $messageId , $domain )) {
$states [] = self :: MESSAGE_MISSING ;
}
} elseif ( $currentCatalogue -> defines ( $messageId , $domain )) {
$states [] = self :: MESSAGE_UNUSED ;
}
2018-07-05 12:24:53 +01:00
if ( ! \in_array ( self :: MESSAGE_UNUSED , $states ) && true === $input -> getOption ( 'only-unused' )
|| ! \in_array ( self :: MESSAGE_MISSING , $states ) && true === $input -> getOption ( 'only-missing' )) {
2014-01-19 23:09:51 +00:00
continue ;
}
foreach ( $fallbackCatalogues as $fallbackCatalogue ) {
2014-03-03 16:28:48 +00:00
if ( $fallbackCatalogue -> defines ( $messageId , $domain ) && $value === $fallbackCatalogue -> get ( $messageId , $domain )) {
2014-01-19 23:09:51 +00:00
$states [] = self :: MESSAGE_EQUALS_FALLBACK ;
2014-03-03 16:28:48 +00:00
2014-01-19 23:09:51 +00:00
break ;
}
}
2015-03-08 20:28:01 +00:00
$row = array ( $this -> formatStates ( $states ), $domain , $this -> formatId ( $messageId ), $this -> sanitizeString ( $value ));
2014-01-19 23:09:51 +00:00
foreach ( $fallbackCatalogues as $fallbackCatalogue ) {
$row [] = $this -> sanitizeString ( $fallbackCatalogue -> get ( $messageId , $domain ));
}
2015-05-09 15:43:35 +01:00
$rows [] = $row ;
2014-01-19 23:09:51 +00:00
}
2015-04-13 16:21:02 +01:00
}
2014-01-19 23:09:51 +00:00
2015-12-07 12:11:35 +00:00
$io -> table ( $headers , $rows );
2014-01-19 23:09:51 +00:00
}
2017-10-19 02:32:22 +01:00
private function formatState ( $state ) : string
2014-01-19 23:09:51 +00:00
{
if ( self :: MESSAGE_MISSING === $state ) {
2015-09-28 14:27:37 +01:00
return '<error> missing </error>' ;
2014-01-19 23:09:51 +00:00
}
if ( self :: MESSAGE_UNUSED === $state ) {
2015-09-28 14:27:37 +01:00
return '<comment> unused </comment>' ;
2014-01-19 23:09:51 +00:00
}
if ( self :: MESSAGE_EQUALS_FALLBACK === $state ) {
2015-09-28 14:27:37 +01:00
return '<info> fallback </info>' ;
2014-01-19 23:09:51 +00:00
}
return $state ;
}
2017-10-19 02:32:22 +01:00
private function formatStates ( array $states ) : string
2014-01-19 23:09:51 +00:00
{
$result = array ();
foreach ( $states as $state ) {
$result [] = $this -> formatState ( $state );
}
return implode ( ' ' , $result );
}
2017-10-19 02:32:22 +01:00
private function formatId ( string $id ) : string
2014-01-19 23:09:51 +00:00
{
2015-09-28 14:27:37 +01:00
return sprintf ( '<fg=cyan;options=bold>%s</>' , $id );
2014-01-19 23:09:51 +00:00
}
2017-10-19 02:32:22 +01:00
private function sanitizeString ( string $string , int $length = 40 ) : string
2014-01-19 23:09:51 +00:00
{
$string = trim ( preg_replace ( '/\s+/' , ' ' , $string ));
2015-10-14 15:40:43 +01:00
if ( false !== $encoding = mb_detect_encoding ( $string , null , true )) {
2014-03-03 16:28:48 +00:00
if ( mb_strlen ( $string , $encoding ) > $length ) {
return mb_substr ( $string , 0 , $length - 3 , $encoding ) . '...' ;
2014-01-19 23:09:51 +00:00
}
2018-07-05 12:24:53 +01:00
} elseif ( \strlen ( $string ) > $length ) {
2014-03-03 16:28:48 +00:00
return substr ( $string , 0 , $length - 3 ) . '...' ;
2014-01-19 23:09:51 +00:00
}
return $string ;
}
2015-04-11 17:38:38 +01:00
2017-10-19 02:32:22 +01:00
private function extractMessages ( string $locale , array $transPaths ) : MessageCatalogue
2015-04-11 17:38:38 +01:00
{
$extractedCatalogue = new MessageCatalogue ( $locale );
2015-07-01 15:16:54 +01:00
foreach ( $transPaths as $path ) {
if ( is_dir ( $path )) {
2017-07-22 10:58:19 +01:00
$this -> extractor -> extract ( $path , $extractedCatalogue );
2015-07-01 15:16:54 +01:00
}
2015-04-11 17:38:38 +01:00
}
return $extractedCatalogue ;
}
2017-10-19 02:32:22 +01:00
private function loadCurrentMessages ( string $locale , array $transPaths ) : MessageCatalogue
2015-04-11 17:38:38 +01:00
{
$currentCatalogue = new MessageCatalogue ( $locale );
2015-07-01 15:16:54 +01:00
foreach ( $transPaths as $path ) {
if ( is_dir ( $path )) {
2017-07-25 19:07:39 +01:00
$this -> reader -> read ( $path , $currentCatalogue );
2015-07-01 15:16:54 +01:00
}
2015-04-11 17:38:38 +01:00
}
return $currentCatalogue ;
}
/**
* @ return MessageCatalogue []
*/
2017-10-19 02:32:22 +01:00
private function loadFallbackCatalogues ( string $locale , array $transPaths ) : array
2015-04-11 17:38:38 +01:00
{
$fallbackCatalogues = array ();
2017-07-22 10:58:19 +01:00
if ( $this -> translator instanceof Translator || $this -> translator instanceof DataCollectorTranslator || $this -> translator instanceof LoggingTranslator ) {
foreach ( $this -> translator -> getFallbackLocales () as $fallbackLocale ) {
2015-04-11 17:38:38 +01:00
if ( $fallbackLocale === $locale ) {
continue ;
}
$fallbackCatalogue = new MessageCatalogue ( $fallbackLocale );
2015-07-01 15:16:54 +01:00
foreach ( $transPaths as $path ) {
if ( is_dir ( $path )) {
2017-07-25 19:07:39 +01:00
$this -> reader -> read ( $path , $fallbackCatalogue );
2015-07-01 15:16:54 +01:00
}
}
2015-04-11 17:38:38 +01:00
$fallbackCatalogues [] = $fallbackCatalogue ;
}
}
return $fallbackCatalogues ;
}
2014-01-19 23:09:51 +00:00
}