Skip to content

Commit 819d734

Browse files
committed
Check for mismatches in the mixin classes
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.
1 parent 0190f3e commit 819d734

21 files changed

Lines changed: 211 additions & 463 deletions

.github/workflows/continuous-integration-code.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,6 @@ jobs:
8989

9090
- name: Run PHPStan
9191
run: vendor/bin/phpstan analyze
92+
93+
- name: Run Validator's mixin linter
94+
run: bin/console lint:mixin

bin/console

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ declare(strict_types=1);
1010

1111
require __DIR__ . '/../vendor/autoload.php';
1212

13-
use Respect\Dev\Commands\CreateMixinCommand;
1413
use Respect\Dev\Commands\LintDocsCommand;
14+
use Respect\Dev\Commands\LintMixinCommand;
1515
use Respect\Dev\Commands\SmokeTestsCheckCompleteCommand;
1616
use Respect\Dev\Commands\LintSpdxCommand;
1717
use Respect\Dev\Commands\UpdateDomainSuffixesCommand;
1818
use Respect\Dev\Commands\UpdateDomainToplevelCommand;
1919
use Respect\Dev\Commands\UpdatePostalCodesCommand;
20+
use Respect\Dev\Differ\ConsoleDiffer;
2021
use Respect\Dev\Markdown\CompositeLinter;
21-
use Respect\Dev\Markdown\Differ as MarkdownDiffer;
2222
use Respect\Dev\Markdown\Linters\AssertionMessageLinter;
2323
use Respect\Dev\Markdown\Linters\ValidatorHeaderLinter;
2424
use Respect\Dev\Markdown\Linters\ValidatorIndexLinter;
@@ -30,10 +30,9 @@ use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
3030
use Symfony\Component\Console\Application;
3131

3232
return (static function () {
33-
$differ = new MarkdownDiffer(new Differ(new UnifiedDiffOutputBuilder('', addLineNumbers: true)));
33+
$differ = new ConsoleDiffer(new Differ(new UnifiedDiffOutputBuilder('', addLineNumbers: true)));
3434

3535
$application = new Application('Respect/Validation', '3.0');
36-
$application->addCommand(new CreateMixinCommand());
3736
$application->addCommand(new LintDocsCommand($differ, new CompositeLinter(
3837
new AssertionMessageLinter(),
3938
new ValidatorHeaderLinter(),
@@ -42,6 +41,7 @@ return (static function () {
4241
new ValidatorTemplatesLinter(),
4342
new ValidatorChangelogLinter(),
4443
)));
44+
$application->addCommand(new LintMixinCommand($differ));
4545
$application->addCommand(new LintSpdxCommand());
4646
$application->addCommand(new UpdateDomainSuffixesCommand());
4747
$application->addCommand(new UpdateDomainToplevelCommand());

phpcs.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<exclude-pattern>tests/Pest.php</exclude-pattern>
2727
</rule>
2828
<rule ref="Generic.Files.LineLength.TooLong">
29+
<exclude-pattern>src/Mixins/</exclude-pattern>
2930
<exclude-pattern>tests/feature/</exclude-pattern>
3031
</rule>
3132
<rule ref="SlevomatCodingStandard.Functions.StaticClosure.ClosureNotStatic">

src-dev/Commands/LintDocsCommand.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
namespace Respect\Dev\Commands;
1212

13-
use Respect\Dev\Markdown\Differ;
13+
use Respect\Dev\Differ\ConsoleDiffer;
14+
use Respect\Dev\Differ\Item;
1415
use Respect\Dev\Markdown\File;
1516
use Respect\Dev\Markdown\Linter;
1617
use Symfony\Component\Console\Attribute\AsCommand;
@@ -30,7 +31,7 @@
3031
final class LintDocsCommand extends Command
3132
{
3233
public function __construct(
33-
private readonly Differ $differ,
34+
private readonly ConsoleDiffer $differ,
3435
private readonly Linter $linter,
3536
) {
3637
parent::__construct();
@@ -61,7 +62,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6162

6263
$lintedFiles[] = $linted;
6364

64-
$output->writeln($this->differ->diff($original, $linted));
65+
$output->writeln($this->differ->diff(
66+
new Item($original->filename, $original->content->build()),
67+
new Item($linted->filename, $linted->content->build()),
68+
));
6569
}
6670

6771
if ($lintedFiles === []) {
Lines changed: 100 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use ReflectionNamedType;
2020
use ReflectionParameter;
2121
use ReflectionUnionType;
22+
use Respect\Dev\Differ\ConsoleDiffer;
23+
use Respect\Dev\Differ\Item;
2224
use Respect\Validation\Mixins\AllBuilder;
2325
use Respect\Validation\Mixins\AllChain;
2426
use Respect\Validation\Mixins\Chain;
@@ -44,31 +46,34 @@
4446
use Symfony\Component\Console\Command\Command;
4547
use Symfony\Component\Console\Input\InputInterface;
4648
use Symfony\Component\Console\Output\OutputInterface;
47-
use Symfony\Component\Console\Style\SymfonyStyle;
4849

49-
use function array_filter;
50+
use function array_keys;
5051
use function array_merge;
52+
use function array_values;
5153
use function count;
5254
use function dirname;
53-
use function file_exists;
55+
use function file_get_contents;
5456
use function file_put_contents;
5557
use function implode;
5658
use function in_array;
5759
use function is_object;
5860
use function ksort;
5961
use function lcfirst;
62+
use function preg_match;
6063
use function preg_replace;
61-
use function shell_exec;
6264
use function sprintf;
6365
use function str_contains;
6466
use function str_starts_with;
67+
use function trim;
6568
use function ucfirst;
6669

70+
use const PHP_EOL;
71+
6772
#[AsCommand(
68-
name: 'create:mixin',
69-
description: 'Generate mixin interfaces from validators',
73+
name: 'lint:mixin',
74+
description: 'Apply linters to the generated mixin interfaces',
7075
)]
71-
final class CreateMixinCommand extends Command
76+
final class LintMixinCommand extends Command
7277
{
7378
private const array NUMBER_RELATED_VALIDATORS = [
7479
'Between',
@@ -109,19 +114,29 @@ final class CreateMixinCommand extends Command
109114
'Named',
110115
];
111116

112-
protected function execute(InputInterface $input, OutputInterface $output): int
113-
{
114-
$io = new SymfonyStyle($input, $output);
117+
public function __construct(
118+
private readonly ConsoleDiffer $differ,
119+
) {
120+
parent::__construct();
121+
}
115122

116-
$io->title('Generating mixin interfaces');
123+
protected function configure(): void
124+
{
125+
$this->addOption(
126+
'fix',
127+
null,
128+
null,
129+
'Automatically fix files with issues.',
130+
);
131+
}
117132

133+
protected function execute(InputInterface $input, OutputInterface $output): int
134+
{
118135
// Scan validators directory
119136
$srcDir = dirname(__DIR__, 2) . '/src';
120137
$validatorsDir = $srcDir . '/Validators';
121138
$validators = $this->scanValidators($validatorsDir);
122139

123-
$io->text(sprintf('Found %d validators', count($validators)));
124-
125140
// Define mixins
126141
$mixins = [
127142
['All', 'all', [], array_merge(['All'], self::STRUCTURE_RELATED_VALIDATORS)],
@@ -133,23 +148,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
133148
['NullOr', 'nullOr', [], ['NullOr', 'Blank', 'Undef', 'UndefOr', 'Templated', 'Named']],
134149
['Property', 'property', [], self::STRUCTURE_RELATED_VALIDATORS],
135150
['UndefOr', 'undefOr', [], ['NullOr', 'Blank', 'Undef', 'UndefOr', 'Attributes', 'Templated', 'Named']],
136-
['', null, [], []],
151+
[null, null, [], []],
137152
];
138153

139-
$io->section('Generating mixin interfaces');
154+
$updatableFiles = [];
140155

141156
foreach ($mixins as [$name, $prefix, $allowList, $denyList]) {
142-
$io->text(sprintf('Generating %sBuilder and %sChain', $name ?: 'Base', $name ?: 'Base'));
143-
144157
$chainedNamespace = new PhpNamespace('Respect\\Validation\\Mixins');
145-
$chainedNamespace->addUse(Validator::class);
146158
$chainedInterface = $chainedNamespace->addInterface($name . 'Chain');
147159

148160
$staticNamespace = new PhpNamespace('Respect\\Validation\\Mixins');
149-
$staticNamespace->addUse(Validator::class);
150161
$staticInterface = $staticNamespace->addInterface($name . 'Builder');
151162

152-
if ($name === '') {
163+
if ($name === null) {
153164
$chainedInterface->addExtend(Validator::class);
154165
$chainedInterface->addExtend(AllChain::class);
155166
$chainedInterface->addExtend(KeyChain::class);
@@ -160,7 +171,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
160171
$chainedInterface->addExtend(NullOrChain::class);
161172
$chainedInterface->addExtend(PropertyChain::class);
162173
$chainedInterface->addExtend(UndefOrChain::class);
163-
$chainedInterface->addComment('@mixin \\' . ValidatorBuilder::class);
174+
$chainedInterface->addComment('@mixin ValidatorBuilder');
175+
$chainedNamespace->addUse(ValidatorBuilder::class);
164176

165177
$staticInterface->addExtend(AllBuilder::class);
166178
$staticInterface->addExtend(KeyBuilder::class);
@@ -175,6 +187,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
175187

176188
foreach ($validators as $originalName => $reflection) {
177189
$this->addMethodToInterface(
190+
$staticNamespace,
178191
$originalName,
179192
$staticInterface,
180193
$reflection,
@@ -183,6 +196,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
183196
$denyList,
184197
);
185198
$this->addMethodToInterface(
199+
$chainedNamespace,
186200
$originalName,
187201
$chainedInterface,
188202
$reflection,
@@ -193,25 +207,42 @@ protected function execute(InputInterface $input, OutputInterface $output): int
193207
}
194208

195209
$printer = new Printer();
196-
$printer->wrapLength = 115;
210+
$printer->wrapLength = 300;
197211

198-
$this->overwriteFile($printer->printNamespace($staticNamespace), $staticInterface->getName());
199-
$this->overwriteFile($printer->printNamespace($chainedNamespace), $chainedInterface->getName());
212+
foreach (
213+
[
214+
[$staticNamespace, $staticInterface],
215+
[$chainedNamespace, $chainedInterface],
216+
] as [$namespace, $interface]
217+
) {
218+
$filename = sprintf('%s/Mixins/%s.php', $srcDir, $interface->getName());
219+
$existingContent = file_get_contents($filename);
220+
$formattedContent = $this->getFormattedContent($printer->printNamespace($namespace), $existingContent);
221+
if ($formattedContent === $existingContent) {
222+
continue;
223+
}
224+
225+
$updatableFiles[$filename] = $formattedContent;
226+
$output->writeln($this->differ->diff(
227+
new Item($filename, $existingContent),
228+
new Item($filename, $formattedContent),
229+
));
230+
}
200231
}
201232

202-
// Run code beautifier
203-
$io->section('Running code beautifier');
204-
$mixinsDir = $srcDir . '/Mixins';
205-
$phpcbfPath = dirname(__DIR__, 2) . '/vendor/bin/phpcbf';
206-
207-
if (file_exists($phpcbfPath)) {
208-
shell_exec($phpcbfPath . ' ' . $mixinsDir);
209-
$io->success('Code beautified');
233+
if ($updatableFiles === []) {
234+
$output->writeln('<info>No changes needed.</info>');
210235
} else {
211-
$io->warning('phpcbf not found, skipping code beautification');
236+
$output->writeln(sprintf('<comment>Changes needed in %d files.</comment>', count($updatableFiles)));
212237
}
213238

214-
$io->success('Mixin interfaces generated successfully');
239+
if ($updatableFiles !== [] && !$input->getOption('fix')) {
240+
return Command::FAILURE;
241+
}
242+
243+
foreach ($updatableFiles as $filename => $content) {
244+
file_put_contents($filename, $content);
245+
}
215246

216247
return Command::SUCCESS;
217248
}
@@ -246,6 +277,7 @@ private function scanValidators(string $directory): array
246277
* @param array<string> $denyList
247278
*/
248279
private function addMethodToInterface(
280+
PhpNamespace $namespace,
249281
string $originalName,
250282
InterfaceType $interfaceType,
251283
ReflectionClass $reflection,
@@ -287,12 +319,15 @@ private function addMethodToInterface(
287319
}
288320

289321
foreach ($reflectionConstructor->getParameters() as $reflectionParameter) {
290-
$this->addParameterToMethod($method, $reflectionParameter);
322+
$this->addParameterToMethod($method, $reflectionParameter, $namespace);
291323
}
292324
}
293325

294-
private function addParameterToMethod(Method $method, ReflectionParameter $reflectionParameter): void
295-
{
326+
private function addParameterToMethod(
327+
Method $method,
328+
ReflectionParameter $reflectionParameter,
329+
PhpNamespace $namespace,
330+
): void {
296331
if ($reflectionParameter->isVariadic()) {
297332
$method->setVariadic();
298333
}
@@ -303,6 +338,11 @@ private function addParameterToMethod(Method $method, ReflectionParameter $refle
303338
if ($type instanceof ReflectionUnionType) {
304339
foreach ($type->getTypes() as $subType) {
305340
$types[] = $subType->getName();
341+
if ($subType->isBuiltin()) {
342+
continue;
343+
}
344+
345+
$namespace->addUse($subType->getName());
306346
}
307347
} elseif ($type instanceof ReflectionNamedType) {
308348
$types[] = $type->getName();
@@ -313,6 +353,10 @@ private function addParameterToMethod(Method $method, ReflectionParameter $refle
313353
) {
314354
return;
315355
}
356+
357+
if (!$type->isBuiltin()) {
358+
$namespace->addUse($type->getName());
359+
}
316360
}
317361

318362
$parameter = $method->addParameter($reflectionParameter->getName());
@@ -342,25 +386,27 @@ private function addParameterToMethod(Method $method, ReflectionParameter $refle
342386
$parameter->setNullable(false);
343387
}
344388

345-
private function overwriteFile(string $content, string $basename): void
389+
private function getFormattedContent(string $content, string $existingContent): string
346390
{
347-
$srcDir = dirname(__DIR__, 2) . '/src';
348-
349-
$SPDX = ' * SPDX';
391+
preg_match('/^<\?php\s*\/\*[\s\S]*?\*\//', $existingContent, $matches);
392+
$existingHeader = $matches[0] ?? '';
393+
394+
$replacements = [
395+
'/\n\n\t(public|\/\*\*)/m' => PHP_EOL . ' $1',
396+
'/\t/m' => ' ',
397+
'/\?([a-zA-Z]+) \$/' => '$1|null $',
398+
'/\/\*\*\n +\* (.+)\n +\*\//m' => '/** $1 */',
399+
];
350400

351-
$finalContent = implode("\n\n", array_filter([
352-
'<?php',
353-
'/*',
354-
$SPDX . '-License-Identifier: MIT',
355-
$SPDX . '-FileCopyrightText: (c) Respect Project Contributors',
356-
'/*',
401+
return implode(PHP_EOL, [
402+
trim($existingHeader) . PHP_EOL,
357403
'declare(strict_types=1);',
358-
preg_replace('/extends (.+, )+/', 'extends' . "\n" . '\1', $content),
359-
]));
360-
361-
file_put_contents(
362-
sprintf('%s/Mixins/%s.php', $srcDir, $basename),
363-
$finalContent,
364-
);
404+
'',
405+
preg_replace(
406+
array_keys($replacements),
407+
array_values($replacements),
408+
$content,
409+
),
410+
]);
365411
}
366412
}

0 commit comments

Comments
 (0)