first commit

This commit is contained in:
2025-07-18 16:20:14 +07:00
commit 98af45c018
16382 changed files with 3148096 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* A console command to display information about the current installation.
*
* @author Roland Franssen <franssen.roland@gmail.com>
*
* @final
*/
#[AsCommand(name: 'about', description: 'Display information about the current project')]
class AboutCommand extends Command
{
protected function configure(): void
{
$this
->setHelp(<<<'EOT'
The <info>%command.name%</info> command displays information about the current Symfony project.
The <info>PHP</info> section displays important configuration that could affect your application. The values might
be different between web and CLI.
EOT
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var KernelInterface $kernel */
$kernel = $this->getApplication()->getKernel();
if (method_exists($kernel, 'getBuildDir')) {
$buildDir = $kernel->getBuildDir();
} else {
$buildDir = $kernel->getCacheDir();
}
$rows = [
['<info>Symfony</>'],
new TableSeparator(),
['Version', Kernel::VERSION],
['Long-Term Support', 4 === Kernel::MINOR_VERSION ? 'Yes' : 'No'],
['End of maintenance', Kernel::END_OF_MAINTENANCE.(self::isExpired(Kernel::END_OF_MAINTENANCE) ? ' <error>Expired</>' : ' (<comment>'.self::daysBeforeExpiration(Kernel::END_OF_MAINTENANCE).'</>)')],
['End of life', Kernel::END_OF_LIFE.(self::isExpired(Kernel::END_OF_LIFE) ? ' <error>Expired</>' : ' (<comment>'.self::daysBeforeExpiration(Kernel::END_OF_LIFE).'</>)')],
new TableSeparator(),
['<info>Kernel</>'],
new TableSeparator(),
['Type', $kernel::class],
['Environment', $kernel->getEnvironment()],
['Debug', $kernel->isDebug() ? 'true' : 'false'],
['Charset', $kernel->getCharset()],
['Cache directory', self::formatPath($kernel->getCacheDir(), $kernel->getProjectDir()).' (<comment>'.self::formatFileSize($kernel->getCacheDir()).'</>)'],
['Build directory', self::formatPath($buildDir, $kernel->getProjectDir()).' (<comment>'.self::formatFileSize($buildDir).'</>)'],
['Log directory', self::formatPath($kernel->getLogDir(), $kernel->getProjectDir()).' (<comment>'.self::formatFileSize($kernel->getLogDir()).'</>)'],
new TableSeparator(),
['<info>PHP</>'],
new TableSeparator(),
['Version', \PHP_VERSION],
['Architecture', (\PHP_INT_SIZE * 8).' bits'],
['Intl locale', class_exists(\Locale::class, false) && \Locale::getDefault() ? \Locale::getDefault() : 'n/a'],
['Timezone', date_default_timezone_get().' (<comment>'.(new \DateTimeImmutable())->format(\DateTimeInterface::W3C).'</>)'],
['OPcache', \extension_loaded('Zend OPcache') && filter_var(\ini_get('opcache.enable'), \FILTER_VALIDATE_BOOL) ? 'true' : 'false'],
['APCu', \extension_loaded('apcu') && filter_var(\ini_get('apc.enabled'), \FILTER_VALIDATE_BOOL) ? 'true' : 'false'],
['Xdebug', \extension_loaded('xdebug') ? 'true' : 'false'],
];
$io->table([], $rows);
return 0;
}
private static function formatPath(string $path, string $baseDir): string
{
return preg_replace('~^'.preg_quote($baseDir, '~').'~', '.', $path);
}
private static function formatFileSize(string $path): string
{
if (is_file($path)) {
$size = filesize($path) ?: 0;
} else {
if (!is_dir($path)) {
return 'n/a';
}
$size = 0;
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS | \RecursiveDirectoryIterator::FOLLOW_SYMLINKS)) as $file) {
if ($file->isReadable()) {
$size += $file->getSize();
}
}
}
return Helper::formatMemory($size);
}
private static function isExpired(string $date): bool
{
$date = \DateTimeImmutable::createFromFormat('d/m/Y', '01/'.$date);
return false !== $date && new \DateTimeImmutable() > $date->modify('last day of this month 23:59:59');
}
private static function daysBeforeExpiration(string $date): string
{
$date = \DateTimeImmutable::createFromFormat('d/m/Y', '01/'.$date);
return (new \DateTimeImmutable())->diff($date->modify('last day of this month 23:59:59'))->format('in %R%a days');
}
}

View File

@@ -0,0 +1,195 @@
<?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;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\StyleInterface;
use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
/**
* A console command for dumping available configuration reference.
*
* @author Kevin Bond <kevinbond@gmail.com>
* @author Wouter J <waldio.webdesign@gmail.com>
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
abstract class AbstractConfigCommand extends ContainerDebugCommand
{
/**
* @return void
*/
protected function listBundles(OutputInterface|StyleInterface $output)
{
$title = 'Available registered bundles with their extension alias if available';
$headers = ['Bundle name', 'Extension alias'];
$rows = [];
$bundles = $this->getApplication()->getKernel()->getBundles();
usort($bundles, fn ($bundleA, $bundleB) => strcmp($bundleA->getName(), $bundleB->getName()));
foreach ($bundles as $bundle) {
$extension = $bundle->getContainerExtension();
$rows[] = [$bundle->getName(), $extension ? $extension->getAlias() : ''];
}
if ($output instanceof StyleInterface) {
$output->title($title);
$output->table($headers, $rows);
} else {
$output->writeln($title);
$table = new Table($output);
$table->setHeaders($headers)->setRows($rows)->render();
}
}
protected function listNonBundleExtensions(OutputInterface|StyleInterface $output): void
{
$title = 'Available registered non-bundle extension aliases';
$headers = ['Extension alias'];
$rows = [];
$kernel = $this->getApplication()->getKernel();
$bundleExtensions = [];
foreach ($kernel->getBundles() as $bundle) {
if ($extension = $bundle->getContainerExtension()) {
$bundleExtensions[$extension::class] = true;
}
}
$extensions = $this->getContainerBuilder($kernel)->getExtensions();
foreach ($extensions as $alias => $extension) {
if (isset($bundleExtensions[$extension::class])) {
continue;
}
$rows[] = [$alias];
}
if (!$rows) {
return;
}
if ($output instanceof StyleInterface) {
$output->title($title);
$output->table($headers, $rows);
} else {
$output->writeln($title);
$table = new Table($output);
$table->setHeaders($headers)->setRows($rows)->render();
}
}
protected function findExtension(string $name): ExtensionInterface
{
$bundles = $this->initializeBundles();
$minScore = \INF;
$kernel = $this->getApplication()->getKernel();
if ($kernel instanceof ExtensionInterface && ($kernel instanceof ConfigurationInterface || $kernel instanceof ConfigurationExtensionInterface)) {
if ($name === $kernel->getAlias()) {
return $kernel;
}
if ($kernel->getAlias()) {
$distance = levenshtein($name, $kernel->getAlias());
if ($distance < $minScore) {
$guess = $kernel->getAlias();
$minScore = $distance;
}
}
}
foreach ($bundles as $bundle) {
if ($name === $bundle->getName()) {
if (!$bundle->getContainerExtension()) {
throw new \LogicException(sprintf('Bundle "%s" does not have a container extension.', $name));
}
return $bundle->getContainerExtension();
}
$distance = levenshtein($name, $bundle->getName());
if ($distance < $minScore) {
$guess = $bundle->getName();
$minScore = $distance;
}
}
$container = $this->getContainerBuilder($kernel);
if ($container->hasExtension($name)) {
return $container->getExtension($name);
}
foreach ($container->getExtensions() as $extension) {
$distance = levenshtein($name, $extension->getAlias());
if ($distance < $minScore) {
$guess = $extension->getAlias();
$minScore = $distance;
}
}
if (!str_ends_with($name, 'Bundle')) {
$message = sprintf('No extensions with configuration available for "%s".', $name);
} else {
$message = sprintf('No extension with alias "%s" is enabled.', $name);
}
if (isset($guess) && $minScore < 3) {
$message .= sprintf("\n\nDid you mean \"%s\"?", $guess);
}
throw new LogicException($message);
}
/**
* @return void
*/
public function validateConfiguration(ExtensionInterface $extension, mixed $configuration)
{
if (!$configuration) {
throw new \LogicException(sprintf('The extension with alias "%s" does not have its getConfiguration() method setup.', $extension->getAlias()));
}
if (!$configuration instanceof ConfigurationInterface) {
throw new \LogicException(sprintf('Configuration class "%s" should implement ConfigurationInterface in order to be dumpable.', get_debug_type($configuration)));
}
}
private function initializeBundles(): array
{
// Re-build bundle manually to initialize DI extensions that can be extended by other bundles in their build() method
// as this method is not called when the container is loaded from the cache.
$kernel = $this->getApplication()->getKernel();
$container = $this->getContainerBuilder($kernel);
$bundles = $kernel->getBundles();
foreach ($bundles as $bundle) {
if ($extension = $bundle->getContainerExtension()) {
$container->registerExtension($extension);
}
}
foreach ($bundles as $bundle) {
$bundle->build($container);
}
return $bundles;
}
}

View File

@@ -0,0 +1,271 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpKernel\Bundle\BundleInterface;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* Command that places bundle web assets into a given directory.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Gábor Egyed <gabor.egyed@gmail.com>
*
* @final
*/
#[AsCommand(name: 'assets:install', description: 'Install bundle\'s web assets under a public directory')]
class AssetsInstallCommand extends Command
{
public const METHOD_COPY = 'copy';
public const METHOD_ABSOLUTE_SYMLINK = 'absolute symlink';
public const METHOD_RELATIVE_SYMLINK = 'relative symlink';
private Filesystem $filesystem;
private string $projectDir;
public function __construct(Filesystem $filesystem, string $projectDir)
{
parent::__construct();
$this->filesystem = $filesystem;
$this->projectDir = $projectDir;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('target', InputArgument::OPTIONAL, 'The target directory', null),
])
->addOption('symlink', null, InputOption::VALUE_NONE, 'Symlink the assets instead of copying them')
->addOption('relative', null, InputOption::VALUE_NONE, 'Make relative symlinks')
->addOption('no-cleanup', null, InputOption::VALUE_NONE, 'Do not remove the assets of the bundles that no longer exist')
->setHelp(<<<'EOT'
The <info>%command.name%</info> command installs bundle assets into a given
directory (e.g. the <comment>public</comment> directory).
<info>php %command.full_name% public</info>
A "bundles" directory will be created inside the target directory and the
"Resources/public" directory of each bundle will be copied into it.
To create a symlink to each bundle instead of copying its assets, use the
<info>--symlink</info> option (will fall back to hard copies when symbolic links aren't possible:
<info>php %command.full_name% public --symlink</info>
To make symlink relative, add the <info>--relative</info> option:
<info>php %command.full_name% public --symlink --relative</info>
EOT
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var KernelInterface $kernel */
$kernel = $this->getApplication()->getKernel();
$targetArg = rtrim($input->getArgument('target') ?? '', '/');
if (!$targetArg) {
$targetArg = $this->getPublicDirectory($kernel->getContainer());
}
if (!is_dir($targetArg)) {
$targetArg = $kernel->getProjectDir().'/'.$targetArg;
if (!is_dir($targetArg)) {
throw new InvalidArgumentException(sprintf('The target directory "%s" does not exist.', $targetArg));
}
}
$bundlesDir = $targetArg.'/bundles/';
$io = new SymfonyStyle($input, $output);
$io->newLine();
if ($input->getOption('relative')) {
$expectedMethod = self::METHOD_RELATIVE_SYMLINK;
$io->text('Trying to install assets as <info>relative symbolic links</info>.');
} elseif ($input->getOption('symlink')) {
$expectedMethod = self::METHOD_ABSOLUTE_SYMLINK;
$io->text('Trying to install assets as <info>absolute symbolic links</info>.');
} else {
$expectedMethod = self::METHOD_COPY;
$io->text('Installing assets as <info>hard copies</info>.');
}
$io->newLine();
$rows = [];
$copyUsed = false;
$exitCode = 0;
$validAssetDirs = [];
/** @var BundleInterface $bundle */
foreach ($kernel->getBundles() as $bundle) {
if (!is_dir($originDir = $bundle->getPath().'/Resources/public') && !is_dir($originDir = $bundle->getPath().'/public')) {
continue;
}
$assetDir = preg_replace('/bundle$/', '', strtolower($bundle->getName()));
$targetDir = $bundlesDir.$assetDir;
$validAssetDirs[] = $assetDir;
if (OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) {
$message = sprintf("%s\n-> %s", $bundle->getName(), $targetDir);
} else {
$message = $bundle->getName();
}
try {
$this->filesystem->remove($targetDir);
if (self::METHOD_RELATIVE_SYMLINK === $expectedMethod) {
$method = $this->relativeSymlinkWithFallback($originDir, $targetDir);
} elseif (self::METHOD_ABSOLUTE_SYMLINK === $expectedMethod) {
$method = $this->absoluteSymlinkWithFallback($originDir, $targetDir);
} else {
$method = $this->hardCopy($originDir, $targetDir);
}
if (self::METHOD_COPY === $method) {
$copyUsed = true;
}
if ($method === $expectedMethod) {
$rows[] = [sprintf('<fg=green;options=bold>%s</>', '\\' === \DIRECTORY_SEPARATOR ? 'OK' : "\xE2\x9C\x94" /* HEAVY CHECK MARK (U+2714) */), $message, $method];
} else {
$rows[] = [sprintf('<fg=yellow;options=bold>%s</>', '\\' === \DIRECTORY_SEPARATOR ? 'WARNING' : '!'), $message, $method];
}
} catch (\Exception $e) {
$exitCode = 1;
$rows[] = [sprintf('<fg=red;options=bold>%s</>', '\\' === \DIRECTORY_SEPARATOR ? 'ERROR' : "\xE2\x9C\x98" /* HEAVY BALLOT X (U+2718) */), $message, $e->getMessage()];
}
}
// remove the assets of the bundles that no longer exist
if (!$input->getOption('no-cleanup') && is_dir($bundlesDir)) {
$dirsToRemove = Finder::create()->depth(0)->directories()->exclude($validAssetDirs)->in($bundlesDir);
$this->filesystem->remove($dirsToRemove);
}
if ($rows) {
$io->table(['', 'Bundle', 'Method / Error'], $rows);
}
if (0 !== $exitCode) {
$io->error('Some errors occurred while installing assets.');
} else {
if ($copyUsed) {
$io->note('Some assets were installed via copy. If you make changes to these assets you have to run this command again.');
}
$io->success($rows ? 'All assets were successfully installed.' : 'No assets were provided by any bundle.');
}
return $exitCode;
}
/**
* Try to create relative symlink.
*
* Falling back to absolute symlink and finally hard copy.
*/
private function relativeSymlinkWithFallback(string $originDir, string $targetDir): string
{
try {
$this->symlink($originDir, $targetDir, true);
$method = self::METHOD_RELATIVE_SYMLINK;
} catch (IOException) {
$method = $this->absoluteSymlinkWithFallback($originDir, $targetDir);
}
return $method;
}
/**
* Try to create absolute symlink.
*
* Falling back to hard copy.
*/
private function absoluteSymlinkWithFallback(string $originDir, string $targetDir): string
{
try {
$this->symlink($originDir, $targetDir);
$method = self::METHOD_ABSOLUTE_SYMLINK;
} catch (IOException) {
// fall back to copy
$method = $this->hardCopy($originDir, $targetDir);
}
return $method;
}
/**
* Creates symbolic link.
*
* @throws IOException if link cannot be created
*/
private function symlink(string $originDir, string $targetDir, bool $relative = false): void
{
if ($relative) {
$this->filesystem->mkdir(\dirname($targetDir));
$originDir = $this->filesystem->makePathRelative($originDir, realpath(\dirname($targetDir)));
}
$this->filesystem->symlink($originDir, $targetDir);
if (!file_exists($targetDir)) {
throw new IOException(sprintf('Symbolic link "%s" was created but appears to be broken.', $targetDir), 0, null, $targetDir);
}
}
/**
* Copies origin to target.
*/
private function hardCopy(string $originDir, string $targetDir): string
{
$this->filesystem->mkdir($targetDir, 0777);
// We use a custom iterator to ignore VCS files
$this->filesystem->mirror($originDir, $targetDir, Finder::create()->ignoreDotFiles(false)->in($originDir));
return self::METHOD_COPY;
}
private function getPublicDirectory(ContainerInterface $container): string
{
$defaultPublicDir = 'public';
if (null === $this->projectDir && !$container->hasParameter('kernel.project_dir')) {
return $defaultPublicDir;
}
$composerFilePath = ($this->projectDir ?? $container->getParameter('kernel.project_dir')).'/composer.json';
if (!file_exists($composerFilePath)) {
return $defaultPublicDir;
}
$composerConfig = json_decode(file_get_contents($composerFilePath), true);
return $composerConfig['extra']['public-dir'] ?? $defaultPublicDir;
}
}

View File

@@ -0,0 +1,71 @@
<?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;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\KernelInterface;
/**
* @internal
*
* @author Robin Chalas <robin.chalas@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
trait BuildDebugContainerTrait
{
protected ContainerBuilder $container;
/**
* Loads the ContainerBuilder from the cache.
*
* @throws \LogicException
*/
protected function getContainerBuilder(KernelInterface $kernel): ContainerBuilder
{
if (isset($this->container)) {
return $this->container;
}
if (!$kernel->isDebug() || !$kernel->getContainer()->getParameter('debug.container.dump') || !(new ConfigCache($kernel->getContainer()->getParameter('debug.container.dump'), true))->isFresh()) {
$buildContainer = \Closure::bind(function () {
$this->initializeBundles();
return $this->buildContainer();
}, $kernel, $kernel::class);
$container = $buildContainer();
$container->getCompilerPassConfig()->setRemovingPasses([]);
$container->getCompilerPassConfig()->setAfterRemovingPasses([]);
$container->compile();
} else {
$buildContainer = \Closure::bind(function () {
$containerBuilder = $this->getContainerBuilder();
$this->prepareContainer($containerBuilder);
return $containerBuilder;
}, $kernel, $kernel::class);
$container = $buildContainer();
(new XmlFileLoader($container, new FileLocator()))->load($kernel->getContainer()->getParameter('debug.container.dump'));
$locatorPass = new ServiceLocatorTagPass();
$locatorPass->process($container);
$container->getCompilerPassConfig()->setBeforeOptimizationPasses([]);
$container->getCompilerPassConfig()->setOptimizationPasses([]);
$container->getCompilerPassConfig()->setBeforeRemovingPasses([]);
}
return $this->container = $container;
}
}

View File

@@ -0,0 +1,254 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Dumper\Preloader;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
use Symfony\Component\HttpKernel\RebootableInterface;
/**
* Clear and Warmup the cache.
*
* @author Francis Besset <francis.besset@gmail.com>
* @author Fabien Potencier <fabien@symfony.com>
*
* @final
*/
#[AsCommand(name: 'cache:clear', description: 'Clear the cache')]
class CacheClearCommand extends Command
{
private CacheClearerInterface $cacheClearer;
private Filesystem $filesystem;
public function __construct(CacheClearerInterface $cacheClearer, ?Filesystem $filesystem = null)
{
parent::__construct();
$this->cacheClearer = $cacheClearer;
$this->filesystem = $filesystem ?? new Filesystem();
}
protected function configure(): void
{
$this
->setDefinition([
new InputOption('no-warmup', '', InputOption::VALUE_NONE, 'Do not warm up the cache'),
new InputOption('no-optional-warmers', '', InputOption::VALUE_NONE, 'Skip optional cache warmers (faster)'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command clears and warms up the application cache for a given environment
and debug mode:
<info>php %command.full_name% --env=dev</info>
<info>php %command.full_name% --env=prod --no-debug</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$fs = $this->filesystem;
$io = new SymfonyStyle($input, $output);
$kernel = $this->getApplication()->getKernel();
$realCacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
$realBuildDir = $kernel->getContainer()->hasParameter('kernel.build_dir') ? $kernel->getContainer()->getParameter('kernel.build_dir') : $realCacheDir;
// the old cache dir name must not be longer than the real one to avoid exceeding
// the maximum length of a directory or file path within it (esp. Windows MAX_PATH)
$oldCacheDir = substr($realCacheDir, 0, -1).(str_ends_with($realCacheDir, '~') ? '+' : '~');
$fs->remove($oldCacheDir);
if (!is_writable($realCacheDir)) {
throw new RuntimeException(sprintf('Unable to write in the "%s" directory.', $realCacheDir));
}
$useBuildDir = $realBuildDir !== $realCacheDir;
$oldBuildDir = substr($realBuildDir, 0, -1).(str_ends_with($realBuildDir, '~') ? '+' : '~');
if ($useBuildDir) {
$fs->remove($oldBuildDir);
if (!is_writable($realBuildDir)) {
throw new RuntimeException(sprintf('Unable to write in the "%s" directory.', $realBuildDir));
}
if ($this->isNfs($realCacheDir)) {
$fs->remove($realCacheDir);
} else {
$fs->rename($realCacheDir, $oldCacheDir);
}
$fs->mkdir($realCacheDir);
}
$io->comment(sprintf('Clearing the cache for the <info>%s</info> environment with debug <info>%s</info>', $kernel->getEnvironment(), var_export($kernel->isDebug(), true)));
if ($useBuildDir) {
$this->cacheClearer->clear($realBuildDir);
}
$this->cacheClearer->clear($realCacheDir);
// The current event dispatcher is stale, let's not use it anymore
$this->getApplication()->setDispatcher(new EventDispatcher());
$containerFile = (new \ReflectionObject($kernel->getContainer()))->getFileName();
$containerDir = basename(\dirname($containerFile));
// the warmup cache dir name must have the same length as the real one
// to avoid the many problems in serialized resources files
$warmupDir = substr($realBuildDir, 0, -1).(str_ends_with($realBuildDir, '_') ? '-' : '_');
if ($output->isVerbose() && $fs->exists($warmupDir)) {
$io->comment('Clearing outdated warmup directory...');
}
$fs->remove($warmupDir);
if ($_SERVER['REQUEST_TIME'] <= filemtime($containerFile) && filemtime($containerFile) <= time()) {
if ($output->isVerbose()) {
$io->comment('Cache is fresh.');
}
if (!$input->getOption('no-warmup') && !$input->getOption('no-optional-warmers')) {
if ($output->isVerbose()) {
$io->comment('Warming up optional cache...');
}
$this->warmupOptionals($realCacheDir, $realBuildDir, $io);
}
} else {
$fs->mkdir($warmupDir);
if (!$input->getOption('no-warmup')) {
if ($output->isVerbose()) {
$io->comment('Warming up cache...');
}
$this->warmup($warmupDir, $realBuildDir);
if (!$input->getOption('no-optional-warmers')) {
if ($output->isVerbose()) {
$io->comment('Warming up optional cache...');
}
$this->warmupOptionals($useBuildDir ? $realCacheDir : $warmupDir, $warmupDir, $io);
}
// fix references to cached files with the real cache directory name
$search = [$warmupDir, str_replace('/', '\\/', $warmupDir), str_replace('\\', '\\\\', $warmupDir)];
$replace = str_replace('\\', '/', $realBuildDir);
foreach (Finder::create()->files()->in($warmupDir) as $file) {
$content = str_replace($search, $replace, file_get_contents($file), $count);
if ($count) {
file_put_contents($file, $content);
}
}
}
if (!$fs->exists($warmupDir.'/'.$containerDir)) {
$fs->rename($realBuildDir.'/'.$containerDir, $warmupDir.'/'.$containerDir);
touch($warmupDir.'/'.$containerDir.'.legacy');
}
if ($this->isNfs($realBuildDir)) {
$io->note('For better performance, you should move the cache and log directories to a non-shared folder of the VM.');
$fs->remove($realBuildDir);
} else {
$fs->rename($realBuildDir, $oldBuildDir);
}
$fs->rename($warmupDir, $realBuildDir);
if ($output->isVerbose()) {
$io->comment('Removing old build and cache directory...');
}
if ($useBuildDir) {
try {
$fs->remove($oldBuildDir);
} catch (IOException $e) {
if ($output->isVerbose()) {
$io->warning($e->getMessage());
}
}
}
try {
$fs->remove($oldCacheDir);
} catch (IOException $e) {
if ($output->isVerbose()) {
$io->warning($e->getMessage());
}
}
}
if ($output->isVerbose()) {
$io->comment('Finished');
}
$io->success(sprintf('Cache for the "%s" environment (debug=%s) was successfully cleared.', $kernel->getEnvironment(), var_export($kernel->isDebug(), true)));
return 0;
}
private function isNfs(string $dir): bool
{
static $mounts = null;
if (null === $mounts) {
$mounts = [];
if ('/' === \DIRECTORY_SEPARATOR && @is_readable('/proc/mounts') && $files = @file('/proc/mounts')) {
foreach ($files as $mount) {
$mount = \array_slice(explode(' ', $mount), 1, -3);
if (!\in_array(array_pop($mount), ['vboxsf', 'nfs'])) {
continue;
}
$mounts[] = implode(' ', $mount).'/';
}
}
}
foreach ($mounts as $mount) {
if (str_starts_with($dir, $mount)) {
return true;
}
}
return false;
}
private function warmup(string $warmupDir, string $realBuildDir): void
{
// create a temporary kernel
$kernel = $this->getApplication()->getKernel();
if (!$kernel instanceof RebootableInterface) {
throw new \LogicException('Calling "cache:clear" with a kernel that does not implement "Symfony\Component\HttpKernel\RebootableInterface" is not supported.');
}
$kernel->reboot($warmupDir);
}
private function warmupOptionals(string $cacheDir, string $warmupDir, SymfonyStyle $io): void
{
$kernel = $this->getApplication()->getKernel();
$warmer = $kernel->getContainer()->get('cache_warmer');
// non optional warmers already ran during container compilation
$warmer->enableOnlyOptionalWarmers();
$preload = (array) $warmer->warmUp($cacheDir, $warmupDir, $io);
if ($preload && file_exists($preloadFile = $warmupDir.'/'.$kernel->getContainer()->getParameter('kernel.container_class').'.preload.php')) {
Preloader::append($preloadFile, $preload);
}
}
}

View File

@@ -0,0 +1,144 @@
<?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;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\CacheClearer\Psr6CacheClearer;
/**
* Clear cache pools.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
#[AsCommand(name: 'cache:pool:clear', description: 'Clear cache pools')]
final class CachePoolClearCommand extends Command
{
private Psr6CacheClearer $poolClearer;
private ?array $poolNames;
/**
* @param string[]|null $poolNames
*/
public function __construct(Psr6CacheClearer $poolClearer, ?array $poolNames = null)
{
parent::__construct();
$this->poolClearer = $poolClearer;
$this->poolNames = $poolNames;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('pools', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'A list of cache pools or cache pool clearers'),
])
->addOption('all', null, InputOption::VALUE_NONE, 'Clear all cache pools')
->addOption('exclude', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'A list of cache pools or cache pool clearers to exclude')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command clears the given cache pools or cache pool clearers.
%command.full_name% <cache pool or clearer 1> [...<cache pool or clearer N>]
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$kernel = $this->getApplication()->getKernel();
$pools = [];
$clearers = [];
$poolNames = $input->getArgument('pools');
$excludedPoolNames = $input->getOption('exclude');
if ($clearAll = $input->getOption('all')) {
if (!$this->poolNames) {
throw new InvalidArgumentException('Could not clear all cache pools, try specifying a specific pool or cache clearer.');
}
if (!$excludedPoolNames) {
$io->comment('Clearing all cache pools...');
}
$poolNames = $this->poolNames;
} elseif (!$poolNames) {
throw new InvalidArgumentException('Either specify at least one pool name, or provide the --all option to clear all pools.');
}
$poolNames = array_diff($poolNames, $excludedPoolNames);
foreach ($poolNames as $id) {
if ($this->poolClearer->hasPool($id)) {
$pools[$id] = $id;
} elseif (!$clearAll || $kernel->getContainer()->has($id)) {
$pool = $kernel->getContainer()->get($id);
if ($pool instanceof CacheItemPoolInterface) {
$pools[$id] = $pool;
} elseif ($pool instanceof Psr6CacheClearer) {
$clearers[$id] = $pool;
} else {
throw new InvalidArgumentException(sprintf('"%s" is not a cache pool nor a cache clearer.', $id));
}
}
}
foreach ($clearers as $id => $clearer) {
$io->comment(sprintf('Calling cache clearer: <info>%s</info>', $id));
$clearer->clear($kernel->getContainer()->getParameter('kernel.cache_dir'));
}
$failure = false;
foreach ($pools as $id => $pool) {
$io->comment(sprintf('Clearing cache pool: <info>%s</info>', $id));
if ($pool instanceof CacheItemPoolInterface) {
if (!$pool->clear()) {
$io->warning(sprintf('Cache pool "%s" could not be cleared.', $pool));
$failure = true;
}
} else {
if (false === $this->poolClearer->clearPool($id)) {
$io->warning(sprintf('Cache pool "%s" could not be cleared.', $pool));
$failure = true;
}
}
}
if ($failure) {
return 1;
}
$io->success('Cache was successfully cleared.');
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if (\is_array($this->poolNames) && $input->mustSuggestArgumentValuesFor('pools')) {
$suggestions->suggestValues($this->poolNames);
}
}
}

View File

@@ -0,0 +1,90 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\CacheClearer\Psr6CacheClearer;
/**
* Delete an item from a cache pool.
*
* @author Pierre du Plessis <pdples@gmail.com>
*/
#[AsCommand(name: 'cache:pool:delete', description: 'Delete an item from a cache pool')]
final class CachePoolDeleteCommand extends Command
{
private Psr6CacheClearer $poolClearer;
private ?array $poolNames;
/**
* @param string[]|null $poolNames
*/
public function __construct(Psr6CacheClearer $poolClearer, ?array $poolNames = null)
{
parent::__construct();
$this->poolClearer = $poolClearer;
$this->poolNames = $poolNames;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('pool', InputArgument::REQUIRED, 'The cache pool from which to delete an item'),
new InputArgument('key', InputArgument::REQUIRED, 'The cache key to delete from the pool'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> deletes an item from a given cache pool.
%command.full_name% <pool> <key>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$pool = $input->getArgument('pool');
$key = $input->getArgument('key');
$cachePool = $this->poolClearer->getPool($pool);
if (!$cachePool->hasItem($key)) {
$io->note(sprintf('Cache item "%s" does not exist in cache pool "%s".', $key, $pool));
return 0;
}
if (!$cachePool->deleteItem($key)) {
throw new \Exception(sprintf('Cache item "%s" could not be deleted.', $key));
}
$io->success(sprintf('Cache item "%s" was successfully deleted.', $key));
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if (\is_array($this->poolNames) && $input->mustSuggestArgumentValuesFor('pool')) {
$suggestions->suggestValues($this->poolNames);
}
}
}

View File

@@ -0,0 +1,109 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Contracts\Service\ServiceProviderInterface;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[AsCommand(name: 'cache:pool:invalidate-tags', description: 'Invalidate cache tags for all or a specific pool')]
final class CachePoolInvalidateTagsCommand extends Command
{
private ServiceProviderInterface $pools;
private array $poolNames;
public function __construct(ServiceProviderInterface $pools)
{
parent::__construct();
$this->pools = $pools;
$this->poolNames = array_keys($pools->getProvidedServices());
}
protected function configure(): void
{
$this
->addArgument('tags', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The tags to invalidate')
->addOption('pool', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The pools to invalidate on')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command invalidates tags from taggable pools. By default, all pools
have the passed tags invalidated. Pass <info>--pool=my_pool</info> to invalidate tags on a specific pool.
php %command.full_name% tag1 tag2
php %command.full_name% tag1 tag2 --pool=cache2 --pool=cache1
EOF)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$pools = $input->getOption('pool') ?: $this->poolNames;
$tags = $input->getArgument('tags');
$tagList = implode(', ', $tags);
$errors = false;
foreach ($pools as $name) {
$io->comment(sprintf('Invalidating tag(s): <info>%s</info> from pool <comment>%s</comment>.', $tagList, $name));
try {
$pool = $this->pools->get($name);
} catch (ServiceNotFoundException) {
$io->error(sprintf('Pool "%s" not found.', $name));
$errors = true;
continue;
}
if (!$pool instanceof TagAwareCacheInterface) {
$io->error(sprintf('Pool "%s" is not taggable.', $name));
$errors = true;
continue;
}
if (!$pool->invalidateTags($tags)) {
$io->error(sprintf('Cache tag(s) "%s" could not be invalidated for pool "%s".', $tagList, $name));
$errors = true;
}
}
if ($errors) {
$io->error('Done but with errors.');
return 1;
}
$io->success('Successfully invalidated cache tags.');
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('pool')) {
$suggestions->suggestValues($this->poolNames);
}
}
}

View File

@@ -0,0 +1,58 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* List available cache pools.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
#[AsCommand(name: 'cache:pool:list', description: 'List available cache pools')]
final class CachePoolListCommand extends Command
{
private array $poolNames;
/**
* @param string[] $poolNames
*/
public function __construct(array $poolNames)
{
parent::__construct();
$this->poolNames = $poolNames;
}
protected function configure(): void
{
$this
->setHelp(<<<'EOF'
The <info>%command.name%</info> command lists all available cache pools.
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->table(['Pool name'], array_map(fn ($pool) => [$pool], $this->poolNames));
return 0;
}
}

View File

@@ -0,0 +1,66 @@
<?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;
use Symfony\Component\Cache\PruneableInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Cache pool pruner command.
*
* @author Rob Frawley 2nd <rmf@src.run>
*/
#[AsCommand(name: 'cache:pool:prune', description: 'Prune cache pools')]
final class CachePoolPruneCommand extends Command
{
private iterable $pools;
/**
* @param iterable<mixed, PruneableInterface> $pools
*/
public function __construct(iterable $pools)
{
parent::__construct();
$this->pools = $pools;
}
protected function configure(): void
{
$this
->setHelp(<<<'EOF'
The <info>%command.name%</info> command deletes all expired items from all pruneable pools.
%command.full_name%
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
foreach ($this->pools as $name => $pool) {
$io->comment(sprintf('Pruning cache pool: <info>%s</info>', $name));
$pool->prune();
}
$io->success('Successfully pruned cache pool(s).');
return 0;
}
}

View File

@@ -0,0 +1,86 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Dumper\Preloader;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerAggregate;
use Symfony\Component\HttpKernel\CacheWarmer\WarmableInterface;
/**
* Warmup the cache.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @final
*/
#[AsCommand(name: 'cache:warmup', description: 'Warm up an empty cache')]
class CacheWarmupCommand extends Command
{
private CacheWarmerAggregate $cacheWarmer;
public function __construct(CacheWarmerAggregate $cacheWarmer)
{
parent::__construct();
$this->cacheWarmer = $cacheWarmer;
}
protected function configure(): void
{
$this
->setDefinition([
new InputOption('no-optional-warmers', '', InputOption::VALUE_NONE, 'Skip optional cache warmers (faster)'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command warms up the cache.
Before running this command, the cache must be empty.
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$kernel = $this->getApplication()->getKernel();
$io->comment(sprintf('Warming up the cache for the <info>%s</info> environment with debug <info>%s</info>', $kernel->getEnvironment(), var_export($kernel->isDebug(), true)));
if (!$input->getOption('no-optional-warmers')) {
$this->cacheWarmer->enableOptionalWarmers();
}
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
if ($kernel instanceof WarmableInterface) {
$kernel->warmUp($cacheDir);
}
$preload = $this->cacheWarmer->warmUp($cacheDir);
$buildDir = $kernel->getContainer()->getParameter('kernel.build_dir');
if ($preload && $cacheDir === $buildDir && file_exists($preloadFile = $buildDir.'/'.$kernel->getContainer()->getParameter('kernel.container_class').'.preload.php')) {
Preloader::append($preloadFile, $preload);
}
$io->success(sprintf('Cache for the "%s" environment (debug=%s) was successfully warmed.', $kernel->getEnvironment(), var_export($kernel->isDebug(), true)));
return 0;
}
}

View File

@@ -0,0 +1,275 @@
<?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;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Compiler\ValidateEnvPlaceholdersPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\Yaml\Yaml;
/**
* A console command for dumping available configuration reference.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*
* @final
*/
#[AsCommand(name: 'debug:config', description: 'Dump the current configuration for an extension')]
class ConfigDebugCommand extends AbstractConfigCommand
{
protected function configure(): void
{
$commentedHelpFormats = array_map(fn ($format) => sprintf('<comment>%s</comment>', $format), $this->getAvailableFormatOptions());
$helpFormats = implode('", "', $commentedHelpFormats);
$this
->setDefinition([
new InputArgument('name', InputArgument::OPTIONAL, 'The bundle name or the extension alias'),
new InputArgument('path', InputArgument::OPTIONAL, 'The configuration option path'),
new InputOption('resolve-env', null, InputOption::VALUE_NONE, 'Display resolved environment variable values instead of placeholders'),
new InputOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), class_exists(Yaml::class) ? 'txt' : 'json'),
])
->setHelp(<<<EOF
The <info>%command.name%</info> command dumps the current configuration for an
extension/bundle.
Either the extension alias or bundle name can be used:
<info>php %command.full_name% framework</info>
<info>php %command.full_name% FrameworkBundle</info>
The <info>--format</info> option specifies the format of the configuration,
these are "{$helpFormats}".
<info>php %command.full_name% framework --format=json</info>
For dumping a specific option, add its path as second argument:
<info>php %command.full_name% framework serializer.enabled</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$errorIo = $io->getErrorStyle();
if (null === $name = $input->getArgument('name')) {
$this->listBundles($errorIo);
$this->listNonBundleExtensions($errorIo);
$errorIo->comment('Provide the name of a bundle as the first argument of this command to dump its configuration. (e.g. <comment>debug:config FrameworkBundle</comment>)');
$errorIo->comment('For dumping a specific option, add its path as the second argument of this command. (e.g. <comment>debug:config FrameworkBundle serializer</comment> to dump the <comment>framework.serializer</comment> configuration)');
return 0;
}
$extension = $this->findExtension($name);
$extensionAlias = $extension->getAlias();
$container = $this->compileContainer();
$config = $this->getConfig($extension, $container, $input->getOption('resolve-env'));
$format = $input->getOption('format');
if (\in_array($format, ['txt', 'yml'], true) && !class_exists(Yaml::class)) {
$errorIo->error('Setting the "format" option to "txt" or "yaml" requires the Symfony Yaml component. Try running "composer install symfony/yaml" or use "--format=json" instead.');
return 1;
}
if (null === $path = $input->getArgument('path')) {
if ('txt' === $input->getOption('format')) {
$io->title(
sprintf('Current configuration for %s', $name === $extensionAlias ? sprintf('extension with alias "%s"', $extensionAlias) : sprintf('"%s"', $name))
);
}
$io->writeln($this->convertToFormat([$extensionAlias => $config], $format));
return 0;
}
try {
$config = $this->getConfigForPath($config, $path, $extensionAlias);
} catch (LogicException $e) {
$errorIo->error($e->getMessage());
return 1;
}
$io->title(sprintf('Current configuration for "%s.%s"', $extensionAlias, $path));
$io->writeln($this->convertToFormat($config, $format));
return 0;
}
private function convertToFormat(mixed $config, string $format): string
{
return match ($format) {
'txt', 'yaml' => Yaml::dump($config, 10),
'json' => json_encode($config, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE),
default => throw new InvalidArgumentException(sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions()))),
};
}
private function compileContainer(): ContainerBuilder
{
$kernel = clone $this->getApplication()->getKernel();
$kernel->boot();
$method = new \ReflectionMethod($kernel, 'buildContainer');
$container = $method->invoke($kernel);
$container->getCompiler()->compile($container);
return $container;
}
/**
* Iterate over configuration until the last step of the given path.
*
* @throws LogicException If the configuration does not exist
*/
private function getConfigForPath(array $config, string $path, string $alias): mixed
{
$steps = explode('.', $path);
foreach ($steps as $step) {
if (!\array_key_exists($step, $config)) {
throw new LogicException(sprintf('Unable to find configuration for "%s.%s".', $alias, $path));
}
$config = $config[$step];
}
return $config;
}
private function getConfigForExtension(ExtensionInterface $extension, ContainerBuilder $container): array
{
$extensionAlias = $extension->getAlias();
$extensionConfig = [];
foreach ($container->getCompilerPassConfig()->getPasses() as $pass) {
if ($pass instanceof ValidateEnvPlaceholdersPass) {
$extensionConfig = $pass->getExtensionConfig();
break;
}
}
if (isset($extensionConfig[$extensionAlias])) {
return $extensionConfig[$extensionAlias];
}
// Fall back to default config if the extension has one
if (!$extension instanceof ConfigurationExtensionInterface && !$extension instanceof ConfigurationInterface) {
throw new \LogicException(sprintf('The extension with alias "%s" does not have configuration.', $extensionAlias));
}
$configs = $container->getExtensionConfig($extensionAlias);
$configuration = $extension instanceof ConfigurationInterface ? $extension : $extension->getConfiguration($configs, $container);
$this->validateConfiguration($extension, $configuration);
return (new Processor())->processConfiguration($configuration, $configs);
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
$suggestions->suggestValues($this->getAvailableExtensions());
$suggestions->suggestValues($this->getAvailableBundles());
return;
}
if ($input->mustSuggestArgumentValuesFor('path') && null !== $name = $input->getArgument('name')) {
try {
$config = $this->getConfig($this->findExtension($name), $this->compileContainer());
$paths = array_keys(self::buildPathsCompletion($config));
$suggestions->suggestValues($paths);
} catch (LogicException) {
}
}
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues($this->getAvailableFormatOptions());
}
}
private function getAvailableExtensions(): array
{
$kernel = $this->getApplication()->getKernel();
$extensions = [];
foreach ($this->getContainerBuilder($kernel)->getExtensions() as $alias => $extension) {
$extensions[] = $alias;
}
return $extensions;
}
private function getAvailableBundles(): array
{
$availableBundles = [];
foreach ($this->getApplication()->getKernel()->getBundles() as $bundle) {
$availableBundles[] = $bundle->getName();
}
return $availableBundles;
}
private function getConfig(ExtensionInterface $extension, ContainerBuilder $container, bool $resolveEnvs = false): mixed
{
return $container->resolveEnvPlaceholders(
$container->getParameterBag()->resolveValue(
$this->getConfigForExtension($extension, $container)
), $resolveEnvs ?: null
);
}
private static function buildPathsCompletion(array $paths, string $prefix = ''): array
{
$completionPaths = [];
foreach ($paths as $key => $values) {
if (\is_array($values)) {
$completionPaths += self::buildPathsCompletion($values, $prefix.$key.'.');
} else {
$completionPaths[$prefix.$key] = null;
}
}
return $completionPaths;
}
private function getAvailableFormatOptions(): array
{
return ['txt', 'yaml', 'json'];
}
}

View File

@@ -0,0 +1,188 @@
<?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;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Dumper\XmlReferenceDumper;
use Symfony\Component\Config\Definition\Dumper\YamlReferenceDumper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Yaml\Yaml;
/**
* A console command for dumping available configuration reference.
*
* @author Kevin Bond <kevinbond@gmail.com>
* @author Wouter J <waldio.webdesign@gmail.com>
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*
* @final
*/
#[AsCommand(name: 'config:dump-reference', description: 'Dump the default configuration for an extension')]
class ConfigDumpReferenceCommand extends AbstractConfigCommand
{
protected function configure(): void
{
$commentedHelpFormats = array_map(fn ($format) => sprintf('<comment>%s</comment>', $format), $this->getAvailableFormatOptions());
$helpFormats = implode('", "', $commentedHelpFormats);
$this
->setDefinition([
new InputArgument('name', InputArgument::OPTIONAL, 'The Bundle name or the extension alias'),
new InputArgument('path', InputArgument::OPTIONAL, 'The configuration option path'),
new InputOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'yaml'),
])
->setHelp(<<<EOF
The <info>%command.name%</info> command dumps the default configuration for an
extension/bundle.
Either the extension alias or bundle name can be used:
<info>php %command.full_name% framework</info>
<info>php %command.full_name% FrameworkBundle</info>
The <info>--format</info> option specifies the format of the configuration,
these are "{$helpFormats}".
<info>php %command.full_name% FrameworkBundle --format=xml</info>
For dumping a specific option, add its path as second argument (only available for the yaml format):
<info>php %command.full_name% framework http_client.default_options</info>
EOF
)
;
}
/**
* @throws \LogicException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$errorIo = $io->getErrorStyle();
if (null === $name = $input->getArgument('name')) {
$this->listBundles($errorIo);
$this->listNonBundleExtensions($errorIo);
$errorIo->comment([
'Provide the name of a bundle as the first argument of this command to dump its default configuration. (e.g. <comment>config:dump-reference FrameworkBundle</comment>)',
'For dumping a specific option, add its path as the second argument of this command. (e.g. <comment>config:dump-reference FrameworkBundle http_client.default_options</comment> to dump the <comment>framework.http_client.default_options</comment> configuration)',
]);
return 0;
}
$extension = $this->findExtension($name);
if ($extension instanceof ConfigurationInterface) {
$configuration = $extension;
} else {
$configuration = $extension->getConfiguration([], $this->getContainerBuilder($this->getApplication()->getKernel()));
}
$this->validateConfiguration($extension, $configuration);
$format = $input->getOption('format');
if ('yaml' === $format && !class_exists(Yaml::class)) {
$errorIo->error('Setting the "format" option to "yaml" requires the Symfony Yaml component. Try running "composer install symfony/yaml" or use "--format=xml" instead.');
return 1;
}
$path = $input->getArgument('path');
if (null !== $path && 'yaml' !== $format) {
$errorIo->error('The "path" option is only available for the "yaml" format.');
return 1;
}
if ($name === $extension->getAlias()) {
$message = sprintf('Default configuration for extension with alias: "%s"', $name);
} else {
$message = sprintf('Default configuration for "%s"', $name);
}
if (null !== $path) {
$message .= sprintf(' at path "%s"', $path);
}
switch ($format) {
case 'yaml':
$io->writeln(sprintf('# %s', $message));
$dumper = new YamlReferenceDumper();
break;
case 'xml':
$io->writeln(sprintf('<!-- %s -->', $message));
$dumper = new XmlReferenceDumper();
break;
default:
$io->writeln($message);
throw new InvalidArgumentException(sprintf('Supported formats are "%s".', implode('", "', $this->getAvailableFormatOptions())));
}
$io->writeln(null === $path ? $dumper->dump($configuration, $extension->getNamespace()) : $dumper->dumpAtPath($configuration, $path));
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
$suggestions->suggestValues($this->getAvailableExtensions());
$suggestions->suggestValues($this->getAvailableBundles());
}
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues($this->getAvailableFormatOptions());
}
}
private function getAvailableExtensions(): array
{
$kernel = $this->getApplication()->getKernel();
$extensions = [];
foreach ($this->getContainerBuilder($kernel)->getExtensions() as $alias => $extension) {
$extensions[] = $alias;
}
return $extensions;
}
private function getAvailableBundles(): array
{
$bundles = [];
foreach ($this->getApplication()->getKernel()->getBundles() as $bundle) {
$bundles[] = $bundle->getName();
}
return $bundles;
}
private function getAvailableFormatOptions(): array
{
return ['yaml', 'xml'];
}
}

View File

@@ -0,0 +1,369 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Console\Helper\DescriptorHelper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
/**
* A console command for retrieving information about services.
*
* @author Ryan Weaver <ryan@thatsquality.com>
*
* @internal
*/
#[AsCommand(name: 'debug:container', description: 'Display current services for an application')]
class ContainerDebugCommand extends Command
{
use BuildDebugContainerTrait;
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('name', InputArgument::OPTIONAL, 'A service name (foo)'),
new InputOption('show-arguments', null, InputOption::VALUE_NONE, 'Show arguments in services'),
new InputOption('show-hidden', null, InputOption::VALUE_NONE, 'Show hidden (internal) services'),
new InputOption('tag', null, InputOption::VALUE_REQUIRED, 'Show all services with a specific tag'),
new InputOption('tags', null, InputOption::VALUE_NONE, 'Display tagged services for an application'),
new InputOption('parameter', null, InputOption::VALUE_REQUIRED, 'Display a specific parameter for an application'),
new InputOption('parameters', null, InputOption::VALUE_NONE, 'Display parameters for an application'),
new InputOption('types', null, InputOption::VALUE_NONE, 'Display types (classes/interfaces) available in the container'),
new InputOption('env-var', null, InputOption::VALUE_REQUIRED, 'Display a specific environment variable used in the container'),
new InputOption('env-vars', null, InputOption::VALUE_NONE, 'Display environment variables used in the container'),
new InputOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'txt'),
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw description'),
new InputOption('deprecations', null, InputOption::VALUE_NONE, 'Display deprecations generated when compiling and warming up the container'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command displays all configured <comment>public</comment> services:
<info>php %command.full_name%</info>
To see deprecations generated during container compilation and cache warmup, use the <info>--deprecations</info> option:
<info>php %command.full_name% --deprecations</info>
To get specific information about a service, specify its name:
<info>php %command.full_name% validator</info>
To get specific information about a service including all its arguments, use the <info>--show-arguments</info> flag:
<info>php %command.full_name% validator --show-arguments</info>
To see available types that can be used for autowiring, use the <info>--types</info> flag:
<info>php %command.full_name% --types</info>
To see environment variables used by the container, use the <info>--env-vars</info> flag:
<info>php %command.full_name% --env-vars</info>
Display a specific environment variable by specifying its name with the <info>--env-var</info> option:
<info>php %command.full_name% --env-var=APP_ENV</info>
Use the --tags option to display tagged <comment>public</comment> services grouped by tag:
<info>php %command.full_name% --tags</info>
Find all services with a specific tag by specifying the tag name with the <info>--tag</info> option:
<info>php %command.full_name% --tag=form.type</info>
Use the <info>--parameters</info> option to display all parameters:
<info>php %command.full_name% --parameters</info>
Display a specific parameter by specifying its name with the <info>--parameter</info> option:
<info>php %command.full_name% --parameter=kernel.debug</info>
By default, internal services are hidden. You can display them
using the <info>--show-hidden</info> flag:
<info>php %command.full_name% --show-hidden</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$errorIo = $io->getErrorStyle();
$this->validateInput($input);
$kernel = $this->getApplication()->getKernel();
$object = $this->getContainerBuilder($kernel);
if ($input->getOption('env-vars')) {
$options = ['env-vars' => true];
} elseif ($envVar = $input->getOption('env-var')) {
$options = ['env-vars' => true, 'name' => $envVar];
} elseif ($input->getOption('types')) {
$options = [];
$options['filter'] = $this->filterToServiceTypes(...);
} elseif ($input->getOption('parameters')) {
$parameters = [];
$parameterBag = $object->getParameterBag();
foreach ($parameterBag->all() as $k => $v) {
$parameters[$k] = $object->resolveEnvPlaceholders($v);
}
$object = new ParameterBag($parameters);
if ($parameterBag instanceof ParameterBag) {
foreach ($parameterBag->allDeprecated() as $k => $deprecation) {
$object->deprecate($k, ...$deprecation);
}
}
$options = [];
} elseif ($parameter = $input->getOption('parameter')) {
$options = ['parameter' => $parameter];
} elseif ($input->getOption('tags')) {
$options = ['group_by' => 'tags'];
} elseif ($tag = $input->getOption('tag')) {
$tag = $this->findProperTagName($input, $errorIo, $object, $tag);
$options = ['tag' => $tag];
} elseif ($name = $input->getArgument('name')) {
$name = $this->findProperServiceName($input, $errorIo, $object, $name, $input->getOption('show-hidden'));
$options = ['id' => $name];
} elseif ($input->getOption('deprecations')) {
$options = ['deprecations' => true];
} else {
$options = [];
}
$helper = new DescriptorHelper();
$options['format'] = $input->getOption('format');
$options['show_arguments'] = $input->getOption('show-arguments');
$options['show_hidden'] = $input->getOption('show-hidden');
$options['raw_text'] = $input->getOption('raw');
$options['output'] = $io;
$options['is_debug'] = $kernel->isDebug();
try {
$helper->describe($io, $object, $options);
if ('txt' === $options['format'] && isset($options['id'])) {
if ($object->hasDefinition($options['id'])) {
$definition = $object->getDefinition($options['id']);
if ($definition->isDeprecated()) {
$errorIo->warning($definition->getDeprecation($options['id'])['message'] ?? sprintf('The "%s" service is deprecated.', $options['id']));
}
}
if ($object->hasAlias($options['id'])) {
$alias = $object->getAlias($options['id']);
if ($alias->isDeprecated()) {
$errorIo->warning($alias->getDeprecation($options['id'])['message'] ?? sprintf('The "%s" alias is deprecated.', $options['id']));
}
}
}
if (isset($options['id']) && isset($kernel->getContainer()->getRemovedIds()[$options['id']])) {
$errorIo->note(sprintf('The "%s" service or alias has been removed or inlined when the container was compiled.', $options['id']));
}
} catch (ServiceNotFoundException $e) {
if ('' !== $e->getId() && '@' === $e->getId()[0]) {
throw new ServiceNotFoundException($e->getId(), $e->getSourceId(), null, [substr($e->getId(), 1)]);
}
throw $e;
}
if (!$input->getArgument('name') && !$input->getOption('tag') && !$input->getOption('parameter') && !$input->getOption('env-vars') && !$input->getOption('env-var') && $input->isInteractive()) {
if ($input->getOption('tags')) {
$errorIo->comment('To search for a specific tag, re-run this command with a search term. (e.g. <comment>debug:container --tag=form.type</comment>)');
} elseif ($input->getOption('parameters')) {
$errorIo->comment('To search for a specific parameter, re-run this command with a search term. (e.g. <comment>debug:container --parameter=kernel.debug</comment>)');
} elseif (!$input->getOption('deprecations')) {
$errorIo->comment('To search for a specific service, re-run this command with a search term. (e.g. <comment>debug:container log</comment>)');
}
}
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues($this->getAvailableFormatOptions());
return;
}
$kernel = $this->getApplication()->getKernel();
$object = $this->getContainerBuilder($kernel);
if ($input->mustSuggestArgumentValuesFor('name')
&& !$input->getOption('tag') && !$input->getOption('tags')
&& !$input->getOption('parameter') && !$input->getOption('parameters')
&& !$input->getOption('env-var') && !$input->getOption('env-vars')
&& !$input->getOption('types') && !$input->getOption('deprecations')
) {
$suggestions->suggestValues($this->findServiceIdsContaining(
$object,
$input->getCompletionValue(),
(bool) $input->getOption('show-hidden')
));
return;
}
if ($input->mustSuggestOptionValuesFor('tag')) {
$suggestions->suggestValues($object->findTags());
return;
}
if ($input->mustSuggestOptionValuesFor('parameter')) {
$suggestions->suggestValues(array_keys($object->getParameterBag()->all()));
}
}
/**
* Validates input arguments and options.
*
* @throws \InvalidArgumentException
*/
protected function validateInput(InputInterface $input): void
{
$options = ['tags', 'tag', 'parameters', 'parameter'];
$optionsCount = 0;
foreach ($options as $option) {
if ($input->getOption($option)) {
++$optionsCount;
}
}
$name = $input->getArgument('name');
if ((null !== $name) && ($optionsCount > 0)) {
throw new InvalidArgumentException('The options tags, tag, parameters & parameter cannot be combined with the service name argument.');
} elseif ((null === $name) && $optionsCount > 1) {
throw new InvalidArgumentException('The options tags, tag, parameters & parameter cannot be combined together.');
}
}
private function findProperServiceName(InputInterface $input, SymfonyStyle $io, ContainerBuilder $container, string $name, bool $showHidden): string
{
$name = ltrim($name, '\\');
if ($container->has($name) || !$input->isInteractive()) {
return $name;
}
$matchingServices = $this->findServiceIdsContaining($container, $name, $showHidden);
if (!$matchingServices) {
throw new InvalidArgumentException(sprintf('No services found that match "%s".', $name));
}
if (1 === \count($matchingServices)) {
return $matchingServices[0];
}
natsort($matchingServices);
return $io->choice('Select one of the following services to display its information', array_values($matchingServices));
}
private function findProperTagName(InputInterface $input, SymfonyStyle $io, ContainerBuilder $container, string $tagName): string
{
if (\in_array($tagName, $container->findTags(), true) || !$input->isInteractive()) {
return $tagName;
}
$matchingTags = $this->findTagsContaining($container, $tagName);
if (!$matchingTags) {
throw new InvalidArgumentException(sprintf('No tags found that match "%s".', $tagName));
}
if (1 === \count($matchingTags)) {
return $matchingTags[0];
}
natsort($matchingTags);
return $io->choice('Select one of the following tags to display its information', array_values($matchingTags));
}
private function findServiceIdsContaining(ContainerBuilder $container, string $name, bool $showHidden): array
{
$serviceIds = $container->getServiceIds();
$foundServiceIds = $foundServiceIdsIgnoringBackslashes = [];
foreach ($serviceIds as $serviceId) {
if (!$showHidden && str_starts_with($serviceId, '.')) {
continue;
}
if (!$showHidden && $container->hasDefinition($serviceId) && $container->getDefinition($serviceId)->hasTag('container.excluded')) {
continue;
}
if (false !== stripos(str_replace('\\', '', $serviceId), $name)) {
$foundServiceIdsIgnoringBackslashes[] = $serviceId;
}
if ('' === $name || false !== stripos($serviceId, $name)) {
$foundServiceIds[] = $serviceId;
}
}
return $foundServiceIds ?: $foundServiceIdsIgnoringBackslashes;
}
private function findTagsContaining(ContainerBuilder $container, string $tagName): array
{
$tags = $container->findTags();
$foundTags = [];
foreach ($tags as $tag) {
if (str_contains($tag, $tagName)) {
$foundTags[] = $tag;
}
}
return $foundTags;
}
/**
* @internal
*/
public function filterToServiceTypes(string $serviceId): bool
{
// filter out things that could not be valid class names
if (!preg_match('/(?(DEFINE)(?<V>[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+))^(?&V)(?:\\\\(?&V))*+(?: \$(?&V))?$/', $serviceId)) {
return false;
}
// if the id has a \, assume it is a class
if (str_contains($serviceId, '\\')) {
return true;
}
return class_exists($serviceId) || interface_exists($serviceId, false);
}
private function getAvailableFormatOptions(): array
{
return (new DescriptorHelper())->getFormats();
}
}

View File

@@ -0,0 +1,114 @@
<?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;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Compiler\CheckTypeDeclarationsPass;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\Compiler\ResolveFactoryClassPass;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag;
use Symfony\Component\HttpKernel\Kernel;
#[AsCommand(name: 'lint:container', description: 'Ensure that arguments injected into services match type declarations')]
final class ContainerLintCommand extends Command
{
private ContainerBuilder $container;
protected function configure(): void
{
$this
->setHelp('This command parses service definitions and ensures that injected values match the type declarations of each services\' class.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$errorIo = $io->getErrorStyle();
try {
$container = $this->getContainerBuilder();
} catch (RuntimeException $e) {
$errorIo->error($e->getMessage());
return 2;
}
$container->setParameter('container.build_time', time());
try {
$container->compile();
} catch (InvalidArgumentException $e) {
$errorIo->error($e->getMessage());
return 1;
}
$io->success('The container was linted successfully: all services are injected with values that are compatible with their type declarations.');
return 0;
}
private function getContainerBuilder(): ContainerBuilder
{
if (isset($this->container)) {
return $this->container;
}
$kernel = $this->getApplication()->getKernel();
$kernelContainer = $kernel->getContainer();
if (!$kernel->isDebug() || !$kernelContainer->getParameter('debug.container.dump') || !(new ConfigCache($kernelContainer->getParameter('debug.container.dump'), true))->isFresh()) {
if (!$kernel instanceof Kernel) {
throw new RuntimeException(sprintf('This command does not support the application kernel: "%s" does not extend "%s".', get_debug_type($kernel), Kernel::class));
}
$buildContainer = \Closure::bind(function (): ContainerBuilder {
$this->initializeBundles();
return $this->buildContainer();
}, $kernel, $kernel::class);
$container = $buildContainer();
} else {
if (!$kernelContainer instanceof Container) {
throw new RuntimeException(sprintf('This command does not support the application container: "%s" does not extend "%s".', get_debug_type($kernelContainer), Container::class));
}
(new XmlFileLoader($container = new ContainerBuilder($parameterBag = new EnvPlaceholderParameterBag()), new FileLocator()))->load($kernelContainer->getParameter('debug.container.dump'));
$refl = new \ReflectionProperty($parameterBag, 'resolved');
$refl->setValue($parameterBag, true);
$container->getCompilerPassConfig()->setBeforeOptimizationPasses([]);
$container->getCompilerPassConfig()->setOptimizationPasses([new ResolveFactoryClassPass()]);
$container->getCompilerPassConfig()->setBeforeRemovingPasses([]);
}
$container->setParameter('container.build_hash', 'lint_container');
$container->setParameter('container.build_id', 'lint_container');
$container->addCompilerPass(new CheckTypeDeclarationsPass(true), PassConfig::TYPE_AFTER_REMOVING, -100);
return $this->container = $container;
}
}

View File

@@ -0,0 +1,200 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Console\Descriptor\Descriptor;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter;
/**
* A console command for autowiring information.
*
* @author Ryan Weaver <ryan@knpuniversity.com>
*
* @internal
*/
#[AsCommand(name: 'debug:autowiring', description: 'List classes/interfaces you can use for autowiring')]
class DebugAutowiringCommand extends ContainerDebugCommand
{
private ?FileLinkFormatter $fileLinkFormatter;
public function __construct(?string $name = null, ?FileLinkFormatter $fileLinkFormatter = null)
{
$this->fileLinkFormatter = $fileLinkFormatter;
parent::__construct($name);
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('search', InputArgument::OPTIONAL, 'A search filter'),
new InputOption('all', null, InputOption::VALUE_NONE, 'Show also services that are not aliased'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command displays the classes and interfaces that
you can use as type-hints for autowiring:
<info>php %command.full_name%</info>
You can also pass a search term to filter the list:
<info>php %command.full_name% log</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$errorIo = $io->getErrorStyle();
$container = $this->getContainerBuilder($this->getApplication()->getKernel());
$serviceIds = $container->getServiceIds();
$serviceIds = array_filter($serviceIds, $this->filterToServiceTypes(...));
if ($search = $input->getArgument('search')) {
$searchNormalized = preg_replace('/[^a-zA-Z0-9\x7f-\xff $]++/', '', $search);
$serviceIds = array_filter($serviceIds, fn ($serviceId) => false !== stripos(str_replace('\\', '', $serviceId), $searchNormalized) && !str_starts_with($serviceId, '.'));
if (!$serviceIds) {
$errorIo->error(sprintf('No autowirable classes or interfaces found matching "%s"', $search));
return 1;
}
}
$reverseAliases = [];
foreach ($container->getAliases() as $id => $alias) {
if ('.' === ($id[0] ?? null)) {
$reverseAliases[(string) $alias][] = $id;
}
}
uasort($serviceIds, 'strnatcmp');
$io->title('Autowirable Types');
$io->text('The following classes & interfaces can be used as type-hints when autowiring:');
if ($search) {
$io->text(sprintf('(only showing classes/interfaces matching <comment>%s</comment>)', $search));
}
$hasAlias = [];
$all = $input->getOption('all');
$previousId = '-';
$serviceIdsNb = 0;
foreach ($serviceIds as $serviceId) {
if ($container->hasDefinition($serviceId) && $container->getDefinition($serviceId)->hasTag('container.excluded')) {
continue;
}
$text = [];
$resolvedServiceId = $serviceId;
if (!str_starts_with($serviceId, $previousId.' $')) {
$text[] = '';
$previousId = preg_replace('/ \$.*/', '', $serviceId);
if ('' !== $description = Descriptor::getClassDescription($previousId, $resolvedServiceId)) {
if (isset($hasAlias[$previousId])) {
continue;
}
$text[] = $description;
}
}
$serviceLine = sprintf('<fg=yellow>%s</>', $serviceId);
if ('' !== $fileLink = $this->getFileLink($previousId)) {
$serviceLine = substr($serviceId, \strlen($previousId));
$serviceLine = sprintf('<fg=yellow;href=%s>%s</>', $fileLink, $previousId).('' !== $serviceLine ? sprintf('<fg=yellow>%s</>', $serviceLine) : '');
}
if ($container->hasAlias($serviceId)) {
$hasAlias[$serviceId] = true;
$serviceAlias = $container->getAlias($serviceId);
$alias = (string) $serviceAlias;
$target = null;
foreach ($reverseAliases[(string) $serviceAlias] ?? [] as $id) {
if (!str_starts_with($id, '.'.$previousId.' $')) {
continue;
}
$target = substr($id, \strlen($previousId) + 3);
if ($previousId.' $'.(new Target($target))->getParsedName() === $serviceId) {
$serviceLine .= ' - <fg=magenta>target:</><fg=cyan>'.$target.'</>';
break;
}
}
if ($container->hasDefinition($serviceAlias) && $decorated = $container->getDefinition($serviceAlias)->getTag('container.decorator')) {
$alias = $decorated[0]['id'];
}
if ($alias !== $target) {
$serviceLine .= ' - <fg=magenta>alias:</><fg=cyan>'.$alias.'</>';
}
if ($serviceAlias->isDeprecated()) {
$serviceLine .= ' - <fg=magenta>deprecated</>';
}
} elseif (!$all) {
++$serviceIdsNb;
continue;
} elseif ($container->getDefinition($serviceId)->isDeprecated()) {
$serviceLine .= ' - <fg=magenta>deprecated</>';
}
$text[] = $serviceLine;
$io->text($text);
}
$io->newLine();
if (0 < $serviceIdsNb) {
$io->text(sprintf('%s more concrete service%s would be displayed when adding the "--all" option.', $serviceIdsNb, $serviceIdsNb > 1 ? 's' : ''));
}
if ($all) {
$io->text('Pro-tip: use interfaces in your type-hints instead of classes to benefit from the dependency inversion principle.');
}
$io->newLine();
return 0;
}
private function getFileLink(string $class): string
{
if (null === $this->fileLinkFormatter
|| (null === $r = $this->getContainerBuilder($this->getApplication()->getKernel())->getReflectionClass($class, false))) {
return '';
}
return (string) $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine());
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('search')) {
$container = $this->getContainerBuilder($this->getApplication()->getKernel());
$suggestions->suggestValues(array_filter($container->getServiceIds(), $this->filterToServiceTypes(...)));
}
}
}

View File

@@ -0,0 +1,163 @@
<?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;
use Psr\Container\ContainerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Helper\DescriptorHelper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Service\ServiceProviderInterface;
/**
* A console command for retrieving information about event dispatcher.
*
* @author Matthieu Auger <mail@matthieuauger.com>
*
* @final
*/
#[AsCommand(name: 'debug:event-dispatcher', description: 'Display configured listeners for an application')]
class EventDispatcherDebugCommand extends Command
{
private const DEFAULT_DISPATCHER = 'event_dispatcher';
private ContainerInterface $dispatchers;
public function __construct(ContainerInterface $dispatchers)
{
parent::__construct();
$this->dispatchers = $dispatchers;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('event', InputArgument::OPTIONAL, 'An event name or a part of the event name'),
new InputOption('dispatcher', null, InputOption::VALUE_REQUIRED, 'To view events of a specific event dispatcher', self::DEFAULT_DISPATCHER),
new InputOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'txt'),
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw description'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command displays all configured listeners:
<info>php %command.full_name%</info>
To get specific listeners for an event, specify its name:
<info>php %command.full_name% kernel.request</info>
EOF
)
;
}
/**
* @throws \LogicException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$options = [];
$dispatcherServiceName = $input->getOption('dispatcher');
if (!$this->dispatchers->has($dispatcherServiceName)) {
$io->getErrorStyle()->error(sprintf('Event dispatcher "%s" is not available.', $dispatcherServiceName));
return 1;
}
$dispatcher = $this->dispatchers->get($dispatcherServiceName);
if ($event = $input->getArgument('event')) {
if ($dispatcher->hasListeners($event)) {
$options = ['event' => $event];
} else {
// if there is no direct match, try find partial matches
$events = $this->searchForEvent($dispatcher, $event);
if (0 === \count($events)) {
$io->getErrorStyle()->warning(sprintf('The event "%s" does not have any registered listeners.', $event));
return 0;
} elseif (1 === \count($events)) {
$options = ['event' => $events[array_key_first($events)]];
} else {
$options = ['events' => $events];
}
}
}
$helper = new DescriptorHelper();
if (self::DEFAULT_DISPATCHER !== $dispatcherServiceName) {
$options['dispatcher_service_name'] = $dispatcherServiceName;
}
$options['format'] = $input->getOption('format');
$options['raw_text'] = $input->getOption('raw');
$options['output'] = $io;
$helper->describe($io, $dispatcher, $options);
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('event')) {
$dispatcherServiceName = $input->getOption('dispatcher');
if ($this->dispatchers->has($dispatcherServiceName)) {
$dispatcher = $this->dispatchers->get($dispatcherServiceName);
$suggestions->suggestValues(array_keys($dispatcher->getListeners()));
}
return;
}
if ($input->mustSuggestOptionValuesFor('dispatcher')) {
if ($this->dispatchers instanceof ServiceProviderInterface) {
$suggestions->suggestValues(array_keys($this->dispatchers->getProvidedServices()));
}
return;
}
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues($this->getAvailableFormatOptions());
}
}
private function searchForEvent(EventDispatcherInterface $dispatcher, string $needle): array
{
$output = [];
$lcNeedle = strtolower($needle);
$allEvents = array_keys($dispatcher->getListeners());
foreach ($allEvents as $event) {
if (str_contains(strtolower($event), $lcNeedle)) {
$output[] = $event;
}
}
return $output;
}
private function getAvailableFormatOptions(): array
{
return (new DescriptorHelper())->getFormats();
}
}

View File

@@ -0,0 +1,175 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Console\Helper\DescriptorHelper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\RouterInterface;
/**
* A console command for retrieving information about routes.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Tobias Schultze <http://tobion.de>
*
* @final
*/
#[AsCommand(name: 'debug:router', description: 'Display current routes for an application')]
class RouterDebugCommand extends Command
{
use BuildDebugContainerTrait;
private RouterInterface $router;
private ?FileLinkFormatter $fileLinkFormatter;
public function __construct(RouterInterface $router, ?FileLinkFormatter $fileLinkFormatter = null)
{
parent::__construct();
$this->router = $router;
$this->fileLinkFormatter = $fileLinkFormatter;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('name', InputArgument::OPTIONAL, 'A route name'),
new InputOption('show-controllers', null, InputOption::VALUE_NONE, 'Show assigned controllers in overview'),
new InputOption('show-aliases', null, InputOption::VALUE_NONE, 'Show aliases in overview'),
new InputOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'txt'),
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw route(s)'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> displays the configured routes:
<info>php %command.full_name%</info>
EOF
)
;
}
/**
* @throws InvalidArgumentException When route does not exist
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = $input->getArgument('name');
$helper = new DescriptorHelper($this->fileLinkFormatter);
$routes = $this->router->getRouteCollection();
$container = null;
if ($this->fileLinkFormatter) {
$container = fn () => $this->getContainerBuilder($this->getApplication()->getKernel());
}
if ($name) {
$route = $routes->get($name);
$matchingRoutes = $this->findRouteNameContaining($name, $routes);
if (!$input->isInteractive() && !$route && \count($matchingRoutes) > 1) {
$helper->describe($io, $this->findRouteContaining($name, $routes), [
'format' => $input->getOption('format'),
'raw_text' => $input->getOption('raw'),
'show_controllers' => $input->getOption('show-controllers'),
'show_aliases' => $input->getOption('show-aliases'),
'output' => $io,
]);
return 0;
}
if (!$route && $matchingRoutes) {
$default = 1 === \count($matchingRoutes) ? $matchingRoutes[0] : null;
$name = $io->choice('Select one of the matching routes', $matchingRoutes, $default);
$route = $routes->get($name);
}
if (!$route) {
throw new InvalidArgumentException(sprintf('The route "%s" does not exist.', $name));
}
$helper->describe($io, $route, [
'format' => $input->getOption('format'),
'raw_text' => $input->getOption('raw'),
'name' => $name,
'output' => $io,
'container' => $container,
]);
} else {
$helper->describe($io, $routes, [
'format' => $input->getOption('format'),
'raw_text' => $input->getOption('raw'),
'show_controllers' => $input->getOption('show-controllers'),
'show_aliases' => $input->getOption('show-aliases'),
'output' => $io,
'container' => $container,
]);
}
return 0;
}
private function findRouteNameContaining(string $name, RouteCollection $routes): array
{
$foundRoutesNames = [];
foreach ($routes as $routeName => $route) {
if (false !== stripos($routeName, $name)) {
$foundRoutesNames[] = $routeName;
}
}
return $foundRoutesNames;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
$suggestions->suggestValues(array_keys($this->router->getRouteCollection()->all()));
return;
}
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues($this->getAvailableFormatOptions());
}
}
private function findRouteContaining(string $name, RouteCollection $routes): RouteCollection
{
$foundRoutes = new RouteCollection();
foreach ($routes as $routeName => $route) {
if (false !== stripos($routeName, $name)) {
$foundRoutes->add($routeName, $route);
}
}
return $foundRoutes;
}
private function getAvailableFormatOptions(): array
{
return (new DescriptorHelper())->getFormats();
}
}

View File

@@ -0,0 +1,121 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface;
use Symfony\Component\Routing\Matcher\TraceableUrlMatcher;
use Symfony\Component\Routing\RouterInterface;
/**
* A console command to test route matching.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @final
*/
#[AsCommand(name: 'router:match', description: 'Help debug routes by simulating a path info match')]
class RouterMatchCommand extends Command
{
private RouterInterface $router;
private iterable $expressionLanguageProviders;
/**
* @param iterable<mixed, ExpressionFunctionProviderInterface> $expressionLanguageProviders
*/
public function __construct(RouterInterface $router, iterable $expressionLanguageProviders = [])
{
parent::__construct();
$this->router = $router;
$this->expressionLanguageProviders = $expressionLanguageProviders;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('path_info', InputArgument::REQUIRED, 'A path info'),
new InputOption('method', null, InputOption::VALUE_REQUIRED, 'Set the HTTP method'),
new InputOption('scheme', null, InputOption::VALUE_REQUIRED, 'Set the URI scheme (usually http or https)'),
new InputOption('host', null, InputOption::VALUE_REQUIRED, 'Set the URI host'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> shows which routes match a given request and which don't and for what reason:
<info>php %command.full_name% /foo</info>
or
<info>php %command.full_name% /foo --method POST --scheme https --host symfony.com --verbose</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$context = $this->router->getContext();
if (null !== $method = $input->getOption('method')) {
$context->setMethod($method);
}
if (null !== $scheme = $input->getOption('scheme')) {
$context->setScheme($scheme);
}
if (null !== $host = $input->getOption('host')) {
$context->setHost($host);
}
$matcher = new TraceableUrlMatcher($this->router->getRouteCollection(), $context);
foreach ($this->expressionLanguageProviders as $provider) {
$matcher->addExpressionLanguageProvider($provider);
}
$traces = $matcher->getTraces($input->getArgument('path_info'));
$io->newLine();
$matches = false;
foreach ($traces as $trace) {
if (TraceableUrlMatcher::ROUTE_ALMOST_MATCHES == $trace['level']) {
$io->text(sprintf('Route <info>"%s"</> almost matches but %s', $trace['name'], lcfirst($trace['log'])));
} elseif (TraceableUrlMatcher::ROUTE_MATCHES == $trace['level']) {
$io->success(sprintf('Route "%s" matches', $trace['name']));
$routerDebugCommand = $this->getApplication()->find('debug:router');
$routerDebugCommand->run(new ArrayInput(['name' => $trace['name']]), $output);
$matches = true;
} elseif ($input->getOption('verbose')) {
$io->text(sprintf('Route "%s" does not match: %s', $trace['name'], $trace['log']));
}
}
if (!$matches) {
$io->error(sprintf('None of the routes match the path "%s"', $input->getArgument('path_info')));
return 1;
}
return 0;
}
}

View File

@@ -0,0 +1,102 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
#[AsCommand(name: 'secrets:decrypt-to-local', description: 'Decrypt all secrets and stores them in the local vault')]
final class SecretsDecryptToLocalCommand extends Command
{
private AbstractVault $vault;
private ?AbstractVault $localVault;
public function __construct(AbstractVault $vault, ?AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force overriding of secrets that already exist in the local vault')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command decrypts all secrets and copies them in the local vault.
<info>%command.full_name%</info>
When the <info>--force</info> option is provided, secrets that already exist in the local vault are overridden.
<info>%command.full_name% --force</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
if (null === $this->localVault) {
$io->error('The local vault is disabled.');
return 1;
}
$secrets = $this->vault->list(true);
$io->comment(sprintf('%d secret%s found in the vault.', \count($secrets), 1 !== \count($secrets) ? 's' : ''));
$skipped = 0;
if (!$input->getOption('force')) {
foreach ($this->localVault->list() as $k => $v) {
if (isset($secrets[$k])) {
++$skipped;
unset($secrets[$k]);
}
}
}
if ($skipped > 0) {
$io->warning([
sprintf('%d secret%s already overridden in the local vault and will be skipped.', $skipped, 1 !== $skipped ? 's are' : ' is'),
'Use the --force flag to override these.',
]);
}
foreach ($secrets as $k => $v) {
if (null === $v) {
$io->error($this->vault->getLastMessage() ?? sprintf('Secret "%s" has been skipped as there was an error reading it.', $k));
continue;
}
$this->localVault->seal($k, $v);
$io->note($this->localVault->getLastMessage());
}
return 0;
}
}

View File

@@ -0,0 +1,77 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
#[AsCommand(name: 'secrets:encrypt-from-local', description: 'Encrypt all local secrets to the vault')]
final class SecretsEncryptFromLocalCommand extends Command
{
private AbstractVault $vault;
private ?AbstractVault $localVault;
public function __construct(AbstractVault $vault, ?AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
parent::__construct();
}
protected function configure(): void
{
$this
->setHelp(<<<'EOF'
The <info>%command.name%</info> command encrypts all locally overridden secrets to the vault.
<info>%command.full_name%</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
if (null === $this->localVault) {
$io->error('The local vault is disabled.');
return 1;
}
foreach ($this->vault->list(true) as $name => $value) {
$localValue = $this->localVault->reveal($name);
if (null !== $localValue && $value !== $localValue) {
$this->vault->seal($name, $localValue);
} elseif (null !== $message = $this->localVault->getLastMessage()) {
$io->error($message);
return 1;
}
}
return 0;
}
}

View File

@@ -0,0 +1,124 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Tobias Schultze <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
#[AsCommand(name: 'secrets:generate-keys', description: 'Generate new encryption keys')]
final class SecretsGenerateKeysCommand extends Command
{
private AbstractVault $vault;
private ?AbstractVault $localVault;
public function __construct(AbstractVault $vault, ?AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('local', 'l', InputOption::VALUE_NONE, 'Update the local vault.')
->addOption('rotate', 'r', InputOption::VALUE_NONE, 'Re-encrypt existing secrets with the newly generated keys.')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command generates a new encryption key.
<info>%command.full_name%</info>
If encryption keys already exist, the command must be called with
the <info>--rotate</info> option in order to override those keys and re-encrypt
existing secrets.
<info>%command.full_name% --rotate</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
if (null === $vault) {
$io->success('The local vault is disabled.');
return 1;
}
if (!$input->getOption('rotate')) {
if ($vault->generateKeys()) {
$io->success($vault->getLastMessage());
if ($this->vault === $vault) {
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠');
}
return 0;
}
$io->warning($vault->getLastMessage());
return 1;
}
$secrets = [];
foreach ($vault->list(true) as $name => $value) {
if (null === $value) {
$io->error($vault->getLastMessage());
return 1;
}
$secrets[$name] = $value;
}
if (!$vault->generateKeys(true)) {
$io->warning($vault->getLastMessage());
return 1;
}
$io->success($vault->getLastMessage());
if ($secrets) {
foreach ($secrets as $name => $value) {
$vault->seal($name, $value);
}
$io->comment('Existing secrets have been rotated to the new keys.');
}
if ($this->vault === $vault) {
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠');
}
return 0;
}
}

View File

@@ -0,0 +1,105 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Dumper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Tobias Schultze <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
#[AsCommand(name: 'secrets:list', description: 'List all secrets')]
final class SecretsListCommand extends Command
{
private AbstractVault $vault;
private ?AbstractVault $localVault;
public function __construct(AbstractVault $vault, ?AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command list all stored secrets.
<info>%command.full_name%</info>
When the option <info>--reveal</info> is provided, the decrypted secrets are also displayed.
<info>%command.full_name% --reveal</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
$io->comment('Use <info>"%env(<name>)%"</info> to reference a secret in a config file.');
if (!$reveal = $input->getOption('reveal')) {
$io->comment(sprintf('To reveal the secrets run <info>php %s %s --reveal</info>', $_SERVER['PHP_SELF'], $this->getName()));
}
$secrets = $this->vault->list($reveal);
$localSecrets = $this->localVault?->list($reveal);
$rows = [];
$dump = new Dumper($output);
$dump = fn ($v) => null === $v ? '******' : $dump($v);
foreach ($secrets as $name => $value) {
$rows[$name] = [$name, $dump($value)];
}
if (null !== $message = $this->vault->getLastMessage()) {
$io->comment($message);
}
foreach ($localSecrets ?? [] as $name => $value) {
if (isset($rows[$name])) {
$rows[$name][] = $dump($value);
}
}
if (null !== $this->localVault && null !== $message = $this->localVault->getLastMessage()) {
$io->comment($message);
}
(new SymfonyStyle($input, $output))
->table(['Secret', 'Value'] + (null !== $localSecrets ? [2 => 'Local Value'] : []), $rows);
$io->comment("Local values override secret values.\nUse <info>secrets:set --local</info> to define them.");
return 0;
}
}

View File

@@ -0,0 +1,100 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
#[AsCommand(name: 'secrets:remove', description: 'Remove a secret from the vault')]
final class SecretsRemoveCommand extends Command
{
private AbstractVault $vault;
private ?AbstractVault $localVault;
public function __construct(AbstractVault $vault, ?AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'The name of the secret')
->addOption('local', 'l', InputOption::VALUE_NONE, 'Update the local vault.')
->setHelp(<<<'EOF'
The <info>%command.name%</info> command removes a secret from the vault.
<info>%command.full_name% <name></info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
if (null === $vault) {
$io->success('The local vault is disabled.');
return 1;
}
if ($vault->remove($name = $input->getArgument('name'))) {
$io->success($vault->getLastMessage() ?? 'Secret was removed from the vault.');
} else {
$io->comment($vault->getLastMessage() ?? 'Secret was not found in the vault.');
}
if ($this->vault === $vault && null !== $this->localVault->reveal($name)) {
$io->comment('Note that this secret is overridden in the local vault.');
}
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if (!$input->mustSuggestArgumentValuesFor('name')) {
return;
}
$vaultKeys = array_keys($this->vault->list(false));
if ($input->getOption('local')) {
if (null === $this->localVault) {
return;
}
$vaultKeys = array_intersect($vaultKeys, array_keys($this->localVault->list(false)));
}
$suggestions->suggestValues($vaultKeys);
}
}

View File

@@ -0,0 +1,147 @@
<?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;
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Tobias Schultze <http://tobion.de>
* @author Jérémy Derussé <jeremy@derusse.com>
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
#[AsCommand(name: 'secrets:set', description: 'Set a secret in the vault')]
final class SecretsSetCommand extends Command
{
private AbstractVault $vault;
private ?AbstractVault $localVault;
public function __construct(AbstractVault $vault, ?AbstractVault $localVault = null)
{
$this->vault = $vault;
$this->localVault = $localVault;
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('name', InputArgument::REQUIRED, 'The name of the secret')
->addArgument('file', InputArgument::OPTIONAL, 'A file where to read the secret from or "-" for reading from STDIN')
->addOption('local', 'l', InputOption::VALUE_NONE, 'Update the local vault.')
->addOption('random', 'r', InputOption::VALUE_OPTIONAL, 'Generate a random value.', false)
->setHelp(<<<'EOF'
The <info>%command.name%</info> command stores a secret in the vault.
<info>%command.full_name% <name></info>
To reference secrets in services.yaml or any other config
files, use <info>"%env(<name>)%"</info>.
By default, the secret value should be entered interactively.
Alternatively, provide a file where to read the secret from:
<info>php %command.full_name% <name> filename</info>
Use "-" as a file name to read from STDIN:
<info>cat filename | php %command.full_name% <name> -</info>
Use <info>--local</info> to override secrets for local needs.
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
$io = new SymfonyStyle($input, $errOutput);
$name = $input->getArgument('name');
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
if (null === $vault) {
$io->error('The local vault is disabled.');
return 1;
}
if ($this->localVault === $vault && !\array_key_exists($name, $this->vault->list())) {
$io->error(sprintf('Secret "%s" does not exist in the vault, you cannot override it locally.', $name));
return 1;
}
if (0 < $random = $input->getOption('random') ?? 16) {
$value = strtr(substr(base64_encode(random_bytes($random)), 0, $random), '+/', '-_');
} elseif (!$file = $input->getArgument('file')) {
$value = $io->askHidden('Please type the secret value');
if (null === $value) {
$io->warning('No value provided: using empty string');
$value = '';
}
} elseif ('-' === $file) {
$value = file_get_contents('php://stdin');
} elseif (is_file($file) && is_readable($file)) {
$value = file_get_contents($file);
} elseif (!is_file($file)) {
throw new \InvalidArgumentException(sprintf('File not found: "%s".', $file));
} elseif (!is_readable($file)) {
throw new \InvalidArgumentException(sprintf('File is not readable: "%s".', $file));
}
if ($vault->generateKeys()) {
$io->success($vault->getLastMessage());
if ($this->vault === $vault) {
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠');
}
}
$vault->seal($name, $value);
$io->success($vault->getLastMessage() ?? 'Secret was successfully stored in the vault.');
if (0 < $random) {
$errOutput->write(' // The generated random value is: <comment>');
$output->write($value);
$errOutput->writeln('</comment>');
$io->newLine();
}
if ($this->vault === $vault && null !== $this->localVault->reveal($name)) {
$io->comment('Note that this secret is overridden in the local vault.');
}
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
$suggestions->suggestValues(array_keys($this->vault->list(false)));
}
}
}

View File

@@ -0,0 +1,410 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Translation\Catalogue\MergeOperation;
use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Component\Translation\Extractor\ExtractorInterface;
use Symfony\Component\Translation\LoggingTranslator;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\Reader\TranslationReaderInterface;
use Symfony\Component\Translation\Translator;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Helps finding unused or missing translation messages in a given locale
* and comparing them with the fallback ones.
*
* @author Florian Voutzinos <florian@voutzinos.com>
*
* @final
*/
#[AsCommand(name: 'debug:translation', description: 'Display translation messages information')]
class TranslationDebugCommand extends Command
{
public const EXIT_CODE_GENERAL_ERROR = 64;
public const EXIT_CODE_MISSING = 65;
public const EXIT_CODE_UNUSED = 66;
public const EXIT_CODE_FALLBACK = 68;
public const MESSAGE_MISSING = 0;
public const MESSAGE_UNUSED = 1;
public const MESSAGE_EQUALS_FALLBACK = 2;
private TranslatorInterface $translator;
private TranslationReaderInterface $reader;
private ExtractorInterface $extractor;
private ?string $defaultTransPath;
private ?string $defaultViewsPath;
private array $transPaths;
private array $codePaths;
private array $enabledLocales;
public function __construct(TranslatorInterface $translator, TranslationReaderInterface $reader, ExtractorInterface $extractor, ?string $defaultTransPath = null, ?string $defaultViewsPath = null, array $transPaths = [], array $codePaths = [], array $enabledLocales = [])
{
parent::__construct();
$this->translator = $translator;
$this->reader = $reader;
$this->extractor = $extractor;
$this->defaultTransPath = $defaultTransPath;
$this->defaultViewsPath = $defaultViewsPath;
$this->transPaths = $transPaths;
$this->codePaths = $codePaths;
$this->enabledLocales = $enabledLocales;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('locale', InputArgument::REQUIRED, 'The locale'),
new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'),
new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'The messages domain'),
new InputOption('only-missing', null, InputOption::VALUE_NONE, 'Display only missing messages'),
new InputOption('only-unused', null, InputOption::VALUE_NONE, 'Display only unused messages'),
new InputOption('all', null, InputOption::VALUE_NONE, 'Load messages from all registered bundles'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command helps finding unused or missing translation
messages and comparing them with the fallback ones by inspecting the
templates and translation files of a given bundle or the default translations directory.
You can display information about bundle translations in a specific locale:
<info>php %command.full_name% en AcmeDemoBundle</info>
You can also specify a translation domain for the search:
<info>php %command.full_name% --domain=messages en AcmeDemoBundle</info>
You can only display missing messages:
<info>php %command.full_name% --only-missing en AcmeDemoBundle</info>
You can only display unused messages:
<info>php %command.full_name% --only-unused en AcmeDemoBundle</info>
You can display information about application translations in a specific locale:
<info>php %command.full_name% en</info>
You can display information about translations in all registered bundles in a specific locale:
<info>php %command.full_name% --all en</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$locale = $input->getArgument('locale');
$domain = $input->getOption('domain');
$exitCode = self::SUCCESS;
/** @var KernelInterface $kernel */
$kernel = $this->getApplication()->getKernel();
// Define Root Paths
$transPaths = $this->getRootTransPaths();
$codePaths = $this->getRootCodePaths($kernel);
// Override with provided Bundle info
if (null !== $input->getArgument('bundle')) {
try {
$bundle = $kernel->getBundle($input->getArgument('bundle'));
$bundleDir = $bundle->getPath();
$transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations'];
$codePaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates'];
if ($this->defaultTransPath) {
$transPaths[] = $this->defaultTransPath;
}
if ($this->defaultViewsPath) {
$codePaths[] = $this->defaultViewsPath;
}
} catch (\InvalidArgumentException) {
// such a bundle does not exist, so treat the argument as path
$path = $input->getArgument('bundle');
$transPaths = [$path.'/translations'];
$codePaths = [$path.'/templates'];
if (!is_dir($transPaths[0])) {
throw new InvalidArgumentException(sprintf('"%s" is neither an enabled bundle nor a directory.', $transPaths[0]));
}
}
} elseif ($input->getOption('all')) {
foreach ($kernel->getBundles() as $bundle) {
$bundleDir = $bundle->getPath();
$transPaths[] = is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundle->getPath().'/translations';
$codePaths[] = is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundle->getPath().'/templates';
}
}
// Extract used messages
$extractedCatalogue = $this->extractMessages($locale, $codePaths);
// Load defined messages
$currentCatalogue = $this->loadCurrentMessages($locale, $transPaths);
// Merge defined and extracted messages to get all message ids
$mergeOperation = new MergeOperation($extractedCatalogue, $currentCatalogue);
$allMessages = $mergeOperation->getResult()->all($domain);
if (null !== $domain) {
$allMessages = [$domain => $allMessages];
}
// No defined or extracted messages
if (!$allMessages || null !== $domain && empty($allMessages[$domain])) {
$outputMessage = sprintf('No defined or extracted messages for locale "%s"', $locale);
if (null !== $domain) {
$outputMessage .= sprintf(' and domain "%s"', $domain);
}
$io->getErrorStyle()->warning($outputMessage);
return self::EXIT_CODE_GENERAL_ERROR;
}
// Load the fallback catalogues
$fallbackCatalogues = $this->loadFallbackCatalogues($locale, $transPaths);
// Display header line
$headers = ['State', 'Domain', 'Id', sprintf('Message Preview (%s)', $locale)];
foreach ($fallbackCatalogues as $fallbackCatalogue) {
$headers[] = sprintf('Fallback Message Preview (%s)', $fallbackCatalogue->getLocale());
}
$rows = [];
// 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 = [];
if ($extractedCatalogue->defines($messageId, $domain)) {
if (!$currentCatalogue->defines($messageId, $domain)) {
$states[] = self::MESSAGE_MISSING;
if (!$input->getOption('only-unused')) {
$exitCode |= self::EXIT_CODE_MISSING;
}
}
} elseif ($currentCatalogue->defines($messageId, $domain)) {
$states[] = self::MESSAGE_UNUSED;
if (!$input->getOption('only-missing')) {
$exitCode |= self::EXIT_CODE_UNUSED;
}
}
if (!\in_array(self::MESSAGE_UNUSED, $states) && $input->getOption('only-unused')
|| !\in_array(self::MESSAGE_MISSING, $states) && $input->getOption('only-missing')
) {
continue;
}
foreach ($fallbackCatalogues as $fallbackCatalogue) {
if ($fallbackCatalogue->defines($messageId, $domain) && $value === $fallbackCatalogue->get($messageId, $domain)) {
$states[] = self::MESSAGE_EQUALS_FALLBACK;
$exitCode |= self::EXIT_CODE_FALLBACK;
break;
}
}
$row = [$this->formatStates($states), $domain, $this->formatId($messageId), $this->sanitizeString($value)];
foreach ($fallbackCatalogues as $fallbackCatalogue) {
$row[] = $this->sanitizeString($fallbackCatalogue->get($messageId, $domain));
}
$rows[] = $row;
}
}
$io->table($headers, $rows);
return $exitCode;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('locale')) {
$suggestions->suggestValues($this->enabledLocales);
return;
}
/** @var KernelInterface $kernel */
$kernel = $this->getApplication()->getKernel();
if ($input->mustSuggestArgumentValuesFor('bundle')) {
$availableBundles = [];
foreach ($kernel->getBundles() as $bundle) {
$availableBundles[] = $bundle->getName();
if ($extension = $bundle->getContainerExtension()) {
$availableBundles[] = $extension->getAlias();
}
}
$suggestions->suggestValues($availableBundles);
return;
}
if ($input->mustSuggestOptionValuesFor('domain')) {
$locale = $input->getArgument('locale');
$mergeOperation = new MergeOperation(
$this->extractMessages($locale, $this->getRootCodePaths($kernel)),
$this->loadCurrentMessages($locale, $this->getRootTransPaths())
);
$suggestions->suggestValues($mergeOperation->getDomains());
}
}
private function formatState(int $state): string
{
if (self::MESSAGE_MISSING === $state) {
return '<error> missing </error>';
}
if (self::MESSAGE_UNUSED === $state) {
return '<comment> unused </comment>';
}
if (self::MESSAGE_EQUALS_FALLBACK === $state) {
return '<info> fallback </info>';
}
return $state;
}
private function formatStates(array $states): string
{
$result = [];
foreach ($states as $state) {
$result[] = $this->formatState($state);
}
return implode(' ', $result);
}
private function formatId(string $id): string
{
return sprintf('<fg=cyan;options=bold>%s</>', $id);
}
private function sanitizeString(string $string, int $length = 40): string
{
$string = trim(preg_replace('/\s+/', ' ', $string));
if (false !== $encoding = mb_detect_encoding($string, null, true)) {
if (mb_strlen($string, $encoding) > $length) {
return mb_substr($string, 0, $length - 3, $encoding).'...';
}
} elseif (\strlen($string) > $length) {
return substr($string, 0, $length - 3).'...';
}
return $string;
}
private function extractMessages(string $locale, array $transPaths): MessageCatalogue
{
$extractedCatalogue = new MessageCatalogue($locale);
foreach ($transPaths as $path) {
if (is_dir($path) || is_file($path)) {
$this->extractor->extract($path, $extractedCatalogue);
}
}
return $extractedCatalogue;
}
private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue
{
$currentCatalogue = new MessageCatalogue($locale);
foreach ($transPaths as $path) {
if (is_dir($path)) {
$this->reader->read($path, $currentCatalogue);
}
}
return $currentCatalogue;
}
/**
* @return MessageCatalogue[]
*/
private function loadFallbackCatalogues(string $locale, array $transPaths): array
{
$fallbackCatalogues = [];
if ($this->translator instanceof Translator || $this->translator instanceof DataCollectorTranslator || $this->translator instanceof LoggingTranslator) {
foreach ($this->translator->getFallbackLocales() as $fallbackLocale) {
if ($fallbackLocale === $locale) {
continue;
}
$fallbackCatalogue = new MessageCatalogue($fallbackLocale);
foreach ($transPaths as $path) {
if (is_dir($path)) {
$this->reader->read($path, $fallbackCatalogue);
}
}
$fallbackCatalogues[] = $fallbackCatalogue;
}
}
return $fallbackCatalogues;
}
private function getRootTransPaths(): array
{
$transPaths = $this->transPaths;
if ($this->defaultTransPath) {
$transPaths[] = $this->defaultTransPath;
}
return $transPaths;
}
private function getRootCodePaths(KernelInterface $kernel): array
{
$codePaths = $this->codePaths;
$codePaths[] = $kernel->getProjectDir().'/src';
if ($this->defaultViewsPath) {
$codePaths[] = $this->defaultViewsPath;
}
return $codePaths;
}
}

View File

@@ -0,0 +1,447 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Translation\Catalogue\MergeOperation;
use Symfony\Component\Translation\Catalogue\TargetOperation;
use Symfony\Component\Translation\Extractor\ExtractorInterface;
use Symfony\Component\Translation\MessageCatalogue;
use Symfony\Component\Translation\MessageCatalogueInterface;
use Symfony\Component\Translation\Reader\TranslationReaderInterface;
use Symfony\Component\Translation\Writer\TranslationWriterInterface;
/**
* A command that parses templates to extract translation messages and adds them
* into the translation files.
*
* @author Michel Salib <michelsalib@hotmail.com>
*
* @final
*/
#[AsCommand(name: 'translation:extract', description: 'Extract missing translations keys from code to translation files')]
class TranslationUpdateCommand extends Command
{
private const ASC = 'asc';
private const DESC = 'desc';
private const SORT_ORDERS = [self::ASC, self::DESC];
private const FORMATS = [
'xlf12' => ['xlf', '1.2'],
'xlf20' => ['xlf', '2.0'],
];
private TranslationWriterInterface $writer;
private TranslationReaderInterface $reader;
private ExtractorInterface $extractor;
private string $defaultLocale;
private ?string $defaultTransPath;
private ?string $defaultViewsPath;
private array $transPaths;
private array $codePaths;
private array $enabledLocales;
public function __construct(TranslationWriterInterface $writer, TranslationReaderInterface $reader, ExtractorInterface $extractor, string $defaultLocale, ?string $defaultTransPath = null, ?string $defaultViewsPath = null, array $transPaths = [], array $codePaths = [], array $enabledLocales = [])
{
parent::__construct();
if (!method_exists($writer, 'getFormats')) {
throw new \InvalidArgumentException(sprintf('The writer class "%s" does not implement the "getFormats()" method.', $writer::class));
}
$this->writer = $writer;
$this->reader = $reader;
$this->extractor = $extractor;
$this->defaultLocale = $defaultLocale;
$this->defaultTransPath = $defaultTransPath;
$this->defaultViewsPath = $defaultViewsPath;
$this->transPaths = $transPaths;
$this->codePaths = $codePaths;
$this->enabledLocales = $enabledLocales;
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('locale', InputArgument::REQUIRED, 'The locale'),
new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'),
new InputOption('prefix', null, InputOption::VALUE_OPTIONAL, 'Override the default prefix', '__'),
new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf12'),
new InputOption('dump-messages', null, InputOption::VALUE_NONE, 'Should the messages be dumped in the console'),
new InputOption('force', null, InputOption::VALUE_NONE, 'Should the extract be done'),
new InputOption('clean', null, InputOption::VALUE_NONE, 'Should clean not found messages'),
new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'Specify the domain to extract'),
new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically (only works with --dump-messages)', 'asc'),
new InputOption('as-tree', null, InputOption::VALUE_OPTIONAL, 'Dump the messages as a tree-like structure: The given value defines the level where to switch to inline YAML'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command extracts translation strings from templates
of a given bundle or the default translations directory. It can display them or merge
the new ones into the translation files.
When new translation strings are found it can automatically add a prefix to the translation
message.
Example running against a Bundle (AcmeBundle)
<info>php %command.full_name% --dump-messages en AcmeBundle</info>
<info>php %command.full_name% --force --prefix="new_" fr AcmeBundle</info>
Example running against default messages directory
<info>php %command.full_name% --dump-messages en</info>
<info>php %command.full_name% --force --prefix="new_" fr</info>
You can sort the output with the <comment>--sort</> flag:
<info>php %command.full_name% --dump-messages --sort=asc en AcmeBundle</info>
<info>php %command.full_name% --dump-messages --sort=desc fr</info>
You can dump a tree-like structure using the yaml format with <comment>--as-tree</> flag:
<info>php %command.full_name% --force --format=yaml --as-tree=3 en AcmeBundle</info>
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$errorIo = $io->getErrorStyle();
// check presence of force or dump-message
if (true !== $input->getOption('force') && true !== $input->getOption('dump-messages')) {
$errorIo->error('You must choose one of --force or --dump-messages');
return 1;
}
$format = $input->getOption('format');
$xliffVersion = '1.2';
if (\array_key_exists($format, self::FORMATS)) {
[$format, $xliffVersion] = self::FORMATS[$format];
}
// check format
$supportedFormats = $this->writer->getFormats();
if (!\in_array($format, $supportedFormats, true)) {
$errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).', xlf12 and xlf20.']);
return 1;
}
/** @var KernelInterface $kernel */
$kernel = $this->getApplication()->getKernel();
// Define Root Paths
$transPaths = $this->getRootTransPaths();
$codePaths = $this->getRootCodePaths($kernel);
$currentName = 'default directory';
// Override with provided Bundle info
if (null !== $input->getArgument('bundle')) {
try {
$foundBundle = $kernel->getBundle($input->getArgument('bundle'));
$bundleDir = $foundBundle->getPath();
$transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations'];
$codePaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates'];
if ($this->defaultTransPath) {
$transPaths[] = $this->defaultTransPath;
}
if ($this->defaultViewsPath) {
$codePaths[] = $this->defaultViewsPath;
}
$currentName = $foundBundle->getName();
} catch (\InvalidArgumentException) {
// such a bundle does not exist, so treat the argument as path
$path = $input->getArgument('bundle');
$transPaths = [$path.'/translations'];
$codePaths = [$path.'/templates'];
if (!is_dir($transPaths[0])) {
throw new InvalidArgumentException(sprintf('"%s" is neither an enabled bundle nor a directory.', $transPaths[0]));
}
}
}
$io->title('Translation Messages Extractor and Dumper');
$io->comment(sprintf('Generating "<info>%s</info>" translation files for "<info>%s</info>"', $input->getArgument('locale'), $currentName));
$io->comment('Parsing templates...');
$extractedCatalogue = $this->extractMessages($input->getArgument('locale'), $codePaths, $input->getOption('prefix'));
$io->comment('Loading translation files...');
$currentCatalogue = $this->loadCurrentMessages($input->getArgument('locale'), $transPaths);
if (null !== $domain = $input->getOption('domain')) {
$currentCatalogue = $this->filterCatalogue($currentCatalogue, $domain);
$extractedCatalogue = $this->filterCatalogue($extractedCatalogue, $domain);
}
// process catalogues
$operation = $input->getOption('clean')
? new TargetOperation($currentCatalogue, $extractedCatalogue)
: new MergeOperation($currentCatalogue, $extractedCatalogue);
// Exit if no messages found.
if (!\count($operation->getDomains())) {
$errorIo->warning('No translation messages were found.');
return 0;
}
$resultMessage = 'Translation files were successfully updated';
$operation->moveMessagesToIntlDomainsIfPossible('new');
// show compiled list of messages
if (true === $input->getOption('dump-messages')) {
$extractedMessagesCount = 0;
$io->newLine();
foreach ($operation->getDomains() as $domain) {
$newKeys = array_keys($operation->getNewMessages($domain));
$allKeys = array_keys($operation->getMessages($domain));
$list = array_merge(
array_diff($allKeys, $newKeys),
array_map(fn ($id) => sprintf('<fg=green>%s</>', $id), $newKeys),
array_map(fn ($id) => sprintf('<fg=red>%s</>', $id), array_keys($operation->getObsoleteMessages($domain)))
);
$domainMessagesCount = \count($list);
if ($sort = $input->getOption('sort')) {
$sort = strtolower($sort);
if (!\in_array($sort, self::SORT_ORDERS, true)) {
$errorIo->error(['Wrong sort order', 'Supported formats are: '.implode(', ', self::SORT_ORDERS).'.']);
return 1;
}
if (self::DESC === $sort) {
rsort($list);
} else {
sort($list);
}
}
$io->section(sprintf('Messages extracted for domain "<info>%s</info>" (%d message%s)', $domain, $domainMessagesCount, $domainMessagesCount > 1 ? 's' : ''));
$io->listing($list);
$extractedMessagesCount += $domainMessagesCount;
}
if ('xlf' === $format) {
$io->comment(sprintf('Xliff output version is <info>%s</info>', $xliffVersion));
}
$resultMessage = sprintf('%d message%s successfully extracted', $extractedMessagesCount, $extractedMessagesCount > 1 ? 's were' : ' was');
}
// save the files
if (true === $input->getOption('force')) {
$io->comment('Writing files...');
$bundleTransPath = false;
foreach ($transPaths as $path) {
if (is_dir($path)) {
$bundleTransPath = $path;
}
}
if (!$bundleTransPath) {
$bundleTransPath = end($transPaths);
}
$this->writer->write($operation->getResult(), $format, ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $xliffVersion, 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]);
if (true === $input->getOption('dump-messages')) {
$resultMessage .= ' and translation files were updated';
}
}
$io->success($resultMessage.'.');
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('locale')) {
$suggestions->suggestValues($this->enabledLocales);
return;
}
/** @var KernelInterface $kernel */
$kernel = $this->getApplication()->getKernel();
if ($input->mustSuggestArgumentValuesFor('bundle')) {
$bundles = [];
foreach ($kernel->getBundles() as $bundle) {
$bundles[] = $bundle->getName();
if ($bundle->getContainerExtension()) {
$bundles[] = $bundle->getContainerExtension()->getAlias();
}
}
$suggestions->suggestValues($bundles);
return;
}
if ($input->mustSuggestOptionValuesFor('format')) {
$suggestions->suggestValues(array_merge(
$this->writer->getFormats(),
array_keys(self::FORMATS)
));
return;
}
if ($input->mustSuggestOptionValuesFor('domain') && $locale = $input->getArgument('locale')) {
$extractedCatalogue = $this->extractMessages($locale, $this->getRootCodePaths($kernel), $input->getOption('prefix'));
$currentCatalogue = $this->loadCurrentMessages($locale, $this->getRootTransPaths());
// process catalogues
$operation = $input->getOption('clean')
? new TargetOperation($currentCatalogue, $extractedCatalogue)
: new MergeOperation($currentCatalogue, $extractedCatalogue);
$suggestions->suggestValues($operation->getDomains());
return;
}
if ($input->mustSuggestOptionValuesFor('sort')) {
$suggestions->suggestValues(self::SORT_ORDERS);
}
}
private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue
{
$filteredCatalogue = new MessageCatalogue($catalogue->getLocale());
// extract intl-icu messages only
$intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX;
if ($intlMessages = $catalogue->all($intlDomain)) {
$filteredCatalogue->add($intlMessages, $intlDomain);
}
// extract all messages and subtract intl-icu messages
if ($messages = array_diff($catalogue->all($domain), $intlMessages)) {
$filteredCatalogue->add($messages, $domain);
}
foreach ($catalogue->getResources() as $resource) {
$filteredCatalogue->addResource($resource);
}
if ($metadata = $catalogue->getMetadata('', $intlDomain)) {
foreach ($metadata as $k => $v) {
$filteredCatalogue->setMetadata($k, $v, $intlDomain);
}
}
if ($metadata = $catalogue->getMetadata('', $domain)) {
foreach ($metadata as $k => $v) {
$filteredCatalogue->setMetadata($k, $v, $domain);
}
}
return $filteredCatalogue;
}
private function extractMessages(string $locale, array $transPaths, string $prefix): MessageCatalogue
{
$extractedCatalogue = new MessageCatalogue($locale);
$this->extractor->setPrefix($prefix);
$transPaths = $this->filterDuplicateTransPaths($transPaths);
foreach ($transPaths as $path) {
if (is_dir($path) || is_file($path)) {
$this->extractor->extract($path, $extractedCatalogue);
}
}
return $extractedCatalogue;
}
private function filterDuplicateTransPaths(array $transPaths): array
{
$transPaths = array_filter(array_map('realpath', $transPaths));
sort($transPaths);
$filteredPaths = [];
foreach ($transPaths as $path) {
foreach ($filteredPaths as $filteredPath) {
if (str_starts_with($path, $filteredPath.\DIRECTORY_SEPARATOR)) {
continue 2;
}
}
$filteredPaths[] = $path;
}
return $filteredPaths;
}
private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue
{
$currentCatalogue = new MessageCatalogue($locale);
foreach ($transPaths as $path) {
if (is_dir($path)) {
$this->reader->read($path, $currentCatalogue);
}
}
return $currentCatalogue;
}
private function getRootTransPaths(): array
{
$transPaths = $this->transPaths;
if ($this->defaultTransPath) {
$transPaths[] = $this->defaultTransPath;
}
return $transPaths;
}
private function getRootCodePaths(KernelInterface $kernel): array
{
$codePaths = $this->codePaths;
$codePaths[] = $kernel->getProjectDir().'/src';
if ($this->defaultViewsPath) {
$codePaths[] = $this->defaultViewsPath;
}
return $codePaths;
}
}

View File

@@ -0,0 +1,161 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\Workflow\Definition;
use Symfony\Component\Workflow\Dumper\GraphvizDumper;
use Symfony\Component\Workflow\Dumper\MermaidDumper;
use Symfony\Component\Workflow\Dumper\PlantUmlDumper;
use Symfony\Component\Workflow\Dumper\StateMachineGraphvizDumper;
use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\StateMachine;
/**
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*
* @final
*/
#[AsCommand(name: 'workflow:dump', description: 'Dump a workflow')]
class WorkflowDumpCommand extends Command
{
/**
* string is the service id.
*
* @var array<string, Definition>
*/
private array $definitions = [];
private ServiceLocator $workflows;
private const DUMP_FORMAT_OPTIONS = [
'puml',
'mermaid',
'dot',
];
public function __construct($workflows)
{
parent::__construct();
if ($workflows instanceof ServiceLocator) {
$this->workflows = $workflows;
} elseif (\is_array($workflows)) {
$this->definitions = $workflows;
trigger_deprecation('symfony/framework-bundle', '6.2', 'Passing an array of definitions in "%s()" is deprecated. Inject a ServiceLocator filled with all workflows instead.', __METHOD__);
} else {
throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be an array or a ServiceLocator, "%s" given.', __METHOD__, \gettype($workflows)));
}
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('name', InputArgument::REQUIRED, 'A workflow name'),
new InputArgument('marking', InputArgument::IS_ARRAY, 'A marking (a list of places)'),
new InputOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label a graph'),
new InputOption('with-metadata', null, InputOption::VALUE_NONE, 'Include the workflow\'s metadata in the dumped graph', null),
new InputOption('dump-format', null, InputOption::VALUE_REQUIRED, 'The dump format ['.implode('|', self::DUMP_FORMAT_OPTIONS).']', 'dot'),
])
->setHelp(<<<'EOF'
The <info>%command.name%</info> command dumps the graphical representation of a
workflow in different formats
<info>DOT</info>: %command.full_name% <workflow name> | dot -Tpng > workflow.png
<info>PUML</info>: %command.full_name% <workflow name> --dump-format=puml | java -jar plantuml.jar -p > workflow.png
<info>MERMAID</info>: %command.full_name% <workflow name> --dump-format=mermaid | mmdc -o workflow.svg
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$workflowName = $input->getArgument('name');
if (isset($this->workflows)) {
if (!$this->workflows->has($workflowName)) {
throw new InvalidArgumentException(sprintf('The workflow named "%s" cannot be found.', $workflowName));
}
$workflow = $this->workflows->get($workflowName);
$type = $workflow instanceof StateMachine ? 'state_machine' : 'workflow';
$definition = $workflow->getDefinition();
} elseif (isset($this->definitions['workflow.'.$workflowName])) {
$definition = $this->definitions['workflow.'.$workflowName];
$type = 'workflow';
} elseif (isset($this->definitions['state_machine.'.$workflowName])) {
$definition = $this->definitions['state_machine.'.$workflowName];
$type = 'state_machine';
}
if (null === $definition) {
throw new InvalidArgumentException(sprintf('No service found for "workflow.%1$s" nor "state_machine.%1$s".', $workflowName));
}
switch ($input->getOption('dump-format')) {
case 'puml':
$transitionType = 'workflow' === $type ? PlantUmlDumper::WORKFLOW_TRANSITION : PlantUmlDumper::STATEMACHINE_TRANSITION;
$dumper = new PlantUmlDumper($transitionType);
break;
case 'mermaid':
$transitionType = 'workflow' === $type ? MermaidDumper::TRANSITION_TYPE_WORKFLOW : MermaidDumper::TRANSITION_TYPE_STATEMACHINE;
$dumper = new MermaidDumper($transitionType);
break;
case 'dot':
default:
$dumper = ('workflow' === $type) ? new GraphvizDumper() : new StateMachineGraphvizDumper();
}
$marking = new Marking();
foreach ($input->getArgument('marking') as $place) {
$marking->mark($place);
}
$options = [
'name' => $workflowName,
'with-metadata' => $input->getOption('with-metadata'),
'nofooter' => true,
'label' => $input->getOption('label'),
];
$output->writeln($dumper->dump($definition, $marking, $options));
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('name')) {
if (isset($this->workflows)) {
$suggestions->suggestValues(array_keys($this->workflows->getProvidedServices()));
} else {
$suggestions->suggestValues(array_keys($this->definitions));
}
}
if ($input->mustSuggestOptionValuesFor('dump-format')) {
$suggestions->suggestValues(self::DUMP_FORMAT_OPTIONS);
}
}
}

View File

@@ -0,0 +1,57 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Translation\Command\XliffLintCommand as BaseLintCommand;
/**
* Validates XLIFF files syntax and outputs encountered errors.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Robin Chalas <robin.chalas@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*
* @final
*/
#[AsCommand(name: 'lint:xliff', description: 'Lint an XLIFF file and outputs encountered errors')]
class XliffLintCommand extends BaseLintCommand
{
public function __construct()
{
$directoryIteratorProvider = function ($directory, $default) {
if (!is_dir($directory)) {
$directory = $this->getApplication()->getKernel()->locateResource($directory);
}
return $default($directory);
};
$isReadableProvider = fn ($fileOrDirectory, $default) => str_starts_with($fileOrDirectory, '@') || $default($fileOrDirectory);
parent::__construct(null, $directoryIteratorProvider, $isReadableProvider);
}
protected function configure(): void
{
parent::configure();
$this->setHelp($this->getHelp().<<<'EOF'
Or find all files in a bundle:
<info>php %command.full_name% @AcmeDemoBundle</info>
EOF
);
}
}

View File

@@ -0,0 +1,56 @@
<?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;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Yaml\Command\LintCommand as BaseLintCommand;
/**
* Validates YAML files syntax and outputs encountered errors.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @final
*/
#[AsCommand(name: 'lint:yaml', description: 'Lint a YAML file and outputs encountered errors')]
class YamlLintCommand extends BaseLintCommand
{
public function __construct()
{
$directoryIteratorProvider = function ($directory, $default) {
if (!is_dir($directory)) {
$directory = $this->getApplication()->getKernel()->locateResource($directory);
}
return $default($directory);
};
$isReadableProvider = fn ($fileOrDirectory, $default) => str_starts_with($fileOrDirectory, '@') || $default($fileOrDirectory);
parent::__construct(null, $directoryIteratorProvider, $isReadableProvider);
}
protected function configure(): void
{
parent::configure();
$this->setHelp($this->getHelp().<<<'EOF'
Or find all files in a bundle:
<info>php %command.full_name% @AcmeDemoBundle</info>
EOF
);
}
}