Skip to content

Commit 9fc824f

Browse files
committed
[FEATURE] Add Rule generator
Resolves: #108
1 parent 9843b09 commit 9fc824f

34 files changed

+1852
-2
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,242 @@
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 execute(InputInterface $input, OutputInterface $output): int
44+
{
45+
/** @var QuestionHelper $helper */
46+
$helper = $this->getHelper('question');
47+
48+
/** @var Typo3Version $typo3Version */
49+
$typo3Version = $helper->ask($input, $output, $this->askForTypo3Version());
50+
$changelogUrl = $helper->ask($input, $output, $this->askForChangelogUrl());
51+
$name = $helper->ask($input, $output, $this->askForName());
52+
$description = $helper->ask($input, $output, $this->askForDescription());
53+
$type = $helper->ask($input, $output, $this->askForType());
54+
55+
$recipe = new Typo3FractorRecipe(
56+
$typo3Version,
57+
$changelogUrl,
58+
$name,
59+
$description,
60+
Typo3FractorTypeFactory::fromString($type)
61+
);
62+
63+
$templateFileInfos = $this->templateFinder->find($recipe->getFractorFixtureFileExtension());
64+
65+
$templateVariables = [
66+
'__MajorPrefixed__' => $recipe->getMajorVersionPrefixed(),
67+
'__Major__' => $recipe->getMajorVersion(),
68+
'__MinorPrefixed__' => $recipe->getMinorVersionPrefixed(),
69+
'__Type__' => $recipe->getFractorTypeFolderName(),
70+
'__FixtureFileExtension__' => $recipe->getFractorFixtureFileExtension(),
71+
'__Name__' => $recipe->getFractorName(),
72+
'__Test_Directory__' => $recipe->getTestDirectory(),
73+
'__Changelog_Annotation__' => $recipe->getChangelogAnnotation(),
74+
'__Description__' => addslashes($recipe->getDescription()),
75+
'__Use__' => $recipe->getUseImports(),
76+
'__Traits__' => $recipe->getTraits(),
77+
'__ExtendsImplements__' => $recipe->getExtendsImplements(),
78+
'__Base_Fractor_Body_Template__' => $recipe->getFractorBodyTemplate(),
79+
];
80+
81+
$targetDirectory = __DIR__ . '/../../../../typo3-fractor';
82+
83+
$generatedFilePaths = $this->fileGenerator->generateFiles(
84+
$templateFileInfos,
85+
$templateVariables,
86+
$targetDirectory
87+
);
88+
89+
$this->configFilesystem->addRuleToConfigurationFile(
90+
$recipe->getSet(),
91+
$templateVariables,
92+
self::FRACTOR_FQN_NAME_PATTERN
93+
);
94+
95+
$testCaseDirectoryPath = $this->resolveTestCaseDirectoryPath($generatedFilePaths);
96+
$this->printSuccess($recipe->getFractorName(), $generatedFilePaths, $testCaseDirectoryPath);
97+
98+
return Command::SUCCESS;
99+
}
100+
101+
private function askForTypo3Version(): Question
102+
{
103+
$whatTypo3Version = new Question('TYPO3-Version (i.e. 12.0): ');
104+
$whatTypo3Version->setNormalizer(
105+
static fn ($version): Typo3Version => Typo3Version::createFromString(trim((string) $version))
106+
);
107+
$whatTypo3Version->setMaxAttempts(2);
108+
$whatTypo3Version->setValidator(
109+
static function (Typo3Version $version): Typo3Version {
110+
Assert::greaterThanEq($version->getMajor(), 7);
111+
Assert::greaterThanEq($version->getMinor(), 0);
112+
113+
return $version;
114+
}
115+
);
116+
117+
return $whatTypo3Version;
118+
}
119+
120+
private function askForChangelogUrl(): Question
121+
{
122+
$whatIsTheUrlToChangelog = new Question(
123+
'Url to changelog (i.e. https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/...) or "x" for none: '
124+
);
125+
$whatIsTheUrlToChangelog->setMaxAttempts(3);
126+
$whatIsTheUrlToChangelog->setValidator(
127+
static function (?string $url): string {
128+
Assert::notNull($url);
129+
130+
if (strtolower($url) === 'x') {
131+
return '';
132+
}
133+
134+
if (! filter_var($url, FILTER_VALIDATE_URL)) {
135+
throw new RuntimeException('Please enter a valid Url');
136+
}
137+
138+
Assert::startsWith($url, 'https://docs.typo3.org/c/typo3/cms-core/main/en-us/Changelog/');
139+
140+
return $url;
141+
}
142+
);
143+
144+
return $whatIsTheUrlToChangelog;
145+
}
146+
147+
private function askForName(): Question
148+
{
149+
$giveMeYourName = new Question('Name (i.e MigrateRequiredFlag): ');
150+
$giveMeYourName->setNormalizer(
151+
static fn ($name): ?string => preg_replace('/Fractor$/', '', ucfirst((string) $name))
152+
);
153+
$giveMeYourName->setMaxAttempts(3);
154+
$giveMeYourName->setValidator(static function (string $name): string {
155+
Assert::minLength($name, 5);
156+
Assert::maxLength($name, 60);
157+
Assert::notContains($name, ' ', 'The name must not contain spaces');
158+
// Pattern from: https://www.php.net/manual/en/language.oop5.basic.php
159+
Assert::regex(
160+
$name,
161+
'/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/',
162+
'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.'
163+
);
164+
165+
return $name;
166+
});
167+
168+
return $giveMeYourName;
169+
}
170+
171+
private function askForDescription(): Question
172+
{
173+
$description = new Question('Description (i.e. Migrate required flag): ');
174+
$description->setMaxAttempts(3);
175+
$description->setValidator(static function (?string $description): string {
176+
Assert::notNull($description, 'Please enter a description');
177+
Assert::minLength($description, 5);
178+
Assert::maxLength($description, 120);
179+
180+
return $description;
181+
});
182+
183+
return $description;
184+
}
185+
186+
private function askForType(): Question
187+
{
188+
$question = new ChoiceQuestion('Please select the Fractor type', [
189+
'flexform',
190+
'fluid',
191+
'typoscript',
192+
'yaml',
193+
'composer',
194+
]);
195+
$question->setMaxAttempts(3);
196+
$question->setErrorMessage('Type %s is invalid.');
197+
198+
return $question;
199+
}
200+
201+
/**
202+
* @param string[] $generatedFilePaths
203+
*/
204+
private function printSuccess(string $name, array $generatedFilePaths, string $testCaseFilePath): void
205+
{
206+
$message = sprintf('<info>New files generated for "%s":</info>', $name);
207+
$this->outputStyle->writeln($message);
208+
209+
sort($generatedFilePaths);
210+
211+
foreach ($generatedFilePaths as $generatedFilePath) {
212+
$fileInfo = $this->fileInfoFactory->createFileInfoFromPath($generatedFilePath);
213+
$this->outputStyle->writeln(' * ' . $fileInfo->getRelativePathname());
214+
}
215+
216+
$message = sprintf(
217+
'<info>Run tests for this fractor:</info>%svendor/bin/phpunit %s',
218+
PHP_EOL . PHP_EOL,
219+
$testCaseFilePath . PHP_EOL
220+
);
221+
$this->outputStyle->writeln($message);
222+
}
223+
224+
/**
225+
* @param string[] $generatedFilePaths
226+
*/
227+
private function resolveTestCaseDirectoryPath(array $generatedFilePaths): string
228+
{
229+
foreach ($generatedFilePaths as $generatedFilePath) {
230+
if (! \str_ends_with($generatedFilePath, 'Test.php')
231+
&& ! \str_ends_with($generatedFilePath, 'Test.php.inc')
232+
) {
233+
continue;
234+
}
235+
236+
$generatedFileInfo = $this->fileInfoFactory->createFileInfoFromPath($generatedFilePath);
237+
return $generatedFileInfo->getRelativePath();
238+
}
239+
240+
throw new ShouldNotHappenException();
241+
}
242+
}
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)