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,175 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset;
use DrupalCodeGenerator\Asset\Resolver\PreserveResolver;
use DrupalCodeGenerator\Asset\Resolver\ReplaceResolver;
use DrupalCodeGenerator\Asset\Resolver\ResolverDefinition;
use DrupalCodeGenerator\Asset\Resolver\ResolverInterface;
use DrupalCodeGenerator\InputOutput\IO;
use DrupalCodeGenerator\Utils;
/**
* Base class for assets.
*/
abstract class Asset implements \Stringable {
/**
* Indicates that the asset can be updated but never created.
*/
private bool $virtual = FALSE;
/**
* Asset mode.
*
* @psalm-var int<0, 511>
*/
private int $mode = 0444;
/**
* Template variables.
*
* @psalm-var array<string, mixed>
*/
private array $vars = [];
/**
* Content resolver.
*/
protected ?ResolverInterface $resolver = NULL;
/**
* Resolver definition.
*/
protected ResolverDefinition $resolverDefinition;
/**
* Asset constructor.
*/
public function __construct(protected readonly string $path) {
// @todo Test this.
match (TRUE) {
$this instanceof Directory,
$this instanceof File,
$this instanceof Symlink => NULL,
default => throw new \LogicException(\sprintf('%s class is internal for extension.', self::class)),
};
$this->resolverDefinition = new ResolverDefinition(ReplaceResolver::class);
}
/**
* Getter for the asset path.
*/
final public function getPath(): string {
return $this->replaceTokens($this->path);
}
/**
* Getter for the asset mode.
*
* @psalm-return int<0, 511>
*/
final public function getMode(): int {
return $this->mode;
}
/**
* Getter for the asset vars.
*
* @psalm-return array<string, mixed>
*/
final public function getVars(): array {
return $this->vars;
}
/**
* Checks if the asset is virtual.
*
* Virtual assets should not cause creating new directories, files or symlinks
* on file system. They meant to be used by resolvers to update existing
* objects.
*/
final public function isVirtual(): bool {
return $this->virtual;
}
/**
* Returns the asset resolver.
*/
public function getResolver(IO $io): ResolverInterface {
return $this->resolver ?? $this->resolverDefinition->createResolver($io);
}
/**
* Setter for asset mode.
*
* @psalm-param int<0, 511> $mode
*/
final public function mode(int $mode): static {
/** @psalm-suppress DocblockTypeContradiction */
if ($mode < 0000 || $mode > 0777) {
throw new \InvalidArgumentException('Incorrect mode value.');
}
$this->mode = $mode;
return $this;
}
/**
* Setter for the asset vars.
*
* @psalm-param array<string, mixed> $vars
*/
final public function vars(array $vars): static {
$this->vars = $vars;
return $this;
}
/**
* Makes the asset "virtual".
*/
final public function setVirtual(bool $virtual): static {
$this->virtual = $virtual;
return $this;
}
/**
* Indicates that existing asset should be replaced.
*/
final public function replaceIfExists(): static {
$this->resolverDefinition = new ResolverDefinition(ReplaceResolver::class);
return $this;
}
/**
* Indicates that existing asset should be preserved.
*/
final public function preserveIfExists(): static {
$this->resolverDefinition = new ResolverDefinition(PreserveResolver::class);
return $this;
}
/**
* Setter for asset resolver.
*/
final public function resolver(ResolverInterface $resolver): static {
$this->resolver = $resolver;
return $this;
}
/**
* Implements the magic __toString() method.
*/
final public function __toString(): string {
return $this->getPath();
}
/**
* Replaces all tokens in a given string with appropriate values.
*/
final protected function replaceTokens(string $input): string {
return Utils::replaceTokens($input, $this->vars);
}
}

View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset;
/**
* Asset collection.
*
* @template-implements \ArrayAccess<string,\DrupalCodeGenerator\Asset\Asset>
* @template-implements \IteratorAggregate<string,\DrupalCodeGenerator\Asset\Asset>
*/
final class AssetCollection implements \ArrayAccess, \IteratorAggregate, \Countable, \Stringable {
/**
* AssetCollection constructor.
*
* @param \DrupalCodeGenerator\Asset\Asset[] $assets
* Assets.
*/
public function __construct(private array $assets = []) {}
/**
* Creates a directory asset.
*/
public function addDirectory(string $path): Directory {
$directory = new Directory($path);
$this->assets[] = $directory;
return $directory;
}
/**
* Creates a file asset.
*/
public function addFile(string $path, ?string $template = NULL): File {
$file = new File($path);
if ($template) {
$file->template($template);
}
$this->assets[] = $file;
return $file;
}
/**
* Creates a symlink asset.
*
* @noinspection PhpUnused
*/
public function addSymlink(string $path, string $target): Symlink {
$symlink = new Symlink($path, $target);
$this->assets[] = $symlink;
return $symlink;
}
/**
* Adds an asset for configuration schema file.
*/
public function addSchemaFile(string $path = 'config/schema/{machine_name}.schema.yml'): File {
return $this->addFile($path)
->appendIfExists();
}
/**
* Adds an asset for service file.
*/
public function addServicesFile(string $path = '{machine_name}.services.yml'): File {
return $this->addFile($path)
->appendIfExists(1);
}
/**
* {@inheritdoc}
*
* @psalm-param \DrupalCodeGenerator\Asset\Asset $value
*/
public function offsetSet(mixed $offset, mixed $value): void {
match (TRUE) {
$value instanceof Directory,
$value instanceof File,
$value instanceof Symlink => NULL,
default => throw new \InvalidArgumentException('Unsupported asset type.'),
};
if ($offset === NULL) {
$this->assets[] = $value;
}
else {
$this->assets[$offset] = $value;
}
}
/**
* {@inheritdoc}
*/
public function offsetGet(mixed $offset): ?Asset {
return $this->assets[$offset] ?? NULL;
}
/**
* {@inheritdoc}
*/
public function offsetUnset(mixed $offset): void {
unset($this->assets[$offset]);
}
/**
* {@inheritdoc}
*/
public function offsetExists(mixed $offset): bool {
return isset($this->assets[$offset]);
}
/**
* {@inheritdoc}
*/
public function getIterator(): \ArrayIterator {
return new \ArrayIterator($this->assets);
}
/**
* {@inheritdoc}
*
* @psalm-return int<0, max>
*/
public function count(): int {
return \count($this->assets);
}
/**
* Returns a collection of directory assets.
*/
public function getDirectories(): self {
return $this->getFiltered(
static fn (Asset $asset): bool => $asset instanceof Directory,
);
}
/**
* Returns a collection of file assets.
*/
public function getFiles(): self {
return $this->getFiltered(
static fn (Asset $asset): bool => $asset instanceof File,
);
}
/**
* Returns a collection of symlink assets.
*/
public function getSymlinks(): self {
return $this->getFiltered(
static fn (Asset $asset): bool => $asset instanceof Symlink,
);
}
/**
* Returns a collection of sorted assets.
*/
public function getSorted(): self {
$sorter = static function (Asset $a, Asset $b): int {
$name_a = (string) $a;
$name_b = (string) $b;
// Top level assets should go first.
$result = \strcasecmp(\dirname($name_a), \dirname($name_b));
if ($result === 0) {
$result = \strcasecmp($name_a, $name_b);
}
return $result;
};
$assets = $this->assets;
\usort($assets, $sorter);
return new self($assets);
}
/**
* Filters the asset collection.
*/
public function getFiltered(callable $filter): self {
$iterator = new \CallbackFilterIterator($this->getIterator(), $filter);
$assets = \iterator_to_array($iterator);
$str_keys = \array_filter(\array_keys($assets), 'is_string');
// Reindex if it's not an associative array.
return new self(\count($str_keys) > 0 ? $assets : \array_values($assets));
}
/**
* {@inheritdoc}
*/
public function __toString(): string {
$output = '';
foreach ($this->getSorted() as $asset) {
$output .= '• ' . $asset . \PHP_EOL;
}
return $output;
}
}

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset;
// @todo Is it still needed?
\class_alias(AssetCollection::class, '\DrupalCodeGenerator\Asset\Assets');

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset;
use DrupalCodeGenerator\Asset\Resolver\PreserveResolver;
use DrupalCodeGenerator\Asset\Resolver\ResolverDefinition;
/**
* Simple data structure to represent a directory being created.
*/
final class Directory extends Asset {
/**
* {@inheritdoc}
*/
public function __construct(string $path) {
parent::__construct($path);
$this->mode(0755);
// Recreating existing directories makes no sense.
$this->resolverDefinition = new ResolverDefinition(PreserveResolver::class);
}
/**
* Named constructor.
*/
public static function create(string $path): self {
return new self($path);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset;
use DrupalCodeGenerator\Asset\Resolver\AppendResolver;
use DrupalCodeGenerator\Asset\Resolver\PrependResolver;
use DrupalCodeGenerator\Asset\Resolver\ResolverDefinition;
use DrupalCodeGenerator\Helper\Renderer\RendererInterface;
/**
* A data structure to represent a file being generated.
*/
final class File extends Asset implements RenderableInterface {
/**
* Asset content.
*/
private string $content = '';
/**
* Template to render main content.
*/
private ?string $template = NULL;
/**
* The template string to render.
*/
private ?string $inlineTemplate = NULL;
/**
* {@inheritdoc}
*/
public function __construct(string $path) {
parent::__construct($path);
$this->mode(0644);
}
/**
* Named constructor.
*/
public static function create(string $path): self {
return new self($path);
}
/**
* Returns the asset content.
*/
public function getContent(): string {
return $this->content;
}
/**
* Sets the asset content.
*/
public function content(string $content): self {
$this->content = $content;
return $this;
}
/**
* Sets the asset template.
*
* Templates with 'twig' extension are processed with Twig template engine.
*/
public function template(string $template): self {
if ($this->inlineTemplate) {
throw new \LogicException('A file cannot have both inline and regular templates.');
}
$this->template = $template;
return $this;
}
/**
* Returns the asset inline template.
*/
public function inlineTemplate(string $inline_template): self {
if ($this->template) {
throw new \LogicException('A file cannot have both inline and regular templates.');
}
$this->inlineTemplate = $inline_template;
return $this;
}
/**
* Sets the "prepend" resolver.
*/
public function prependIfExists(): self {
$this->resolverDefinition = new ResolverDefinition(PrependResolver::class);
return $this;
}
/**
* Sets the "append" resolver.
*
* @psalm-param int<0, max> $header_size
*/
public function appendIfExists(int $header_size = 0): self {
$this->resolverDefinition = new ResolverDefinition(AppendResolver::class, $header_size);
return $this;
}
/**
* {@inheritdoc}
*/
public function render(RendererInterface $renderer): void {
if ($this->inlineTemplate) {
$content = $renderer->renderInline($this->inlineTemplate, $this->getVars());
$this->content($content);
}
elseif ($this->template) {
$template = $this->replaceTokens($this->template);
$content = $renderer->render($template, $this->getVars());
$this->content($content);
}
// It's OK that the file has no templates as consumers may set rendered
// content directly through `content()` method.
}
/**
* Checks if the asset is a PHP script.
*/
public function isPhp(): bool {
return \in_array(
\pathinfo($this->getPath(), \PATHINFO_EXTENSION),
['php', 'module', 'install', 'inc', 'theme'],
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset;
use DrupalCodeGenerator\Helper\Renderer\RendererInterface;
/**
* An interface for renderable assets.
*/
interface RenderableInterface {
/**
* Renders the asset.
*/
public function render(RendererInterface $renderer): void;
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset\Resolver;
use DrupalCodeGenerator\Asset\Asset;
use DrupalCodeGenerator\Asset\File;
use DrupalCodeGenerator\InputOutput\IO;
final class AppendResolver implements ResolverInterface, ResolverFactoryInterface {
/**
* Constructs the object.
*
* @psalm-param int<0, max> $headerSize
*/
public function __construct(private readonly int $headerSize = 0) {
/** @psalm-suppress DocblockTypeContradiction */
if ($headerSize < 0) {
throw new \InvalidArgumentException('Header size must be greater than or equal to 0.');
}
}
/**
* {@inheritdoc}
*/
public static function createResolver(IO $io, mixed $options): self {
return new self($options);
}
/**
* {@inheritdoc}
*/
public function resolve(Asset $asset, string $path): File {
if (!$asset instanceof File) {
throw new \InvalidArgumentException('Wrong asset type.');
}
$new_content = $asset->getContent();
// Remove header from existing content.
if ($this->headerSize > 0) {
$new_content = \implode("\n", \array_slice(\explode("\n", $new_content), $this->headerSize));
}
$existing_content = \file_get_contents($path);
return clone $asset->content($existing_content . "\n" . $new_content);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset\Resolver;
use DrupalCodeGenerator\Asset\Asset;
use DrupalCodeGenerator\Asset\File;
final class PrependResolver implements ResolverInterface {
/**
* {@inheritdoc}
*/
public function resolve(Asset $asset, string $path): File {
if (!$asset instanceof File) {
throw new \InvalidArgumentException('Wrong asset type.');
}
$new_content = $asset->getContent();
$existing_content = \file_get_contents($path);
return clone $asset->content($new_content . "\n" . $existing_content);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset\Resolver;
use DrupalCodeGenerator\Asset\Asset;
final class PreserveResolver implements ResolverInterface {
/**
* {@inheritdoc}
*
* @psalm-return null
*/
public function resolve(Asset $asset, string $path): ?Asset {
return NULL;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset\Resolver;
use DrupalCodeGenerator\Asset\Asset;
use DrupalCodeGenerator\Asset\File;
use DrupalCodeGenerator\Asset\Symlink;
use DrupalCodeGenerator\InputOutput\IO;
final class ReplaceResolver implements ResolverInterface, ResolverFactoryInterface {
/**
* Constructs the object.
*/
public function __construct(private readonly IO $io) {}
/**
* {@inheritdoc}
*/
public static function createResolver(IO $io, mixed $options): self {
return new self($io);
}
/**
* {@inheritdoc}
*/
public function resolve(Asset $asset, string $path): NULL|File|Symlink {
if (!$asset instanceof File && !$asset instanceof Symlink) {
throw new \InvalidArgumentException('Wrong asset type.');
}
$replace = $this->io->getInput()->getOption('replace') ||
$this->io->getInput()->getOption('dry-run') ||
$this->io->confirm("The file <comment>$path</comment> already exists. Would you like to replace it?");
return $replace ? clone $asset : NULL;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset\Resolver;
use DrupalCodeGenerator\InputOutput\IO;
final class ResolverDefinition {
/**
* Constructs the object.
*
* @psalm-param class-string<\DrupalCodeGenerator\Asset\Resolver\ResolverInterface> $className
*/
public function __construct(
public readonly string $className,
public readonly mixed $options = NULL,
) {}
/**
* Creates asset resolver.
*/
public function createResolver(IO $io): ResolverInterface {
if (\is_subclass_of($this->className, ResolverFactoryInterface::class)) {
$resolver = $this->className::createResolver($io, $this->options);
}
else {
$resolver = new $this->className();
}
return $resolver;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset\Resolver;
use DrupalCodeGenerator\InputOutput\IO;
/**
* Interface for classes capable of creating resolvers.
*/
interface ResolverFactoryInterface {
/**
* Creates a resolver.
*/
public static function createResolver(IO $io, mixed $options): ResolverInterface;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset\Resolver;
use DrupalCodeGenerator\Asset\Asset;
/**
* Interface resolver.
*
* A resolver is called when the asset with the same path already exists in the
* file system. The purpose of the resolver is to merge the existing asset with
* the one provided by a generator.
*/
interface ResolverInterface {
/**
* Resolves an asset.
*
* Returns the resolved asset or NULL if existing asset is up-to-date.
*
* @throw \InvalidArgumentException
*/
public function resolve(Asset $asset, string $path): ?Asset;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DrupalCodeGenerator\Asset;
/**
* Simple data structure to represent a symlink being generated.
*/
final class Symlink extends Asset {
/**
* Symlink target.
*/
private readonly string $target;
/**
* {@inheritdoc}
*/
public function __construct(string $path, string $target) {
parent::__construct($path);
$this->target = $target;
$this->mode(0644);
}
/**
* Named constructor.
*/
public static function create(string $path, string $target): self {
return new self($path, $target);
}
/**
* Getter for symlink target.
*/
public function getTarget(): string {
return $this->replaceTokens($this->target);
}
}