Skip to content

Commit 3372d22

Browse files
authored
[FEATURE] Command to generate XSD Schemas for ViewHelpers (#876)
This feature is a re-implementation of the XSD schema generator, which was previously available as a separate package [^1]. These schema files provide auto-complete in IDEs (like PHPStorm) for all available ViewHelpers in a composer project. The `bin/fluid` CLI command now supports the additional command `schema`, which scans the whole composer projects for valid ViewHelper classes. The class in question needs to meet the following requirements, which enforces already-existing conventions: * file name must end with `ViewHelper.php` * file path must contain a directory `ViewHelpers`, which serves as a divider between a Fluid namespace and its ViewHelpers * class must implement ViewHelperInterface * class name must end with `ViewHelper` * class must not be abstract These conditions are checked by `ViewHelperMetadataFactory`, which then creates the corresponding `ViewHelperMetadata` value object. The `SchemaGenerator` then receives a list of `ViewHelperMetadata` objects as well as an XML namespace (like `http://typo3.org/ns/TYPO3Fluid/Fluid/ViewHelpers`) and generates an xsd schema file that describes the ViewHelper and its arguments. The `ConsoleRunner` collects these files, creates unique file names and writes them as `schema_*.xsd` files to the destination folder, specified with `--destination`. IDEs either pick up those files automatically or need to be configured manually to do so. [^1]: https://github.com/TYPO3/Fluid.SchemaGenerator/
1 parent a6c951a commit 3372d22

26 files changed

+1194
-55
lines changed

bin/fluid

+4-4
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@ declare(strict_types=1);
1111
use TYPO3Fluid\Fluid\Tools\ConsoleRunner;
1212

1313
if (file_exists(__DIR__ . '/../autoload.php')) {
14-
require_once __DIR__ . '/../autoload.php';
14+
$autoloader = require_once __DIR__ . '/../autoload.php';
1515
} elseif (file_exists(__DIR__ . '/../vendor/autoload.php')) {
16-
require_once __DIR__ . '/../vendor/autoload.php';
16+
$autoloader = require_once __DIR__ . '/../vendor/autoload.php';
1717
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
18-
require_once __DIR__ . '/../../../autoload.php';
18+
$autoloader = require_once __DIR__ . '/../../../autoload.php';
1919
}
2020

2121
$runner = new ConsoleRunner();
2222
try {
23-
echo $runner->handleCommand($argv);
23+
echo $runner->handleCommand($argv, $autoloader);
2424
} catch (InvalidArgumentException $error) {
2525
echo PHP_EOL . 'ERROR! ' . $error->getMessage() . PHP_EOL . PHP_EOL;
2626
}

composer.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
},
1818
"require-dev": {
1919
"ext-json": "*",
20+
"ext-simplexml": "*",
2021
"friendsofphp/php-cs-fixer": "^3.54.0",
2122
"phpstan/phpstan": "^1.10.14",
2223
"phpstan/phpstan-phpunit": "^1.3.11",
2324
"phpunit/phpunit": "^10.2.6"
2425
},
2526
"suggest": {
26-
"ext-json": "PHP JSON is needed when using JSONVariableProvider: A relatively rare use case"
27+
"ext-json": "PHP JSON is needed when using JSONVariableProvider: A relatively rare use case",
28+
"ext-simplexml": "SimpleXML is required for the XSD schema generator"
2729
},
2830
"autoload": {
2931
"psr-4": {

phpunit.xml.dist

+2
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
<testsuites>
1818
<testsuite name="Unit">
1919
<directory>tests/Unit</directory>
20+
<directory suffix=".phpt">tests/Unit</directory>
2021
</testsuite>
2122
<testsuite name="Functional">
2223
<directory>tests/Functional</directory>
24+
<directory suffix=".phpt">tests/Functional</directory>
2325
</testsuite>
2426
</testsuites>
2527
<php>

src/Schema/SchemaGenerator.php

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file belongs to the package "TYPO3 Fluid".
7+
* See LICENSE.txt that was shipped with this package.
8+
*/
9+
10+
namespace TYPO3Fluid\Fluid\Schema;
11+
12+
/**
13+
* @internal
14+
*/
15+
final class SchemaGenerator
16+
{
17+
/**
18+
* @param ViewHelperMetadata[] $viewHelpers
19+
*/
20+
public function generate(string $xmlNamespace, array $viewHelpers): \SimpleXMLElement
21+
{
22+
$file = $this->createXmlRootElement($xmlNamespace);
23+
foreach ($viewHelpers as $metadata) {
24+
$xsdElement = $file->addChild('xsd:element');
25+
26+
$xsdElement->addAttribute('name', $metadata->tagName);
27+
28+
$documentation = $metadata->documentation;
29+
// Add deprecation information to ViewHelper documentation
30+
if (isset($metadata->docTags['@deprecated'])) {
31+
$documentation .= "\n@deprecated " . $metadata->docTags['@deprecated'];
32+
}
33+
$documentation = trim($documentation);
34+
35+
// Add documentation to xml
36+
if ($documentation !== '') {
37+
$xsdAnnotation = $xsdElement->addChild('xsd:annotation');
38+
$xsdDocumentation = $xsdAnnotation->addChild('xsd:documentation');
39+
$this->appendWithCdata($xsdDocumentation, $documentation);
40+
}
41+
42+
$xsdComplexType = $xsdElement->addChild('xsd:complexType');
43+
44+
// Allow text as well as subelements
45+
$xsdComplexType->addAttribute('mixed', 'true');
46+
47+
// Allow a sequence of arbitrary subelements of any type
48+
$xsdSequence = $xsdComplexType->addChild('xsd:sequence');
49+
$xsdAny = $xsdSequence->addChild('xsd:any');
50+
$xsdAny->addAttribute('minOccurs', '0');
51+
52+
// Add argument definitions to xml
53+
foreach ($metadata->argumentDefinitions as $argumentDefinition) {
54+
$default = $argumentDefinition->getDefaultValue();
55+
$type = $argumentDefinition->getType();
56+
57+
$xsdAttribute = $xsdComplexType->addChild('xsd:attribute');
58+
$xsdAttribute->addAttribute('type', $this->convertPhpTypeToXsdType($type));
59+
$xsdAttribute->addAttribute('name', $argumentDefinition->getName());
60+
if ($argumentDefinition->isRequired()) {
61+
$xsdAttribute->addAttribute('use', 'required');
62+
} else {
63+
$xsdAttribute->addAttribute('default', $this->createFluidRepresentation($default));
64+
}
65+
66+
// Add PHP type to documentation text
67+
// TODO check if there is a better field for this
68+
$documentation = $argumentDefinition->getDescription();
69+
$documentation .= "\n@type $type";
70+
$documentation = trim($documentation);
71+
72+
// Add documentation for argument to xml
73+
$xsdAnnotation = $xsdAttribute->addChild('xsd:annotation');
74+
$xsdDocumentation = $xsdAnnotation->addChild('xsd:documentation');
75+
$this->appendWithCdata($xsdDocumentation, $documentation);
76+
}
77+
78+
if ($metadata->allowsArbitraryArguments) {
79+
$xsdComplexType->addChild('xsd:anyAttribute');
80+
}
81+
}
82+
83+
return $file;
84+
}
85+
86+
private function appendWithCdata(\SimpleXMLElement $parent, string $text): \SimpleXMLElement
87+
{
88+
$parentDomNode = dom_import_simplexml($parent);
89+
$parentDomNode->appendChild($parentDomNode->ownerDocument->createCDATASection($text));
90+
return simplexml_import_dom($parentDomNode);
91+
}
92+
93+
private function createXmlRootElement(string $targetNamespace): \SimpleXMLElement
94+
{
95+
return new \SimpleXMLElement(
96+
'<?xml version="1.0" encoding="UTF-8"?><xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" targetNamespace="' . $targetNamespace . '"></xsd:schema>',
97+
);
98+
}
99+
100+
private function convertPhpTypeToXsdType(string $type): string
101+
{
102+
switch ($type) {
103+
case 'integer':
104+
return 'xsd:integer';
105+
case 'float':
106+
return 'xsd:float';
107+
case 'double':
108+
return 'xsd:double';
109+
case 'boolean':
110+
case 'bool':
111+
return 'xsd:boolean';
112+
case 'string':
113+
return 'xsd:string';
114+
case 'array':
115+
case 'mixed':
116+
default:
117+
return 'xsd:anySimpleType';
118+
}
119+
}
120+
121+
private function createFluidRepresentation(mixed $input, bool $isRoot = true): string
122+
{
123+
if (is_array($input)) {
124+
$fluidArray = [];
125+
foreach ($input as $key => $value) {
126+
$fluidArray[] = $this->createFluidRepresentation($key, false) . ': ' . $this->createFluidRepresentation($value, false);
127+
}
128+
return '{' . implode(', ', $fluidArray) . '}';
129+
}
130+
131+
if (is_string($input) && !$isRoot) {
132+
return "'" . addcslashes($input, "'") . "'";
133+
}
134+
135+
if (is_bool($input)) {
136+
return ($input) ? 'true' : 'false';
137+
}
138+
139+
if (is_null($input)) {
140+
return 'NULL';
141+
}
142+
143+
// Generally, this wouldn't be correct, since it's not the correct representation,
144+
// but in the context of XSD files we merely need to provide *any* string representation
145+
if (is_object($input)) {
146+
return '';
147+
}
148+
149+
return (string)$input;
150+
}
151+
}

src/Schema/ViewHelperFinder.php

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file belongs to the package "TYPO3 Fluid".
7+
* See LICENSE.txt that was shipped with this package.
8+
*/
9+
10+
namespace TYPO3Fluid\Fluid\Schema;
11+
12+
use Composer\Autoload\ClassLoader;
13+
use FilesystemIterator;
14+
use RecursiveDirectoryIterator;
15+
use RecursiveIteratorIterator;
16+
17+
/**
18+
* @internal
19+
*/
20+
final class ViewHelperFinder
21+
{
22+
private const FILE_SUFFIX = 'ViewHelper.php';
23+
24+
private ViewHelperMetadataFactory $viewHelperMetadataFactory;
25+
26+
public function __construct(?ViewHelperMetadataFactory $viewHelperMetadataFactory = null)
27+
{
28+
$this->viewHelperMetadataFactory = $viewHelperMetadataFactory ?? new ViewHelperMetadataFactory();
29+
}
30+
31+
/**
32+
* @return ViewHelperMetadata[]
33+
*/
34+
public function findViewHelpersInComposerProject(ClassLoader $autoloader): array
35+
{
36+
$viewHelpers = [];
37+
foreach ($autoloader->getPrefixesPsr4() as $namespace => $paths) {
38+
foreach ($paths as $path) {
39+
$viewHelpers = array_merge($viewHelpers, $this->findViewHelperFilesInPath($namespace, $path));
40+
}
41+
}
42+
return $viewHelpers;
43+
}
44+
45+
/**
46+
* @return ViewHelperMetadata[]
47+
*/
48+
private function findViewHelperFilesInPath(string $namespace, string $path): array
49+
{
50+
$viewHelpers = [];
51+
$iterator = new RecursiveIteratorIterator(
52+
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_PATHNAME),
53+
);
54+
foreach ($iterator as $filePath) {
55+
// Naming convention: ViewHelper files need to have "ViewHelper" suffix
56+
if (!str_ends_with((string)$filePath, self::FILE_SUFFIX)) {
57+
continue;
58+
}
59+
60+
// Guesstimate PHP namespace based on file path
61+
$pathInPackage = substr($filePath, strlen($path) + 1, -4);
62+
$className = $namespace . str_replace('/', '\\', $pathInPackage);
63+
$phpNamespace = substr($className, 0, strrpos($className, '\\'));
64+
65+
// Make sure that we generated the correct namespace for the file;
66+
// This prevents duplicate class declarations if files are part of
67+
// multiple/overlapping namespaces
68+
// The alternative would be to use PHP-Parser for the whole finding process,
69+
// but then we would have to check for correct interface implementation of
70+
// ViewHelper classes manually
71+
$phpCode = file_get_contents($filePath);
72+
if (!preg_match('#namespace\s+' . preg_quote($phpNamespace, '#') . '\s*;#', $phpCode)) {
73+
continue;
74+
}
75+
76+
try {
77+
$viewHelpers[] = $this->viewHelperMetadataFactory->createFromViewhelperClass($className);
78+
} catch (\InvalidArgumentException) {
79+
// Just ignore this class
80+
}
81+
}
82+
return $viewHelpers;
83+
}
84+
}

src/Schema/ViewHelperMetadata.php

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file belongs to the package "TYPO3 Fluid".
7+
* See LICENSE.txt that was shipped with this package.
8+
*/
9+
10+
namespace TYPO3Fluid\Fluid\Schema;
11+
12+
use TYPO3Fluid\Fluid\Core\ViewHelper\ArgumentDefinition;
13+
14+
/**
15+
* @internal
16+
*/
17+
final class ViewHelperMetadata
18+
{
19+
/**
20+
* @param array<string, string> $docTags
21+
* @param array<string, ArgumentDefinition> $argumentDefinitions
22+
*/
23+
public function __construct(
24+
public readonly string $className,
25+
public readonly string $namespace,
26+
public readonly string $name,
27+
public readonly string $tagName,
28+
public readonly string $documentation,
29+
public readonly string $xmlNamespace,
30+
public readonly array $docTags,
31+
public readonly array $argumentDefinitions,
32+
public readonly bool $allowsArbitraryArguments,
33+
) {}
34+
}

0 commit comments

Comments
 (0)