clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\Core\Config\ConfigFactoryInterface;
use Symfony\Component\Console\Helper\Helper;
/**
* A helper that provides information about Drupal configuration.
*/
final class ConfigInfo extends Helper {
/**
* Constructs the object.
*/
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'config_info';
}
/**
* Gets configuration object names.
*
* @psalm-return list<string>
* @psalm-suppress MoreSpecificReturnType
*/
public function getConfigNames(): array {
/** @psalm-suppress LessSpecificReturnStatement */
return $this->configFactory->listAll();
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\Core\Extension\Extension;
/**
* A helper that provides information about installed Drupal extensions.
*/
interface ExtensionInfoInterface {
/**
* Returns a list of currently installed extensions.
*/
public function getExtensions(): array;
/**
* Returns a human name of the extension.
*/
public function getExtensionName(string $machine_name): ?string;
/**
* Returns a machine name of the extension.
*/
public function getExtensionMachineName(string $name): ?string;
/**
* Returns destination for generated extension code.
*/
public function getDestination(string $machine_name, bool $is_new): ?string;
/**
* Gets extension info for a given absolute path.
*/
public function getExtensionFromPath(string $path): ?Extension;
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Symfony\Component\Console\Helper\Helper;
/**
* Provides information about available Drupal hooks.
*/
final class HookInfo extends Helper {
/**
* Constructs helper.
*/
public function __construct(private readonly ModuleHandlerInterface $moduleHandler) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'hook_info';
}
/**
* Gets templates for all available hooks.
*
* @psalm-return array<string, string>
*/
public function getHookTemplates(): array {
static $hooks;
if ($hooks) {
return $hooks;
}
$core_api_files = \glob(\DRUPAL_ROOT . '/core/lib/Drupal/Core/*/*.api.php');
$core_api_files[] = \DRUPAL_ROOT . '/core/core.api.php';
$module_api_files = [];
foreach ($this->moduleHandler->getModuleList() as $machine_name => $module) {
$api_file = \DRUPAL_ROOT . '/' . $module->getPath() . '/' . $machine_name . '.api.php';
if (\file_exists($api_file)) {
$module_api_files[] = $api_file;
}
}
$api_files = \array_merge($core_api_files, $module_api_files);
$reducer = static fn (array $collected, string $api_file): array => \array_merge($collected, self::parseHooks($api_file));
return \array_reduce($api_files, $reducer, []);
}
/**
* Returns filetype of a hook.
*/
public static function getFileType(string $hook_name): string {
// Some Drupal hooks are not defined in MODULE_NAME.module file.
return match ($hook_name) {
'install',
'uninstall',
'schema',
'requirements',
'update_N',
'update_last_removed' => 'install',
// See views_hook_info().
'views_data',
'views_data_alter',
'views_analyze',
'views_invalidate_cache',
'field_views_data',
'field_views_data_alter',
// See \Drupal\views\views::$plugins.
'views_plugins_access_alter',
'views_plugins_area_alter',
'views_plugins_argument_alter',
'views_plugins_argument_default_alter',
'views_plugins_argument_validator_alter',
'views_plugins_cache_alter',
'views_plugins_display_extender_alter',
'views_plugins_display_alter',
'views_plugins_exposed_form_alter',
'views_plugins_field_alter',
'views_plugins_filter_alter',
'views_plugins_join_alter',
'views_plugins_pager_alter',
'views_plugins_query_alter',
'views_plugins_relationship_alter',
'views_plugins_row_alter',
'views_plugins_sort_alter',
'views_plugins_style_alter',
'views_plugins_wizard_alter' => 'views.inc',
'views_query_substitutions',
'views_form_substitutions',
'views_pre_view',
'views_pre_build',
'views_post_build',
'views_pre_execute',
'views_post_execute',
'views_pre_render',
'views_post_render',
'views_query_alter' => 'views_execution.inc',
'token_info',
'token_info_alter',
'tokens',
'tokens_alter' => 'tokens.inc',
'post_update_NAME' => 'post_update.php',
default => 'module',
};
}
/**
* Creates hook templates from PHP file.
*
* @psalm-return array<string, string>
*/
private static function parseHooks(string $file): array {
\preg_match_all(
"/function hook_(?P<name>.*)\(.*\n\}\n/Us",
\file_get_contents($file),
$matches,
);
/** @psalm-var array{0: array, 1: array, name: array} $matches */
$results = [];
foreach ($matches[0] as $index => $hook_code) {
$hook_name = $matches['name'][$index];
$results[$hook_name] = self::buildHookTemplate($hook_name, $hook_code);
}
return $results;
}
/**
* Builds hook template from PHP code.
*/
private static function buildHookTemplate(string $hook_name, string $hook_code): string {
$hook_template = \str_replace('function hook_', 'function {{ machine_name }}_', $hook_code);
// Add 'void' return type when it is clear that the hook returns nothing.
// @todo Remove this once Drupal adds return typehints for hooks.
if (!\str_contains($hook_template, 'return')) {
$hook_template = \preg_replace('#^(function \{\{ machine_name \}\}_.+\)) \{$#m', '\1: void {', $hook_template);
}
$file_description = self::getFileDescription(self::getFileType($hook_name));
return <<< TWIG
<?php
declare(strict_types=1);
/**
* @file
* $file_description
*/
/**
* Implements hook_$hook_name().
*/
$hook_template
TWIG;
}
/**
* Gets file description.
*/
private static function getFileDescription(string $file_type): string {
return match($file_type) {
'install' => 'Install, update and uninstall functions for the {{ name }} module.',
'module' => 'Primary module hooks for {{ name }} module.',
'post_update.php' => 'Post update functions for the {{ name }} module.',
'tokens.inc' => 'Builds tokens for the {{ name }} module.',
'views.inc' => 'Views hooks for the {{ name }} module.',
'views_execution.inc' => 'Provide views runtime hooks for the {{ name }} module.',
default => throw new \InvalidArgumentException('Unsupported file type.'),
};
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Filesystem\Path;
/**
* A helper that provides information about installed Drupal modules.
*/
final class ModuleInfo extends Helper implements ExtensionInfoInterface {
/**
* Constructs the object.
*/
public function __construct(
private readonly ModuleHandlerInterface $moduleHandler,
private readonly ModuleExtensionList $moduleList,
) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'module_info';
}
/**
* {@inheritdoc}
*
* @psalm-return array<string, string>
*/
public function getExtensions(): array {
$modules = [];
foreach ($this->moduleHandler->getModuleList() as $machine_name => $module) {
/** @psalm-suppress InternalMethod */
$modules[$machine_name] = $this->moduleList->getName($machine_name);
}
return $modules;
}
/**
* {@inheritdoc}
*/
public function getDestination(string $machine_name, bool $is_new): string {
$modules_dir = \is_dir(\DRUPAL_ROOT . '/modules/custom') ?
'modules/custom' : 'modules';
if ($is_new) {
$destination = $modules_dir;
}
else {
$destination = \array_key_exists($machine_name, $this->getExtensions())
? $this->moduleHandler->getModule($machine_name)->getPath()
: $modules_dir . '/' . $machine_name;
}
return \DRUPAL_ROOT . '/' . $destination;
}
/**
* {@inheritdoc}
*/
public function getExtensionName(string $machine_name): ?string {
return $this->getExtensions()[$machine_name] ?? NULL;
}
/**
* {@inheritdoc}
*/
public function getExtensionMachineName(string $name): ?string {
return \array_search($name, $this->getExtensions()) ?: NULL;
}
/**
* Gets module info for a given absolute path.
*/
public function getExtensionFromPath(string $path): ?Extension {
if (!Path::isAbsolute($path)) {
throw new \InvalidArgumentException('The path must be absolute.');
}
foreach ($this->moduleHandler->getModuleList() as $module) {
if (\str_starts_with($path, \DRUPAL_ROOT . '/' . $module->getPath())) {
return $module;
}
}
return NULL;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\Core\Extension\Extension;
/**
* This helper can be used to avoid conditional calls for extension info.
*
* @todo Is it still needed?
*/
final class NullExtensionInfo implements ExtensionInfoInterface {
/**
* {@inheritdoc}
*/
public function getExtensions(): array {
return [];
}
/**
* {@inheritdoc}
*/
public function getDestination(string $machine_name, bool $is_new): ?string {
return NULL;
}
/**
* {@inheritdoc}
*/
public function getExtensionName(string $machine_name): ?string {
return NULL;
}
/**
* {@inheritdoc}
*/
public function getExtensionMachineName(string $name): ?string {
return NULL;
}
/**
* {@inheritdoc}
*/
public function getExtensionFromPath(string $path): ?Extension {
return NULL;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\user\PermissionHandlerInterface;
use Symfony\Component\Console\Helper\Helper;
/**
* A helper that provides information about permissions.
*
* @todo Create a test for this.
*/
final class PermissionInfo extends Helper {
/**
* Constructs the helper.
*/
public function __construct(
private readonly PermissionHandlerInterface $permissionHandler,
) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'permission_info';
}
/**
* Gets names of all available permissions.
*
* @psalm-return list<string>
*/
public function getPermissionNames(): array {
$permissions = \array_keys($this->permissionHandler->getPermissions());
\sort($permissions);
return $permissions;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\Core\Routing\RouteProviderInterface;
use Symfony\Component\Console\Helper\Helper;
/**
* A helper that provides information about routes.
*/
final class RouteInfo extends Helper {
/**
* Constructs helper.
*/
public function __construct(
private readonly RouteProviderInterface $routeProvider,
) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'route_info';
}
/**
* Returns names of all routes on the system.
*
* @psalm-return list<string>
*/
public function getRouteNames(): array {
/** @var \Traversable<string,\Symfony\Component\Routing\Route> $routes */
$routes = $this->routeProvider->getAllRoutes();
$route_names = \array_keys(\iterator_to_array($routes));
// Sort names to ease testing.
\sort($route_names);
return $route_names;
}
/**
* Returns all routes on the system.
*
* @psalm-suppress InvalidReturnType
* @psalm-suppress InvalidReturnStatement
*/
public function getRoutes(): \ArrayIterator {
return $this->routeProvider->getAllRoutes();
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use DrupalCodeGenerator\Application;
use DrupalCodeGenerator\Utils;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* A helper that provides information about available Drupal services.
*/
final class ServiceInfo extends Helper {
/**
* Constructs the helper.
*/
public function __construct(private readonly ContainerInterface $container) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'service_info';
}
/**
* Gets a service by name.
*/
public function getService(string $name): ?object {
return $this->container->get($name);
}
/**
* Gets all defined service IDs.
*
* @psalm-return list<string>
*/
public function getServicesIds(): array {
// $this->container->getServiceIds() cannot be used here because it is not
// defined in the Symfony\Component\DependencyInjection\ContainerInterface.
$service_ids = \array_keys($this->getServiceDefinitions());
\sort($service_ids);
return $service_ids;
}
/**
* Gets all service definitions.
*
* @psalm-return array<string, array>
*/
public function getServiceDefinitions(): array {
return \array_filter(
\array_map('unserialize', $this->getSerializedDefinitions()),
static fn (array $definition, string $id): bool =>
// Filter out parameters.
\array_key_exists('class', $definition) &&
// Filter out Drush services.
!\str_starts_with(\ltrim($definition['class'], '\\'), 'Drush') &&
// Filter out aliases.
// @todo Should we support aliases?
!\str_contains($id, '\\'),
\ARRAY_FILTER_USE_BOTH,
);
}
/**
* Gets all service definitions.
*
* @psalm-return array<string, string>
*/
public function getServiceClasses(): array {
$service_definitions = $this->getServiceDefinitions();
/** @psalm-var array<string, class-string> $classes */
$classes = \array_combine(
\array_keys($service_definitions),
\array_column($service_definitions, 'class'),
);
return \array_map([Utils::class, 'addLeadingSlash'], $classes);
}
/**
* Gets service definition.
*
* @psalm-return array{class: class-string}|null
*/
public function getServiceDefinition(string $service_id): ?array {
$serialized_definitions = $this->getSerializedDefinitions();
if (!\array_key_exists($service_id, $serialized_definitions)) {
return NULL;
}
// @phpcs:ignore DrupalPractice.FunctionCalls.InsecureUnserialize.InsecureUnserialize
return \unserialize($serialized_definitions[$service_id]);
}
/**
* Gets metadata for a given service.
*
* @todo Add extended description.
*/
public function getServiceMeta(string $service_id): array {
$dumped_meta = \json_decode(
\file_get_contents(Application::ROOT . '/resources/service-meta.json'),
TRUE,
);
// Most used core services are described statically.
if (\array_key_exists($service_id, $dumped_meta)) {
$meta = $dumped_meta[$service_id];
}
// For services from contrib and custom modules we build meta on demand.
elseif ($definition = $this->getServiceDefinition($service_id)) {
$class = $definition['class'];
/** @psalm-var class-string $interface */
$interface = $class . 'Interface';
$meta = [
'name' => Utils::camelize($service_id, FALSE),
'type_fqn' => \is_subclass_of($class, $interface) ? $interface : $class,
];
}
else {
// @todo Move this exception to ServiceInfo::getServiceDefinition()?
throw new \LogicException(
\sprintf('Service "%s" does not exist.', $service_id),
);
}
$type_parts = \explode('\\', $meta['type_fqn']);
$meta['type'] = \end($type_parts);
\ksort($meta);
return $meta;
}
/**
* Returns array of serialized service definitions.
*
* @psalm-return array<string, string>
*/
private function getSerializedDefinitions(): array {
$cache_definitions = $this->container
->get('kernel')
->getCachedContainerDefinition();
$serialized_definitions = $cache_definitions['services'] ?? [];
\ksort($serialized_definitions);
return $serialized_definitions;
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Drupal;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Symfony\Component\Console\Helper\Helper;
/**
* A helper that provides information about installed Drupal themes.
*
* @todo Create a test for this.
*/
final class ThemeInfo extends Helper implements ExtensionInfoInterface {
/**
* Constructs the object.
*/
public function __construct(private readonly ThemeHandlerInterface $themeHandler) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'theme_info';
}
/**
* {@inheritdoc}
*/
public function getExtensions(): array {
$themes = [];
foreach ($this->themeHandler->listInfo() as $machine_name => $theme) {
if (!isset($theme->info['name'])) {
throw new \RuntimeException('Missing theme name');
}
$themes[$machine_name] = $theme->info['name'];
}
return $themes;
}
/**
* {@inheritdoc}
*/
public function getDestination(string $machine_name, bool $is_new): string {
$themes_dir = \is_dir(\DRUPAL_ROOT . '/themes/custom') ?
'themes/custom' : 'themes';
if ($is_new) {
$destination = $themes_dir;
}
else {
$destination = \array_key_exists($machine_name, $this->getExtensions())
? $this->themeHandler->getTheme($machine_name)->getPath()
: $themes_dir . '/' . $machine_name;
}
return \DRUPAL_ROOT . '/' . $destination;
}
/**
* {@inheritdoc}
*/
public function getExtensionName(string $machine_name): ?string {
return $this->getExtensions()[$machine_name] ?? NULL;
}
/**
* {@inheritdoc}
*/
public function getExtensionMachineName(string $name): ?string {
return \array_search($name, $this->getExtensions()) ?: NULL;
}
/**
* {@inheritdoc}
*/
public function getExtensionFromPath(string $path): ?Extension {
if (!\str_starts_with($path, '/')) {
throw new \InvalidArgumentException('The path must be absolute.');
}
// @todo Implements this.
return NULL;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Dumper;
use DrupalCodeGenerator\Asset\AssetCollection;
use DrupalCodeGenerator\Asset\Directory;
use DrupalCodeGenerator\Asset\File;
use DrupalCodeGenerator\Asset\Symlink;
use DrupalCodeGenerator\InputOutput\IOAwareInterface;
use DrupalCodeGenerator\InputOutput\IOAwareTrait;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Filesystem\Filesystem;
/**
* Asset dumper form generators.
*/
abstract class BaseDumper extends Helper implements DumperInterface, IOAwareInterface {
use IOAwareTrait;
/**
* Constructs the object.
*/
public function __construct(protected readonly Filesystem $filesystem) {}
/**
* {@inheritdoc}
*/
final public function dump(AssetCollection $assets, string $destination): AssetCollection {
$dumped_assets = new AssetCollection();
foreach ($assets as $asset) {
$path = $destination . '/' . $asset->getPath();
$resolved_asset = clone $asset;
if ($this->filesystem->exists($path)) {
$resolved_asset = $asset->getResolver($this->io())->resolve($asset, $path);
}
elseif ($asset->isVirtual()) {
continue;
}
if ($resolved_asset) {
match (TRUE) {
$resolved_asset instanceof Directory => $this->dumpDirectory($resolved_asset, $path),
$resolved_asset instanceof File => $this->dumpFile($resolved_asset, $path),
$resolved_asset instanceof Symlink => $this->dumpSymlink($resolved_asset, $path),
default => throw new \LogicException('Unsupported asset type'),
};
$dumped_assets[] = $resolved_asset;
}
}
return $dumped_assets;
}
/**
* Creates a directory.
*/
abstract protected function dumpDirectory(Directory $directory, string $path): void;
/**
* Dumps a file.
*/
abstract protected function dumpFile(File $file, string $path): void;
/**
* Dumps a symlink.
*/
abstract protected function dumpSymlink(Symlink $symlink, string $path): void;
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Dumper;
use DrupalCodeGenerator\Asset\Asset;
use DrupalCodeGenerator\Asset\Directory;
use DrupalCodeGenerator\Asset\File;
use DrupalCodeGenerator\Asset\Symlink;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Dumps asset to console output.
*/
final class DryDumper extends BaseDumper {
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'dry_dumper';
}
/**
* {@inheritdoc}
*/
protected function dumpDirectory(Directory $directory, string $path): void {
$this->io()->title($this->getPath($directory, $path) . ' (empty directory)');
}
/**
* {@inheritdoc}
*/
protected function dumpFile(File $file, string $path): void {
$this->io()->title($this->getPath($file, $path));
$this->io()->writeln($file->getContent(), OutputInterface::OUTPUT_RAW);
}
/**
* {@inheritdoc}
*/
protected function dumpSymlink(Symlink $symlink, string $path): void {
$this->io()->title($this->getPath($symlink, $path));
$this->io()->writeln('Symlink to ' . $symlink->getTarget(), OutputInterface::OUTPUT_RAW);
}
/**
* {@inheritdoc}
*/
private function getPath(Asset $asset, string $path): string {
return $this->io()->getInput()->getOption('full-path') ? $path : $asset->getPath();
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Dumper;
use DrupalCodeGenerator\Asset\AssetCollection;
/**
* An interface for asset dumpers.
*/
interface DumperInterface {
/**
* Dumps the generated code to file system or stdout.
*/
public function dump(AssetCollection $assets, string $destination): AssetCollection;
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Dumper;
use DrupalCodeGenerator\Asset\Directory;
use DrupalCodeGenerator\Asset\File;
use DrupalCodeGenerator\Asset\Symlink;
/**
* Dumps assets to file system.
*/
final class FileSystemDumper extends BaseDumper {
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'filesystem_dumper';
}
/**
* {@inheritdoc}
*/
protected function dumpDirectory(Directory $directory, string $path): void {
$this->filesystem->mkdir($path, $directory->getMode());
}
/**
* {@inheritdoc}
*/
protected function dumpFile(File $file, string $path): void {
$this->filesystem->dumpFile($path, $file->getContent());
$this->filesystem->chmod($path, $file->getMode());
}
/**
* {@inheritdoc}
*/
protected function dumpSymlink(Symlink $symlink, string $path): void {
$file_exists = $this->filesystem->exists($path);
if ($file_exists) {
$this->filesystem->remove($path);
}
if (!@\symlink($symlink->getTarget(), $path)) {
throw new \RuntimeException('Could not create a symlink to ' . $symlink->getTarget());
}
$this->filesystem->chmod($path, $symlink->getMode());
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Printer;
use DrupalCodeGenerator\Asset\Asset;
use DrupalCodeGenerator\Asset\AssetCollection;
use DrupalCodeGenerator\InputOutput\IOAwareInterface;
use DrupalCodeGenerator\InputOutput\IOAwareTrait;
use Symfony\Component\Console\Helper\Helper;
/**
* Prints assets as a bulleted list.
*/
final class ListPrinter extends Helper implements PrinterInterface, IOAwareInterface {
use IOAwareTrait;
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'assets_list_printer';
}
/**
* {@inheritdoc}
*/
public function printAssets(AssetCollection $assets, string $base_path = ''): void {
if (\count($assets) === 0) {
return;
}
$this->io()->title('The following directories and files have been created or updated:');
$assets = $assets->getSorted();
$print_asset = static fn (Asset $asset): string => self::formatPath($asset, $base_path);
// Group results by asset type.
$directories = \array_map($print_asset, \iterator_to_array($assets->getDirectories()));
$files = \array_map($print_asset, \iterator_to_array($assets->getFiles()));
$symlinks = \array_map($print_asset, \iterator_to_array($assets->getSymlinks()));
$all_items = \array_merge($directories, $files, $symlinks);
$this->io()->listing($all_items);
}
/**
* Returns formatted path of a given asset.
*/
private static function formatPath(Asset $asset, string $base_path): string {
$path = $asset->getPath();
if (!\str_starts_with($path, '/')) {
$path = $base_path . $path;
}
return $path;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Printer;
use DrupalCodeGenerator\Asset\AssetCollection;
/**
* An interface for asset printers.
*/
interface PrinterInterface {
/**
* Prints summary.
*/
public function printAssets(AssetCollection $assets, string $base_path = ''): void;
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Printer;
use DrupalCodeGenerator\Asset\Asset;
use DrupalCodeGenerator\Asset\AssetCollection;
use DrupalCodeGenerator\InputOutput\IOAwareInterface;
use DrupalCodeGenerator\InputOutput\IOAwareTrait;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Helper\TableStyle;
/**
* Prints assets in tabular form.
*/
final class TablePrinter extends Helper implements PrinterInterface, IOAwareInterface {
use IOAwareTrait;
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'assets_table_printer';
}
/**
* {@inheritdoc}
*/
public function printAssets(AssetCollection $assets, string $base_path = ''): void {
if (\count($assets) === 0) {
return;
}
$this->io()->title('The following directories and files have been created or updated:');
/** @psalm-var non-empty-list<non-empty-list<string>> $headers */
$headers[] = ['Type', 'Path', 'Lines', 'Size'];
$rows = [];
foreach ($assets->getDirectories()->getSorted() as $directory) {
$rows[] = ['directory', $this->formatPath($base_path, $directory), '-', '-'];
}
$total_size = $total_lines = 0;
foreach ($assets->getFiles()->getSorted() as $file) {
/** @var \DrupalCodeGenerator\Asset\File $file */
$size = \mb_strlen($file->getContent());
$total_size += $size;
$lines = $size === 0 ? 0 : \substr_count($file->getContent(), "\n") + 1;
$total_lines += $lines;
$rows[] = ['file', $this->formatPath($base_path, $file), $lines, $size];
}
foreach ($assets->getSymlinks()->getSorted() as $symlink) {
$rows[] = ['symlink', $this->formatPath($base_path, $symlink), '-', '-'];
}
$rows[] = new TableSeparator();
// Summary.
$total_assets = \count($assets);
$rows[] = [
'',
\sprintf('Total: %d %s', $total_assets, $total_assets === 1 ? 'asset' : 'assets'),
$total_lines,
self::formatMemory($total_size),
];
$right_aligned = (new TableStyle())->setPadType(\STR_PAD_LEFT);
$this->io()
->buildTable($headers, $rows)
->setColumnStyle(2, $right_aligned)
->setColumnStyle(3, $right_aligned)
->render();
$this->io()->newLine();
}
/**
* Returns formatted path of a given asset.
*/
protected function formatPath(string $base_path, Asset $asset): string {
$path = $asset->getPath();
if (!\str_starts_with($path, '/')) {
$path = $base_path . $path;
}
return $path;
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
// phpcs:disable SlevomatCodingStandard.Classes.RequireAbstractOrFinal.ClassNeitherAbstractNorFinal
namespace DrupalCodeGenerator\Helper;
use DrupalCodeGenerator\Exception\SilentException;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Helper\QuestionHelper as BaseQuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
/**
* The QuestionHelper class provides helpers to interact with the user.
*
* @todo Move answers queue in a separate helper.
*/
class QuestionHelper extends BaseQuestionHelper {
/**
* Counter to match questions and answers.
*
* @psalm-var int<0, max>
*/
private int $counter = 0;
/**
* {@inheritdoc}
*/
public function ask(InputInterface $input, OutputInterface $output, Question $question): mixed {
// When the generator is started from the Navigation command the input is
// not supplied with 'answer' option.
$answers = $input->hasOption('answer') ? $input->getOption('answer') : [];
if (!\array_key_exists($this->counter, $answers)) {
return parent::ask($input, $output, $question);
}
// -- Simulate interaction.
$answer = $answers[$this->counter++];
if ($output instanceof ConsoleOutputInterface) {
$output = $output->getErrorOutput();
}
$this->writePrompt($output, $question);
$output->write("$answer\n");
$answer ??= $question->getDefault();
if ($validator = $question->getValidator()) {
try {
$answer = $validator($answer);
}
catch (\UnexpectedValueException $exception) {
// The exception is a result of wrong user input. So no need to render
// it in details as Application::renderException() does.
$this->writeError($output, $exception);
throw new SilentException($exception->getMessage(), previous: $exception);
}
}
if ($question->isTrimmable() && \is_string($answer)) {
$answer = \trim($answer);
}
if ($normalizer = $question->getNormalizer()) {
$answer = $normalizer($answer);
}
return $answer;
}
/**
* {@inheritdoc}
*/
final protected function writePrompt(OutputInterface $output, Question $question): void {
// @todo Remove this once the following issue is resolved.
// @see https://github.com/symfony/symfony/issues/39946
$style = new OutputFormatterStyle('white', 'blue', ['bold']);
$output->getFormatter()->setStyle('title', $style);
$question_text = $question->getQuestion();
$default_value = $question->getDefault();
// Navigation command formats questions itself.
// @todo Check if the question is already formatted in a more generic way.
if (!\str_starts_with($question_text, '<title>')) {
$question_text = "\n <info>$question_text</info>";
if ($default_value !== NULL && $default_value !== '') {
if ($question instanceof ConfirmationQuestion) {
// Confirmation question always has boolean default value.
// @see \Symfony\Component\Console\Question\ConfirmationQuestion::__construct()
$default_value = $default_value ? 'Yes' : 'No';
}
$question_text .= " [<comment>$default_value</comment>]:";
}
// Colon and question mark should not show up together.
elseif (!\str_ends_with($question->getQuestion(), '?')) {
$question_text .= ':';
}
}
$output->writeln($question_text);
if ($question instanceof ChoiceQuestion) {
$choices = $question->getChoices();
\assert(\count($choices) > 0);
$max_width = \max(\array_map([self::class, 'width'], \array_keys($choices)));
$messages = [];
foreach ($choices as $key => $value) {
// For numeric keys left padding makes more sense.
$key = \str_pad((string) $key, $max_width, pad_type: \STR_PAD_LEFT);
$messages[] = ' [<info>' . $key . '</info>] ' . $value;
}
$output->writeln($messages);
}
$output->write(' ➤ ');
}
/**
* {@inheritdoc}
*/
final protected function writeError(OutputInterface $output, \Throwable $error): void {
// Add one-space indentation to comply with DCG output style.
$output->writeln(' <error>' . $error->getMessage() . '</error>');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Renderer;
use DrupalCodeGenerator\Asset\RenderableInterface;
/**
* Renderer interface.
*/
interface RendererInterface {
/**
* Renders a template.
*
* Templates with 'twig' extension are processed with Twig template engine.
*/
public function render(string $template, array $vars): string;
/**
* Renders a template string directly.
*/
public function renderInline(string $inline_template, array $vars): string;
/**
* Renders an asset.
*/
public function renderAsset(RenderableInterface $asset): void;
/**
* Registers a path where templates are stored.
*/
public function registerTemplatePath(string $path): void;
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Helper\Renderer;
use DrupalCodeGenerator\Asset\RenderableInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Console\Helper\Helper;
use Twig\Environment as TwigEnvironment;
/**
* Renders assets and templates using Twig template engine.
*/
final class TwigRenderer extends Helper implements RendererInterface, LoggerAwareInterface {
use LoggerAwareTrait;
/**
* Constructs the Renderer object.
*/
public function __construct(private readonly TwigEnvironment $twig) {}
/**
* {@inheritdoc}
*/
public function getName(): string {
return 'renderer';
}
/**
* {@inheritdoc}
*
* @psalm-suppress PossiblyNullReference
*/
public function render(string $template, array $vars): string {
if (\str_ends_with($template, '.twig')) {
$output = $this->twig->render($template, $vars);
$this->logger->debug('Rendered template: {template}', ['template' => $template]);
}
else {
$file_name = $this->twig->resolveTemplate($template)->getSourceContext()->getPath();
$output = \file_get_contents($file_name);
$this->logger->debug('Copied source: {source}', ['source' => $file_name]);
}
return $output;
}
/**
* {@inheritdoc}
*/
public function renderInline(string $inline_template, array $vars): string {
return $this->twig->createTemplate($inline_template)->render($vars);
}
/**
* {@inheritdoc}
*/
public function renderAsset(RenderableInterface $asset): void {
$asset->render($this);
}
/**
* {@inheritdoc}
*/
public function registerTemplatePath(string $path): void {
$loader = $this->twig->getLoader();
/** @var \Twig\Loader\FilesystemLoader $loader */
$loader->prependPath($path);
}
}