mirror of
https://github.com/Respect/Validation.git
synced 2026-03-15 14:55:44 +01:00
When we change the contract of a validator, or create a new one, we need to ensure that the mixin for the validator is present and matches the validator's constructor. This commit changes the current class that generates those mixin classes, converting it into a linter so we can run it in the GitHub workflow to check for missing changes.
412 lines
13 KiB
PHP
412 lines
13 KiB
PHP
<?php
|
|
|
|
/*
|
|
* SPDX-License-Identifier: MIT
|
|
* SPDX-FileCopyrightText: (c) Respect Project Contributors
|
|
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Respect\Dev\Commands;
|
|
|
|
use DirectoryIterator;
|
|
use Nette\PhpGenerator\InterfaceType;
|
|
use Nette\PhpGenerator\Method;
|
|
use Nette\PhpGenerator\PhpNamespace;
|
|
use Nette\PhpGenerator\Printer;
|
|
use ReflectionClass;
|
|
use ReflectionNamedType;
|
|
use ReflectionParameter;
|
|
use ReflectionUnionType;
|
|
use Respect\Dev\Differ\ConsoleDiffer;
|
|
use Respect\Dev\Differ\Item;
|
|
use Respect\Validation\Mixins\AllBuilder;
|
|
use Respect\Validation\Mixins\AllChain;
|
|
use Respect\Validation\Mixins\Chain;
|
|
use Respect\Validation\Mixins\KeyBuilder;
|
|
use Respect\Validation\Mixins\KeyChain;
|
|
use Respect\Validation\Mixins\LengthBuilder;
|
|
use Respect\Validation\Mixins\LengthChain;
|
|
use Respect\Validation\Mixins\MaxBuilder;
|
|
use Respect\Validation\Mixins\MaxChain;
|
|
use Respect\Validation\Mixins\MinBuilder;
|
|
use Respect\Validation\Mixins\MinChain;
|
|
use Respect\Validation\Mixins\NotBuilder;
|
|
use Respect\Validation\Mixins\NotChain;
|
|
use Respect\Validation\Mixins\NullOrBuilder;
|
|
use Respect\Validation\Mixins\NullOrChain;
|
|
use Respect\Validation\Mixins\PropertyBuilder;
|
|
use Respect\Validation\Mixins\PropertyChain;
|
|
use Respect\Validation\Mixins\UndefOrBuilder;
|
|
use Respect\Validation\Mixins\UndefOrChain;
|
|
use Respect\Validation\Validator;
|
|
use Respect\Validation\ValidatorBuilder;
|
|
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 function array_keys;
|
|
use function array_merge;
|
|
use function array_values;
|
|
use function count;
|
|
use function dirname;
|
|
use function file_get_contents;
|
|
use function file_put_contents;
|
|
use function implode;
|
|
use function in_array;
|
|
use function is_object;
|
|
use function ksort;
|
|
use function lcfirst;
|
|
use function preg_match;
|
|
use function preg_replace;
|
|
use function sprintf;
|
|
use function str_contains;
|
|
use function str_starts_with;
|
|
use function trim;
|
|
use function ucfirst;
|
|
|
|
use const PHP_EOL;
|
|
|
|
#[AsCommand(
|
|
name: 'lint:mixin',
|
|
description: 'Apply linters to the generated mixin interfaces',
|
|
)]
|
|
final class LintMixinCommand extends Command
|
|
{
|
|
private const array NUMBER_RELATED_VALIDATORS = [
|
|
'Between',
|
|
'BetweenExclusive',
|
|
'Equals',
|
|
'Equivalent',
|
|
'Even',
|
|
'Factor',
|
|
'Fibonacci',
|
|
'Finite',
|
|
'GreaterThan',
|
|
'Identical',
|
|
'In',
|
|
'Infinite',
|
|
'LessThan',
|
|
'LessThanOrEqual',
|
|
'GreaterThanOrEqual',
|
|
'Multiple',
|
|
'Odd',
|
|
'PerfectSquare',
|
|
'Positive',
|
|
'PrimeNumber',
|
|
];
|
|
|
|
private const array STRUCTURE_RELATED_VALIDATORS = [
|
|
'Exists',
|
|
'Key',
|
|
'KeyExists',
|
|
'KeyOptional',
|
|
'KeySet',
|
|
'NullOr',
|
|
'UndefOr',
|
|
'Property',
|
|
'PropertyExists',
|
|
'PropertyOptional',
|
|
'Attributes',
|
|
'Templated',
|
|
'Named',
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly ConsoleDiffer $differ,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->addOption(
|
|
'fix',
|
|
null,
|
|
null,
|
|
'Automatically fix files with issues.',
|
|
);
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
{
|
|
// Scan validators directory
|
|
$srcDir = dirname(__DIR__, 2) . '/src';
|
|
$validatorsDir = $srcDir . '/Validators';
|
|
$validators = $this->scanValidators($validatorsDir);
|
|
|
|
// Define mixins
|
|
$mixins = [
|
|
['All', 'all', [], array_merge(['All'], self::STRUCTURE_RELATED_VALIDATORS)],
|
|
['Key', 'key', [], self::STRUCTURE_RELATED_VALIDATORS],
|
|
['Length', 'length', self::NUMBER_RELATED_VALIDATORS, []],
|
|
['Max', 'max', self::NUMBER_RELATED_VALIDATORS, []],
|
|
['Min', 'min', self::NUMBER_RELATED_VALIDATORS, []],
|
|
['Not', 'not', [], ['Not', 'NullOr', 'UndefOr', 'Attributes', 'Templated', 'Named']],
|
|
['NullOr', 'nullOr', [], ['NullOr', 'Blank', 'Undef', 'UndefOr', 'Templated', 'Named']],
|
|
['Property', 'property', [], self::STRUCTURE_RELATED_VALIDATORS],
|
|
['UndefOr', 'undefOr', [], ['NullOr', 'Blank', 'Undef', 'UndefOr', 'Attributes', 'Templated', 'Named']],
|
|
[null, null, [], []],
|
|
];
|
|
|
|
$updatableFiles = [];
|
|
|
|
foreach ($mixins as [$name, $prefix, $allowList, $denyList]) {
|
|
$chainedNamespace = new PhpNamespace('Respect\\Validation\\Mixins');
|
|
$chainedInterface = $chainedNamespace->addInterface($name . 'Chain');
|
|
|
|
$staticNamespace = new PhpNamespace('Respect\\Validation\\Mixins');
|
|
$staticInterface = $staticNamespace->addInterface($name . 'Builder');
|
|
|
|
if ($name === null) {
|
|
$chainedInterface->addExtend(Validator::class);
|
|
$chainedInterface->addExtend(AllChain::class);
|
|
$chainedInterface->addExtend(KeyChain::class);
|
|
$chainedInterface->addExtend(LengthChain::class);
|
|
$chainedInterface->addExtend(MaxChain::class);
|
|
$chainedInterface->addExtend(MinChain::class);
|
|
$chainedInterface->addExtend(NotChain::class);
|
|
$chainedInterface->addExtend(NullOrChain::class);
|
|
$chainedInterface->addExtend(PropertyChain::class);
|
|
$chainedInterface->addExtend(UndefOrChain::class);
|
|
$chainedInterface->addComment('@mixin ValidatorBuilder');
|
|
$chainedNamespace->addUse(ValidatorBuilder::class);
|
|
|
|
$staticInterface->addExtend(AllBuilder::class);
|
|
$staticInterface->addExtend(KeyBuilder::class);
|
|
$staticInterface->addExtend(LengthBuilder::class);
|
|
$staticInterface->addExtend(MaxBuilder::class);
|
|
$staticInterface->addExtend(MinBuilder::class);
|
|
$staticInterface->addExtend(NotBuilder::class);
|
|
$staticInterface->addExtend(NullOrBuilder::class);
|
|
$staticInterface->addExtend(PropertyBuilder::class);
|
|
$staticInterface->addExtend(UndefOrBuilder::class);
|
|
}
|
|
|
|
foreach ($validators as $originalName => $reflection) {
|
|
$this->addMethodToInterface(
|
|
$staticNamespace,
|
|
$originalName,
|
|
$staticInterface,
|
|
$reflection,
|
|
$prefix,
|
|
$allowList,
|
|
$denyList,
|
|
);
|
|
$this->addMethodToInterface(
|
|
$chainedNamespace,
|
|
$originalName,
|
|
$chainedInterface,
|
|
$reflection,
|
|
$prefix,
|
|
$allowList,
|
|
$denyList,
|
|
);
|
|
}
|
|
|
|
$printer = new Printer();
|
|
$printer->wrapLength = 300;
|
|
|
|
foreach (
|
|
[
|
|
[$staticNamespace, $staticInterface],
|
|
[$chainedNamespace, $chainedInterface],
|
|
] as [$namespace, $interface]
|
|
) {
|
|
$filename = sprintf('%s/Mixins/%s.php', $srcDir, $interface->getName());
|
|
$existingContent = file_get_contents($filename);
|
|
$formattedContent = $this->getFormattedContent($printer->printNamespace($namespace), $existingContent);
|
|
if ($formattedContent === $existingContent) {
|
|
continue;
|
|
}
|
|
|
|
$updatableFiles[$filename] = $formattedContent;
|
|
$output->writeln($this->differ->diff(
|
|
new Item($filename, $existingContent),
|
|
new Item($filename, $formattedContent),
|
|
));
|
|
}
|
|
}
|
|
|
|
if ($updatableFiles === []) {
|
|
$output->writeln('<info>No changes needed.</info>');
|
|
} else {
|
|
$output->writeln(sprintf('<comment>Changes needed in %d files.</comment>', count($updatableFiles)));
|
|
}
|
|
|
|
if ($updatableFiles !== [] && !$input->getOption('fix')) {
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
foreach ($updatableFiles as $filename => $content) {
|
|
file_put_contents($filename, $content);
|
|
}
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
/** @return array<string, ReflectionClass> */
|
|
private function scanValidators(string $directory): array
|
|
{
|
|
$names = [];
|
|
|
|
foreach (new DirectoryIterator($directory) as $file) {
|
|
if (!$file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$className = 'Respect\\Validation\\Validators\\' . $file->getBasename('.php');
|
|
$reflection = new ReflectionClass($className);
|
|
|
|
if ($reflection->isAbstract()) {
|
|
continue;
|
|
}
|
|
|
|
$names[$reflection->getShortName()] = $reflection;
|
|
}
|
|
|
|
ksort($names);
|
|
|
|
return $names;
|
|
}
|
|
|
|
/**
|
|
* @param array<string> $allowList
|
|
* @param array<string> $denyList
|
|
*/
|
|
private function addMethodToInterface(
|
|
PhpNamespace $namespace,
|
|
string $originalName,
|
|
InterfaceType $interfaceType,
|
|
ReflectionClass $reflection,
|
|
string|null $prefix,
|
|
array $allowList,
|
|
array $denyList,
|
|
): void {
|
|
if ($allowList !== [] && !in_array($reflection->getShortName(), $allowList, true)) {
|
|
return;
|
|
}
|
|
|
|
if ($denyList !== [] && in_array($reflection->getShortName(), $denyList, true)) {
|
|
return;
|
|
}
|
|
|
|
$name = $prefix ? $prefix . ucfirst($originalName) : lcfirst($originalName);
|
|
$method = $interfaceType->addMethod($name)->setPublic()->setReturnType(Chain::class);
|
|
|
|
if (str_contains($interfaceType->getName(), 'Builder')) {
|
|
$method->setStatic();
|
|
}
|
|
|
|
if ($prefix === 'key') {
|
|
$method->addParameter('key')->setType('int|string');
|
|
}
|
|
|
|
if ($prefix === 'property') {
|
|
$method->addParameter('propertyName')->setType('string');
|
|
}
|
|
|
|
$reflectionConstructor = $reflection->getConstructor();
|
|
if ($reflectionConstructor === null) {
|
|
return;
|
|
}
|
|
|
|
$comment = $reflectionConstructor->getDocComment();
|
|
if ($comment) {
|
|
$method->addComment(preg_replace('@(/\*\* *| +\* +| +\*/)@', '', $comment));
|
|
}
|
|
|
|
foreach ($reflectionConstructor->getParameters() as $reflectionParameter) {
|
|
$this->addParameterToMethod($method, $reflectionParameter, $namespace);
|
|
}
|
|
}
|
|
|
|
private function addParameterToMethod(
|
|
Method $method,
|
|
ReflectionParameter $reflectionParameter,
|
|
PhpNamespace $namespace,
|
|
): void {
|
|
if ($reflectionParameter->isVariadic()) {
|
|
$method->setVariadic();
|
|
}
|
|
|
|
$type = $reflectionParameter->getType();
|
|
$types = [];
|
|
|
|
if ($type instanceof ReflectionUnionType) {
|
|
foreach ($type->getTypes() as $subType) {
|
|
$types[] = $subType->getName();
|
|
if ($subType->isBuiltin()) {
|
|
continue;
|
|
}
|
|
|
|
$namespace->addUse($subType->getName());
|
|
}
|
|
} elseif ($type instanceof ReflectionNamedType) {
|
|
$types[] = $type->getName();
|
|
if (
|
|
str_starts_with($type->getName(), 'Sokil')
|
|
|| str_starts_with($type->getName(), 'Egulias')
|
|
|| $type->getName() === 'finfo'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!$type->isBuiltin()) {
|
|
$namespace->addUse($type->getName());
|
|
}
|
|
}
|
|
|
|
$parameter = $method->addParameter($reflectionParameter->getName());
|
|
$parameter->setType(implode('|', $types));
|
|
|
|
if (!$reflectionParameter->isDefaultValueAvailable()) {
|
|
$parameter->setNullable($reflectionParameter->isOptional());
|
|
}
|
|
|
|
if (count($types) > 1 || $reflectionParameter->isVariadic()) {
|
|
$parameter->setNullable(false);
|
|
}
|
|
|
|
if (!$reflectionParameter->isDefaultValueAvailable()) {
|
|
return;
|
|
}
|
|
|
|
$defaultValue = $reflectionParameter->getDefaultValue();
|
|
if (is_object($defaultValue)) {
|
|
$parameter->setDefaultValue(null);
|
|
$parameter->setNullable(true);
|
|
|
|
return;
|
|
}
|
|
|
|
$parameter->setDefaultValue($defaultValue);
|
|
$parameter->setNullable(false);
|
|
}
|
|
|
|
private function getFormattedContent(string $content, string $existingContent): string
|
|
{
|
|
preg_match('/^<\?php\s*\/\*[\s\S]*?\*\//', $existingContent, $matches);
|
|
$existingHeader = $matches[0] ?? '';
|
|
|
|
$replacements = [
|
|
'/\n\n\t(public|\/\*\*)/m' => PHP_EOL . ' $1',
|
|
'/\t/m' => ' ',
|
|
'/\?([a-zA-Z]+) \$/' => '$1|null $',
|
|
'/\/\*\*\n +\* (.+)\n +\*\//m' => '/** $1 */',
|
|
];
|
|
|
|
return implode(PHP_EOL, [
|
|
trim($existingHeader) . PHP_EOL,
|
|
'declare(strict_types=1);',
|
|
'',
|
|
preg_replace(
|
|
array_keys($replacements),
|
|
array_values($replacements),
|
|
$content,
|
|
),
|
|
]);
|
|
}
|
|
}
|