Skip to content

Commit 28458ea

Browse files
committed
[FEATURE] Add Rule generator
Resolves: #108
1 parent 068253d commit 28458ea

34 files changed

+1425
-3
lines changed

composer.json

+3
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"a9f/fractor-extension-installer": "self.version",
4848
"a9f/fractor-fluid": "self.version",
4949
"a9f/fractor-phpstan-rules": "self.version",
50+
"a9f/fractor-rule-generator": "self.version",
5051
"a9f/fractor-typoscript": "self.version",
5152
"a9f/fractor-xml": "self.version",
5253
"a9f/fractor-yaml": "self.version",
@@ -64,6 +65,7 @@
6465
"a9f\\FractorFluid\\": "packages/fractor-fluid/src/",
6566
"a9f\\FractorMonorepo\\": "src/",
6667
"a9f\\FractorPhpStanRules\\": "packages/fractor-phpstan-rules/src/",
68+
"a9f\\FractorRuleGenerator\\": "packages/fractor-rule-generator/src/",
6769
"a9f\\FractorTypoScript\\": "packages/fractor-typoscript/src/",
6870
"a9f\\FractorXml\\": "packages/fractor-xml/src/",
6971
"a9f\\FractorYaml\\": "packages/fractor-yaml/src/",
@@ -83,6 +85,7 @@
8385
"a9f\\FractorDocGenerator\\Tests\\": "packages/fractor-doc-generator/tests/",
8486
"a9f\\FractorFluid\\Tests\\": "packages/fractor-fluid/tests/",
8587
"a9f\\FractorPhpStanRules\\Tests\\": "packages/fractor-phpstan-rules/tests/",
88+
"a9f\\FractorRuleGenerator\\Tests\\": "packages/fractor-rule-generator/tests/",
8689
"a9f\\FractorTypoScript\\Tests\\": "packages/fractor-typoscript/tests/",
8790
"a9f\\FractorXml\\Tests\\": "packages/fractor-xml/tests/",
8891
"a9f\\FractorYaml\\Tests\\": "packages/fractor-yaml/tests/",

ecs.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
YodaStyleFixer::class,
3232
OperatorLinebreakFixer::class,
3333
])
34-
->withSkip([__DIR__ . '/packages/extension-installer/generated'])
34+
->withSkip([
35+
__DIR__ . '/packages/extension-installer/generated',
36+
__DIR__ . '/packages/fractor-rule-generator/templates',
37+
])
3538
->withPreparedSets(psr12: true, common: true, symplify: true, cleanCode: true)
3639
->withPaths([__DIR__ . '/e2e', __DIR__ . '/src', __DIR__ . '/packages'])
3740
->withRootFiles();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/vendor/
2+
/composer.lock
3+
.phpunit.cache
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "a9f/fractor-rule-generator",
3+
"description": "Fractor rule generator",
4+
"license": "MIT",
5+
"type": "fractor-extension",
6+
"authors": [
7+
{
8+
"name": "Andreas Wolf",
9+
"email": "[email protected]",
10+
"role": "Lead Developer"
11+
}
12+
],
13+
"require": {
14+
"php": "^8.2",
15+
"a9f/fractor": "^0.3",
16+
"a9f/fractor-extension-installer": "^0.3",
17+
"nette/utils": "^4.0",
18+
"symfony/config": "^5.4 || ^6.4 || ^7.0",
19+
"symfony/console": "^5.4 || ^6.4 || ^7.0",
20+
"symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0",
21+
"symfony/filesystem": "^5.4 || ^6.4 || ^7.0",
22+
"symfony/finder": "^5.4 || ^6.4 || ^7.0",
23+
"symplify/rule-doc-generator-contracts": "^11.2",
24+
"webmozart/assert": "^1.11"
25+
},
26+
"minimum-stability": "dev",
27+
"prefer-stable": true,
28+
"autoload": {
29+
"psr-4": {
30+
"a9f\\FractorRuleGenerator\\": "src/"
31+
}
32+
},
33+
"autoload-dev": {
34+
"psr-4": {
35+
"a9f\\FractorRuleGenerator\\Tests\\": "tests/"
36+
}
37+
},
38+
"config": {
39+
"allow-plugins": {
40+
"a9f/fractor-extension-installer": true
41+
},
42+
"sort-packages": true
43+
},
44+
"extra": {
45+
"branch-alias": {
46+
"dev-main": "0.3-dev"
47+
}
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Symfony\Component\Console\Output\ConsoleOutput;
6+
use Symfony\Component\Console\Output\OutputInterface;
7+
use Symfony\Component\DependencyInjection\ContainerBuilder;
8+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
9+
10+
return static function (ContainerConfigurator $containerConfigurator, ContainerBuilder $containerBuilder): void {
11+
$services = $containerConfigurator->services();
12+
$services->defaults()
13+
->autowire()
14+
->private()
15+
->autoconfigure();
16+
17+
$services->load('a9f\\FractorRuleGenerator\\', __DIR__ . '/../../fractor-rule-generator/src')
18+
->exclude([__DIR__ . '/../src/ValueObject', __DIR__ . '/../src/**/ValueObject']);
19+
20+
$services->set(ConsoleOutput::class);
21+
$services->alias(OutputInterface::class, ConsoleOutput::class);
22+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" cacheDirectory=".phpunit.cache">
3+
<testsuites>
4+
<testsuite name="fractor-rule-generator">
5+
<directory>tests</directory>
6+
</testsuite>
7+
</testsuites>
8+
<source>
9+
<include>
10+
<directory>./src</directory>
11+
</include>
12+
</source>
13+
</phpunit>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace a9f\FractorRuleGenerator\Console\Command;
6+
7+
use a9f\Fractor\Exception\ShouldNotHappenException;
8+
use a9f\Fractor\FileSystem\FileInfoFactory;
9+
use a9f\FractorRuleGenerator\Factory\Typo3FractorTypeFactory;
10+
use a9f\FractorRuleGenerator\FileSystem\ConfigFilesystem;
11+
use a9f\FractorRuleGenerator\Finder\TemplateFinder;
12+
use a9f\FractorRuleGenerator\Generator\FileGenerator;
13+
use a9f\FractorRuleGenerator\ValueObject\Typo3FractorRecipe;
14+
use a9f\FractorRuleGenerator\ValueObject\Typo3Version;
15+
use RuntimeException;
16+
use Symfony\Component\Console\Attribute\AsCommand;
17+
use Symfony\Component\Console\Command\Command;
18+
use Symfony\Component\Console\Helper\QuestionHelper;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
use Symfony\Component\Console\Question\ChoiceQuestion;
22+
use Symfony\Component\Console\Question\Question;
23+
use Webmozart\Assert\Assert;
24+
25+
#[AsCommand(name: 'generate-rule', description: 'Generate a new Fractor rule in a proper location, with tests')]
26+
final class GenerateRuleCommand extends Command
27+
{
28+
/**
29+
* @var string
30+
*/
31+
private const FRACTOR_FQN_NAME_PATTERN = 'a9f\Typo3Fractor\TYPO3__MajorPrefixed__\__Type__\__Name__';
32+
33+
public function __construct(
34+
private readonly TemplateFinder $templateFinder,
35+
private readonly FileGenerator $fileGenerator,
36+
private readonly OutputInterface $outputStyle,
37+
private readonly ConfigFilesystem $configFilesystem,
38+
private readonly FileInfoFactory $fileInfoFactory
39+
) {
40+
parent::__construct();
41+
}
42+
43+
protected function configure(): void
44+
{
45+
}
46+
47+
protected function execute(InputInterface $input, OutputInterface $output): int
48+
{
49+
/** @var QuestionHelper $helper */
50+
$helper = $this->getHelper('question');
51+
/** @var Typo3Version $typo3Version */
52+
$typo3Version = $helper->ask($input, $output, $this->askForTypo3Version());
53+
$changelogUrl = $helper->ask($input, $output, $this->askForChangelogUrl());
54+
$name = $helper->ask($input, $output, $this->askForName());
55+
$description = $helper->ask($input, $output, $this->askForDescription());
56+
$type = $helper->ask($input, $output, $this->askForType());
57+
58+
$recipe = new Typo3FractorRecipe(
59+
$typo3Version,
60+
$changelogUrl,
61+
$name,
62+
$description,
63+
Typo3FractorTypeFactory::fromString($type)
64+
);
65+
66+
$templateFileInfos = $this->templateFinder->find($recipe->getFractorFixtureFileExtension());
67+
68+
$templateVariables = [
69+
'__MajorPrefixed__' => $recipe->getMajorVersionPrefixed(),
70+
'__Major__' => $recipe->getMajorVersion(),
71+
'__MinorPrefixed__' => $recipe->getMinorVersionPrefixed(),
72+
'__Type__' => $recipe->getFractorTypeFolderName(),
73+
'__FixtureFileExtension__' => $recipe->getFractorFixtureFileExtension(),
74+
'__Name__' => $recipe->getFractorName(),
75+
'__Test_Directory__' => $recipe->getTestDirectory(),
76+
'__Changelog_Annotation__' => $recipe->getChangelogAnnotation(),
77+
'__Description__' => addslashes($recipe->getDescription()),
78+
'__Use__' => $recipe->getUseImports(),
79+
'__Traits__' => $recipe->getTraits(),
80+
'__ExtendsImplements__' => $recipe->getExtendsImplements(),
81+
'__Base_Fractor_Body_Template__' => $recipe->getFractorBodyTemplate(),
82+
];
83+
84+
$targetDirectory = __DIR__ . '/../../../../typo3-fractor';
85+
86+
$generatedFilePaths = $this->fileGenerator->generateFiles(
87+
$templateFileInfos,
88+
$templateVariables,
89+
$targetDirectory
90+
);
91+
92+
$this->configFilesystem->addRuleToConfigurationFile(
93+
$recipe->getSet(),
94+
$templateVariables,
95+
self::FRACTOR_FQN_NAME_PATTERN
96+
);
97+
98+
$testCaseDirectoryPath = $this->resolveTestCaseDirectoryPath($generatedFilePaths);
99+
$this->printSuccess($recipe->getFractorName(), $generatedFilePaths, $testCaseDirectoryPath);
100+
101+
return Command::SUCCESS;
102+
}
103+
104+
private function askForTypo3Version(): Question
105+
{
106+
$whatTypo3Version = new Question('TYPO3-Version (i.e. 12.0): ');
107+
$whatTypo3Version->setNormalizer(
108+
static fn ($version): Typo3Version => Typo3Version::createFromString(trim((string) $version))
109+
);
110+
$whatTypo3Version->setMaxAttempts(2);
111+
$whatTypo3Version->setValidator(
112+
static function (Typo3Version $version): Typo3Version {
113+
Assert::greaterThanEq($version->getMajor(), 7);
114+
Assert::greaterThanEq($version->getMinor(), 0);
115+
116+
return $version;
117+
}
118+
);
119+
120+
return $whatTypo3Version;
121+
}
122+
123+
private function askForChangelogUrl(): Question
124+
{
125+
$whatIsTheUrlToChangelog = new Question(
126+
'Url to changelog (i.e. https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/...) or "x" for none: '
127+
);
128+
$whatIsTheUrlToChangelog->setMaxAttempts(3);
129+
$whatIsTheUrlToChangelog->setValidator(
130+
static function (?string $url): string {
131+
Assert::notNull($url);
132+
133+
if (strtolower($url) === 'x') {
134+
return '';
135+
}
136+
137+
if (! filter_var($url, FILTER_VALIDATE_URL)) {
138+
throw new RuntimeException('Please enter a valid Url');
139+
}
140+
141+
Assert::startsWith($url, 'https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/');
142+
143+
return $url;
144+
}
145+
);
146+
147+
return $whatIsTheUrlToChangelog;
148+
}
149+
150+
private function askForName(): Question
151+
{
152+
$giveMeYourName = new Question('Name (i.e MigrateRequiredFlag): ');
153+
$giveMeYourName->setNormalizer(
154+
static fn ($name): ?string => preg_replace('/Fractor$/', '', ucfirst((string) $name))
155+
);
156+
$giveMeYourName->setMaxAttempts(3);
157+
$giveMeYourName->setValidator(static function (string $name): string {
158+
Assert::minLength($name, 5);
159+
Assert::maxLength($name, 60);
160+
Assert::notContains($name, ' ', 'The name must not contain spaces');
161+
// Pattern from: https://www.php.net/manual/en/language.oop5.basic.php
162+
Assert::regex(
163+
$name,
164+
'/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/',
165+
'The name must be a valid PHP class name. A valid class name starts with a letter or underscore, followed by any number of letters, numbers, or underscores.'
166+
);
167+
168+
return $name;
169+
});
170+
171+
return $giveMeYourName;
172+
}
173+
174+
private function askForDescription(): Question
175+
{
176+
$description = new Question('Description (i.e. Migrate required flag): ');
177+
$description->setMaxAttempts(3);
178+
$description->setValidator(static function (?string $description): string {
179+
Assert::notNull($description, 'Please enter a description');
180+
Assert::minLength($description, 5);
181+
Assert::maxLength($description, 120);
182+
183+
return $description;
184+
});
185+
186+
return $description;
187+
}
188+
189+
private function askForType(): Question
190+
{
191+
$question = new ChoiceQuestion('Please select the Fractor type', [
192+
'flexform',
193+
'fluid',
194+
'typoscript',
195+
'yaml',
196+
'composer',
197+
]);
198+
$question->setMaxAttempts(3);
199+
$question->setErrorMessage('Type %s is invalid.');
200+
201+
return $question;
202+
}
203+
204+
/**
205+
* @param string[] $generatedFilePaths
206+
*/
207+
private function printSuccess(string $name, array $generatedFilePaths, string $testCaseFilePath): void
208+
{
209+
$message = sprintf('<info>New files generated for "%s":</info>', $name);
210+
$this->outputStyle->writeln($message);
211+
212+
sort($generatedFilePaths);
213+
214+
foreach ($generatedFilePaths as $generatedFilePath) {
215+
$fileInfo = $this->fileInfoFactory->createFileInfoFromPath($generatedFilePath);
216+
$this->outputStyle->writeln(' * ' . $fileInfo->getRelativePathname());
217+
}
218+
219+
$message = sprintf(
220+
'<info>Run tests for this fractor:</info>%svendor/bin/phpunit %s',
221+
PHP_EOL . PHP_EOL,
222+
$testCaseFilePath . PHP_EOL
223+
);
224+
$this->outputStyle->writeln($message);
225+
}
226+
227+
/**
228+
* @param string[] $generatedFilePaths
229+
*/
230+
private function resolveTestCaseDirectoryPath(array $generatedFilePaths): string
231+
{
232+
foreach ($generatedFilePaths as $generatedFilePath) {
233+
if (! \str_ends_with($generatedFilePath, 'Test.php')
234+
&& ! \str_ends_with($generatedFilePath, 'Test.php.inc')
235+
) {
236+
continue;
237+
}
238+
239+
$generatedFileInfo = $this->fileInfoFactory->createFileInfoFromPath($generatedFilePath);
240+
return $generatedFileInfo->getRelativePath();
241+
}
242+
243+
throw new ShouldNotHappenException();
244+
}
245+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace a9f\FractorRuleGenerator\Contract;
6+
7+
interface Typo3FractorTypeInterface extends \Stringable
8+
{
9+
public function getFolderName(): string;
10+
11+
public function getUseImports(): string;
12+
13+
public function getExtendsImplements(): string;
14+
15+
public function getTraits(): string;
16+
17+
public function getFractorFixtureFileExtension(): string;
18+
19+
public function getFractorBodyTemplate(): string;
20+
}

0 commit comments

Comments
 (0)