devel_wizard-2.x-dev/src/Utils.php
src/Utils.php
<?php
declare(strict_types=1);
namespace Drupal\devel_wizard;
use Drupal\Component\Serialization\Yaml as Yaml;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Config\Entity\ConfigEntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Psr\Log\LogLevel;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Finder\Finder;
use Symfony\Component\String\UnicodeString;
use Symfony\Component\Yaml\Yaml as SymfonyYaml;
class Utils {
/**
* @todo Enum.
*/
public const DRUPAL_PROJECT_TYPES = [
'drush',
'profile',
'module',
'theme',
'engine',
];
/**
* Matches lowercase letters, numbers, underscores.
*/
public const MACHINE_NAME_REGEXP = '/^[a-z][a-z0-9_]*$/';
protected Filesystem $fs;
public static function alwaysFalse(): false {
return FALSE;
}
public function __construct(?Filesystem $fs = NULL) {
$this->fs = $fs ?: new Filesystem();
}
public function selfProjectRootDir(): string {
return dirname(__DIR__);
}
public function getJsonEncodeFlags(): int {
return \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE;
}
public function stringVariants(string $string, string $prefix): array {
return [
"{$prefix}LowerSnake" => (new UnicodeString($string))
->snake()
->lower()
->toString(),
"{$prefix}UpperSnake" => (new UnicodeString($string))
->snake()
->upper()
->toString(),
"{$prefix}LowerDash" => (new UnicodeString($string))
->snake()
->lower()
->replace('_', '-')
->toString(),
"{$prefix}UpperDash" => (new UnicodeString($string))
->snake()
->upper()
->replace('_', '-')
->toString(),
"{$prefix}LowerCamel" => (new UnicodeString($string))
->camel()
->toString(),
"{$prefix}UpperCamel" => (new UnicodeString("a_$string"))
->camel()
->trimPrefix('a')
->toString(),
];
}
public function logLevelByExpiryDate(
\DateTimeInterface $expiry_date,
int $warning_days = 180,
int $error_day = 90,
): string {
$now = new \DateTimeImmutable();
$diff = $expiry_date->diff($now);
if ($diff->invert === 0 || $diff->days < $error_day) {
// Already in the past or in the near future.
return LogLevel::ERROR;
}
if ($diff->invert === 1 && $diff->days < $warning_days) {
// In the future but not far enough.
return LogLevel::WARNING;
}
return LogLevel::INFO;
}
public function formatByExpiryDate(
string $text,
\DateTimeInterface $expiry_date,
int $warning_days = 180,
int $error_day = 90
): string {
return $this->formatByLogLevel(
$text,
$this->logLevelByExpiryDate($expiry_date, $warning_days, $error_day),
);
}
public function formatByLogLevel(
string $text,
string $log_level,
): string {
if (php_sapi_name() !== 'cli') {
return $text;
}
return match($log_level) {
'emergency',
'alert',
'critical',
'error' => "<fg=red>$text</>",
'warning' => "<fg=yellow>$text</>",
default => $text,
};
}
/**
* @link https://www.php.net/supported-versions.php
*/
public function phpVersions(): array {
return [
'8.3' => [
'version' => '8.3',
'release' => new \DateTimeImmutable('2023-11-23'),
'support' => new \DateTimeImmutable('2025-12-31'),
'security' => new \DateTimeImmutable('2027-12-31'),
'features' => [
'typed class constants',
],
],
'8.2' => [
'version' => '8.2',
'release' => new \DateTimeImmutable('2022-12-08'),
'support' => new \DateTimeImmutable('2024-12-08'),
'security' => new \DateTimeImmutable('2025-12-08'),
'features' => [
'"readonly" classes',
'"true" type',
'standalone "null" and "false"',
],
],
'8.1' => [
'version' => '8.1',
'release' => new \DateTimeImmutable('2021-11-25'),
'support' => new \DateTimeImmutable('2023-11-25'),
'security' => new \DateTimeImmutable('2024-11-25'),
'features' => [
'enumerations',
'"never" return type',
'intersection types',
'"readonly" properties',
],
],
'8.0' => [
'version' => '8.0',
'release' => new \DateTimeImmutable('2020-11-30'),
'support' => new \DateTimeImmutable('2022-11-30'),
'security' => new \DateTimeImmutable('2023-11-30'),
'features' => [
'union types',
'named arguments',
'attributes',
'match expression',
],
],
'7.4' => [
'version' => '7.4',
'release' => new \DateTimeImmutable('2019-11-28'),
'support' => new \DateTimeImmutable('2021-11-28'),
'security' => new \DateTimeImmutable('2022-11-28'),
'features' => [
'typed properties',
'short closures',
'null coalescing operator',
],
],
'7.3' => [
'version' => '7.3',
'release' => new \DateTimeImmutable('2018-12-06'),
'support' => new \DateTimeImmutable('2020-12-06'),
'security' => new \DateTimeImmutable('2021-12-06'),
'features' => [
'references in list assignments',
'flexible heredocs',
],
],
];
}
public function phpVersionChoices(
?array $php_versions = NULL,
string $pattern = '{version} - active: {support}; security: {security}'
): array {
if ($php_versions === NULL) {
$php_versions = $this->phpVersions();
}
$choices = [];
$date_formatter = \Drupal::getContainer()->get('date.formatter');
foreach ($php_versions as $php) {
$args = [];
foreach ($php as $key => $value) {
switch (gettype($value)) {
case 'array':
$args["{{$key}}"] = implode(', ', $value);
break;
case 'object':
if ($value instanceof \DateTimeInterface) {
$date = $date_formatter->format($value->getTimestamp(), 'html_date');
$args["{{$key}}"] = $key === 'release' ? $date : Utils::formatByExpiryDate($date, $value);
}
break;
default:
$args["{{$key}}"] = $value;
break;
}
}
$choices[$php['version']] = strtr($pattern, $args);
}
return $choices;
}
/**
* @return \Symfony\Component\Console\Question\Question[]
*/
public function questionsPhpMinimum(): array {
$questions = [];
$questions['php_minimum'] = (new ChoiceQuestion('Minimum PHP version', Utils::phpVersionChoices(), '7.4'))
->setValidator([$this, 'validateRequired']);
return $questions;
}
/**
* @return string[]
*/
public function projectDestinationChoices(
string $type,
string $rootProjectDir,
string $drupalRootDir
): array {
assert(
in_array($type, static::DRUPAL_PROJECT_TYPES),
"invalid project type: '$type'",
);
$choices = [];
$vendorDrupalDir = "$rootProjectDir/../../drupal";
$fs = new Filesystem();
if ($fs->exists($vendorDrupalDir)) {
$choices[] = $fs->makePathRelative($vendorDrupalDir, $drupalRootDir);
}
switch ($type) {
case 'profile':
case 'module':
case 'theme':
$choices[] = $fs->makePathRelative("$drupalRootDir/{$type}s/custom", $drupalRootDir);
break;
case 'theme_engine':
$choices[] = $fs->makePathRelative("$drupalRootDir/themes/custom", $drupalRootDir);
break;
}
return $choices;
}
/**
* @return array<string, string>
*/
public function gitTemplateChoices(string $home): array {
return ['' => '- Default - '] + $this->dirsToOptions($this->getAvailableGitTemplateDirs($home));
}
/**
* @return iterable<string, \Symfony\Component\Finder\SplFileInfo>
*/
public function getAvailableGitTemplateDirs(string $home): iterable {
return (new Finder())
->in("$home/Templates/Git/")
->directories()
->depth(0);
}
/**
* @param iterable<string, \Symfony\Component\Finder\SplFileInfo> $dirs
*
* @return array<string, string>
*/
public function dirsToOptions(iterable $dirs): array {
$options = [];
foreach ($dirs as $dir) {
$options[$dir->getPathname()] = $dir->getBasename();
}
return $options;
}
public function getStackedValidator(callable ...$validators): callable {
return function ($input) use ($validators) {
foreach ($validators as $validator) {
$input = $validator($input);
}
return $input;
};
}
public function getConfigEntityIdExistsValidator(string $message, ConfigEntityStorageInterface $storage, bool $hasToBeExists = FALSE): callable {
return function ($value) use ($message, $storage, $hasToBeExists) {
if ($value === NULL || $value === '') {
return $value;
}
$msgText = '';
$entity = $storage->load($value);
if ($entity && !$hasToBeExists) {
$msgText = '@message: @entity_type_label %machine_name already exists';
}
elseif (!$entity && $hasToBeExists) {
$msgText = '@message: @entity_type_label %machine_name not exists';
}
if ($msgText) {
$msgArgs = [
'@message' => $message,
'@entity_type_label' => $storage->getEntityType()->getLabel(),
'%machine_name' => $value,
];
throw new \InvalidArgumentException(strtr($msgText, $msgArgs), 1);
}
return $value;
};
}
public function getRequiredValidator(string $message): callable {
return function ($input) use ($message) {
// FALSE is not considered as empty value because question helper uses
// it as negative answer on confirmation questions.
if ($input === NULL || $input === '') {
throw new \UnexpectedValueException($message);
}
return $input;
};
}
public function getRegexpValidator(string $message, string $pattern, bool $invert = FALSE): callable {
return function ($input) use ($message, $pattern, $invert) {
if ($input === NULL || $input === '') {
return $input;
}
$valid = (bool) preg_match($pattern, $input);
$valid = $invert ? !$valid : $valid;
if ($valid) {
return $input;
}
throw new \InvalidArgumentException(
strtr(
'@message; pattern @pattern does not match to input: @input',
[
'@message' => $message,
'@pattern' => $pattern,
'@input' => $input,
],
),
1,
);
};
}
/**
* @param \Drupal\Core\Config\Entity\ConfigEntityInterface[] $entities
*
* @return string[]
*/
public function configEntityChoices(iterable $entities): array {
$choices = [];
foreach ($entities as $entity) {
$choices[$entity->id()] = $entity->label();
}
return $choices;
}
/**
* @param \Drupal\views\Entity\View[] $views
* @param string $path
*/
public function findViewDisplayByPath(iterable $views, string $path): ?array {
foreach ($views as $view) {
foreach ($view->get('display') as $displayId => $display) {
$actual = $display['display_options']['path'] ?? NULL;
if ($actual === $path) {
return [
'view' => $view,
'display_id' => $displayId,
];
}
}
}
return NULL;
}
public function openFileUrl(string $fileName, int $line = 0, int $column = 0, string $basePath = '') {
if ($basePath === '') {
$basePath = getcwd();
}
return strtr(
ini_get('xdebug.file_link_format') ?: 'phpstorm://open?file=%f&line=%l&column=%c',
[
'%f' => urlencode(Path::makeAbsolute($fileName, $basePath)),
'%l' => $line,
'%c' => $column,
],
);
}
public function className(string $fqn): string {
return preg_replace('@^.*\\\\@', '', $fqn);
}
/**
* @param string $namespace
* Fully qualified name.
*
* @return string
* Relative path from the MODULE/src directory.
*/
public function relativeNamespaceDir(string $namespace): string {
$namespaceParts = array_slice(explode('\\', $namespace), 2);
return Path::join(...$namespaceParts) ?: '.';
}
/**
* @param string $classFqn
* Class FQN "Drupal\my_module\Foo\Bar\Something".
*
* @return string
* File path "src/Foo/Bar/Something.php".
*
* @code
* \Symfony\Component\Filesystem\Path::join(
* $moduleDir,
* $utils->classFqnToFilePath($classFqn),
* )
* @endcode
*/
public function classFqnToFilePath(string $classFqn): string {
return Path::join(
'src',
$this->relativeNamespaceDir($classFqn) . '.php',
);
}
public function addUseStatements(array $useStatements, string $fileContent): string {
$afterThis = "declare(strict_types=1);\n";
$pos = strpos($fileContent, $afterThis);
if ($pos !== FALSE) {
$pos += mb_strlen($afterThis);
}
else {
$pos = strpos($fileContent, "\nuse ");
if ($pos !== FALSE) {
$pos -= 5;
}
}
if ($pos === FALSE) {
// @todo Better detection.
return $fileContent;
}
// @todo Sort.
foreach (array_reverse($useStatements) as $useStatement) {
if (strpos($fileContent, $useStatement) !== FALSE) {
continue;
}
$fileContent = substr_replace(
$fileContent,
"$useStatement\n",
$pos,
0,
);
}
return $fileContent;
}
public function inputName(string|array ...$parts): string {
$parents = [];
foreach ($parts as $part) {
if (is_string($part)) {
$parents[] = $part;
continue;
}
$parents = array_merge($parents, $part);
}
$name = array_shift($parents);
if ($parents) {
$name .= '[' . implode('][', $parents) . ']';
}
return $name;
}
public function detectExtensionMachineName(string $dir): ?string {
$fs = new Filesystem();
$filename = Path::join($dir, 'composer.json');
$project_type = 'module';
if ($fs->exists($filename)) {
$composer = \json_decode(
file_get_contents($filename) ?: '{}',
TRUE,
);
if (isset($composer['type'])) {
$project_type = preg_replace('/^drupal-/', '', $composer['type']);
}
$project_name = $composer['name'] ?? '';
if (preg_match('@^drupal/@', $project_name) === 1) {
return str_replace(
'-',
'_',
preg_replace('@^drupal/@', '', $project_name),
);
}
if ($project_type === 'project') {
return 'app';
}
}
$info_files = (new Finder())
->in($dir)
->files()
->name('*.info.yml')
->depth(0)
->getIterator();
$info_files->rewind();
/** @var \Symfony\Component\Finder\SplFileInfo $info_file */
$info_file = $info_files->current();
return $info_file?->getBasename('.info.yml');
}
public function initDrushInProject(string $projectRoot) {
$composerJsonFileName = Path::join($projectRoot, 'composer.json');
$composerJson = json_decode(
file_get_contents($composerJsonFileName) ?: '{}',
TRUE,
);
if (!empty($composerJson['extra']['drush'])) {
return;
}
$composerJson['extra']['drush'] = [
'services' => [
'drush.services.yml' => '^11 || ^12',
],
];
$this->fs->dumpFile(
$composerJsonFileName,
json_encode($composerJson, $this->getJsonEncodeFlags()),
);
}
/**
* Replaces elements in the $fileName YAML file.
*/
public function ymlFileReplace(string $filePath, array $entries, array $parents = []): array {
$report = [
'@num_of_added' => 0,
'@num_of_updated' => 0,
];
if (!$entries) {
return $report;
}
// @todo Comment friendly way.
$content = $this->fs->exists($filePath) ? file_get_contents($filePath) : '{}';
$root = Yaml::decode($content);
if (!NestedArray::keyExists($root, $parents)) {
NestedArray::setValue($root, $parents, []);
}
$data =& NestedArray::getValue($root, $parents);
$countBefore = count($data);
foreach ($entries as $entryId => $entry) {
$data[$entryId] = $entry;
}
$this->fs->mkdir(Path::getDirectory($filePath));
$this->fs->dumpFile($filePath, SymfonyYaml::dump($root, 999, 2));
$report['@num_of_added'] = count($data) - $countBefore;
$report['@num_of_updated'] = count($entries) - $report['@num_of_added'];
return $report;
}
public function getEntityTypeInfo(EntityTypeInterface $entityType): array {
$idUpperCamel = (new UnicodeString('a_' . $entityType->id()))
->camel()
->trimPrefix('a')
->toString();
$info = [
'id' => $entityType->id(),
'idUpperCamel' => $idUpperCamel,
'definition' => $entityType,
'interface' => NULL,
'interfaceFqn' => NULL,
];
$class = $entityType->getClass();
$implements = class_implements($class);
foreach ($implements as $interfaceFqn) {
if (str_ends_with($interfaceFqn, "\\{$idUpperCamel}Interface")) {
$info['interface'] = "{$idUpperCamel}Interface";
$info['interfaceFqn'] = $interfaceFqn;
}
}
return $info;
}
}
