Skip to content

Commit a79297a

Browse files
alexandre-dauboisnicolas-grekas
authored andcommitted
[Config] Add array-shapes to generated config builders
1 parent 9c413a3 commit a79297a

File tree

33 files changed

+1138
-290
lines changed

33 files changed

+1138
-290
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Config\Builder;
13+
14+
use Symfony\Component\Config\Definition\ArrayNode;
15+
use Symfony\Component\Config\Definition\BaseNode;
16+
use Symfony\Component\Config\Definition\BooleanNode;
17+
use Symfony\Component\Config\Definition\EnumNode;
18+
use Symfony\Component\Config\Definition\FloatNode;
19+
use Symfony\Component\Config\Definition\IntegerNode;
20+
use Symfony\Component\Config\Definition\NodeInterface;
21+
use Symfony\Component\Config\Definition\NumericNode;
22+
use Symfony\Component\Config\Definition\PrototypedArrayNode;
23+
use Symfony\Component\Config\Definition\ScalarNode;
24+
use Symfony\Component\Config\Definition\StringNode;
25+
26+
/**
27+
* @author Alexandre Daubois <[email protected]>
28+
*
29+
* @internal
30+
*/
31+
final class ArrayShapeGenerator
32+
{
33+
public static function generate(NodeInterface $node): string
34+
{
35+
return str_replace("\n", "\n * ", self::doGeneratePhpDoc($node));
36+
}
37+
38+
private static function doGeneratePhpDoc(NodeInterface $node, int $nestingLevel = 1): string
39+
{
40+
if (!$node instanceof ArrayNode) {
41+
return match (true) {
42+
$node instanceof BooleanNode => $node->hasDefaultValue() && null === $node->getDefaultValue() ? 'bool|null' : 'bool',
43+
$node instanceof StringNode => 'string',
44+
$node instanceof NumericNode => self::handleNumericNode($node),
45+
$node instanceof EnumNode => $node->getPermissibleValues('|'),
46+
$node instanceof ScalarNode => 'scalar|null',
47+
default => 'mixed',
48+
};
49+
}
50+
51+
if ($node instanceof PrototypedArrayNode) {
52+
$isHashmap = (bool) $node->getKeyAttribute();
53+
$arrayType = ($isHashmap ? 'array<string, ' : 'list<').self::doGeneratePhpDoc($node->getPrototype(), 1 + $nestingLevel).'>';
54+
55+
return $node->hasDefaultValue() && null === $node->getDefaultValue() ? $arrayType.'|null' : $arrayType;
56+
}
57+
58+
if (!($children = $node->getChildren()) && !$node->getParent() instanceof PrototypedArrayNode) {
59+
return $node->hasDefaultValue() && null === $node->getDefaultValue() ? 'array<mixed>|null' : 'array<mixed>';
60+
}
61+
62+
$arrayShape = \sprintf("array{%s\n", self::generateInlinePhpDocForNode($node));
63+
64+
foreach ($children as $child) {
65+
$arrayShape .= str_repeat(' ', $nestingLevel).self::dumpNodeKey($child).': ';
66+
67+
if ($child instanceof PrototypedArrayNode) {
68+
$isHashmap = (bool) $child->getKeyAttribute();
69+
$childArrayType = ($isHashmap ? 'array<string, ' : 'list<').self::doGeneratePhpDoc($child->getPrototype(), 1 + $nestingLevel).'>';
70+
$arrayShape .= $child->hasDefaultValue() && null === $child->getDefaultValue() ? $childArrayType.'|null' : $childArrayType;
71+
} else {
72+
$arrayShape .= self::doGeneratePhpDoc($child, 1 + $nestingLevel);
73+
}
74+
75+
$arrayShape .= \sprintf(",%s\n", !$child instanceof ArrayNode ? self::generateInlinePhpDocForNode($child) : '');
76+
}
77+
78+
if ($node->shouldIgnoreExtraKeys()) {
79+
$arrayShape .= str_repeat(' ', $nestingLevel)."...<mixed>\n";
80+
}
81+
82+
$arrayShape = $arrayShape.str_repeat(' ', $nestingLevel - 1).'}';
83+
84+
return $node->hasDefaultValue() && null === $node->getDefaultValue() ? $arrayShape.'|null' : $arrayShape;
85+
}
86+
87+
private static function dumpNodeKey(NodeInterface $node): string
88+
{
89+
$name = $node->getName();
90+
$quoted = str_starts_with($name, '@')
91+
|| \in_array(strtolower($name), ['int', 'float', 'bool', 'null', 'scalar'], true)
92+
|| strpbrk($name, '\'"');
93+
94+
if ($quoted) {
95+
$name = "'".addslashes($name)."'";
96+
}
97+
98+
return $name.($node->isRequired() ? '' : '?');
99+
}
100+
101+
private static function handleNumericNode(NumericNode $node): string
102+
{
103+
$min = $node->getMin() ?? 'min';
104+
$max = $node->getMax() ?? 'max';
105+
106+
if ($node instanceof IntegerNode) {
107+
return \sprintf('int<%s, %s>', $min, $max);
108+
}
109+
if ($node instanceof FloatNode) {
110+
return 'float';
111+
}
112+
113+
return \sprintf('int<%s, %s>|float', $min, $max);
114+
}
115+
116+
private static function generateInlinePhpDocForNode(BaseNode $node): string
117+
{
118+
$comment = '';
119+
if ($node->isDeprecated()) {
120+
$comment .= ' // Deprecated: '.$node->getDeprecation($node->getName(), $node->getPath())['message'];
121+
}
122+
123+
if ($info = $node->getInfo()) {
124+
$comment .= ' // '.$info;
125+
}
126+
127+
if ($node->hasDefaultValue()) {
128+
$comment .= ' // Default: '.json_encode($node->getDefaultValue(), \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRESERVE_ZERO_FRACTION);
129+
}
130+
131+
return rtrim(preg_replace('/\s+/', ' ', $comment));
132+
}
133+
}

src/Symfony/Component/Config/Builder/ConfigBuilderGenerator.php

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public function NAME(): string
6565
return \'ALIAS\';
6666
}', ['ALIAS' => $rootNode->getPath()]);
6767

68-
$this->writeClasses();
68+
$this->writeClasses($rootNode);
6969
}
7070

7171
return function () use ($path, $rootClass) {
@@ -86,10 +86,10 @@ private function getFullPath(ClassBuilder $class): string
8686
return $directory.\DIRECTORY_SEPARATOR.$class->getFilename();
8787
}
8888

89-
private function writeClasses(): void
89+
private function writeClasses(NodeInterface $node): void
9090
{
9191
foreach ($this->classes as $class) {
92-
$this->buildConstructor($class);
92+
$this->buildConstructor($class, $node);
9393
$this->buildToArray($class);
9494
if ($class->getProperties()) {
9595
$class->addProperty('_usedProperties', null, '[]');
@@ -114,7 +114,7 @@ private function buildNode(NodeInterface $node, ClassBuilder $class, string $nam
114114
$child instanceof PrototypedArrayNode => $this->handlePrototypedArrayNode($child, $class, $namespace),
115115
$child instanceof VariableNode => $this->handleVariableNode($child, $class),
116116
$child instanceof ArrayNode => $this->handleArrayNode($child, $class, $namespace),
117-
default => throw new \RuntimeException(\sprintf('Unknown node "%s".', $child::class)),
117+
default => throw new \RuntimeException(\sprintf('Unknown node "%s".', get_debug_type($child))),
118118
};
119119
}
120120
}
@@ -503,8 +503,8 @@ private function buildToArray(ClassBuilder $class): void
503503

504504
$body .= strtr('
505505
if (isset($this->_usedProperties[\'PROPERTY\'])) {
506-
$output[\'ORG_NAME\'] = '.$code.';
507-
}', ['PROPERTY' => $p->getName(), 'ORG_NAME' => $p->getOriginalName(), 'CLASS' => $p->getType()]);
506+
$output[\'ORIG_NAME\'] = '.$code.';
507+
}', ['PROPERTY' => $p->getName(), 'ORIG_NAME' => $p->getOriginalName(), 'CLASS' => $p->getType()]);
508508
}
509509

510510
$extraKeys = $class->shouldAllowExtraKeys() ? ' + $this->_extraKeys' : '';
@@ -518,51 +518,54 @@ public function NAME(): array
518518
}');
519519
}
520520

521-
private function buildConstructor(ClassBuilder $class): void
521+
private function buildConstructor(ClassBuilder $class, NodeInterface $node): void
522522
{
523523
$body = '';
524524
foreach ($class->getProperties() as $p) {
525-
$code = '$value[\'ORG_NAME\']';
525+
$code = '$config[\'ORIG_NAME\']';
526526
if (null !== $p->getType()) {
527527
if ($p->isArray()) {
528528
$code = $p->areScalarsAllowed()
529-
? 'array_map(fn ($v) => \is_array($v) ? new '.$p->getType().'($v) : $v, $value[\'ORG_NAME\'])'
530-
: 'array_map(fn ($v) => new '.$p->getType().'($v), $value[\'ORG_NAME\'])'
529+
? 'array_map(fn ($v) => \is_array($v) ? new '.$p->getType().'($v) : $v, $config[\'ORIG_NAME\'])'
530+
: 'array_map(fn ($v) => new '.$p->getType().'($v), $config[\'ORIG_NAME\'])'
531531
;
532532
} else {
533533
$code = $p->areScalarsAllowed()
534-
? '\is_array($value[\'ORG_NAME\']) ? new '.$p->getType().'($value[\'ORG_NAME\']) : $value[\'ORG_NAME\']'
535-
: 'new '.$p->getType().'($value[\'ORG_NAME\'])'
534+
? '\is_array($config[\'ORIG_NAME\']) ? new '.$p->getType().'($config[\'ORIG_NAME\']) : $config[\'ORIG_NAME\']'
535+
: 'new '.$p->getType().'($config[\'ORIG_NAME\'])'
536536
;
537537
}
538538
}
539539

540540
$body .= strtr('
541-
if (array_key_exists(\'ORG_NAME\', $value)) {
541+
if (array_key_exists(\'ORIG_NAME\', $config)) {
542542
$this->_usedProperties[\'PROPERTY\'] = true;
543543
$this->PROPERTY = '.$code.';
544-
unset($value[\'ORG_NAME\']);
544+
unset($config[\'ORIG_NAME\']);
545545
}
546-
', ['PROPERTY' => $p->getName(), 'ORG_NAME' => $p->getOriginalName()]);
546+
', ['PROPERTY' => $p->getName(), 'ORIG_NAME' => $p->getOriginalName()]);
547547
}
548548

549549
if ($class->shouldAllowExtraKeys()) {
550550
$body .= '
551-
$this->_extraKeys = $value;
551+
$this->_extraKeys = $config;
552552
';
553553
} else {
554554
$body .= '
555-
if ([] !== $value) {
556-
throw new InvalidConfigurationException(sprintf(\'The following keys are not supported by "%s": \', __CLASS__).implode(\', \', array_keys($value)));
555+
if ($config) {
556+
throw new InvalidConfigurationException(sprintf(\'The following keys are not supported by "%s": \', __CLASS__).implode(\', \', array_keys($config)));
557557
}';
558558

559559
$class->addUse(InvalidConfigurationException::class);
560560
}
561561

562562
$class->addMethod('__construct', '
563-
public function __construct(array $value = [])
563+
/**
564+
* @param PARAM_TYPE $config
565+
*/
566+
public function __construct(array $config = [])
564567
{'.$body.'
565-
}');
568+
}', ['PARAM_TYPE' => ArrayShapeGenerator::generate($node)]);
566569
}
567570

568571
private function buildSetExtraKey(ClassBuilder $class): void

src/Symfony/Component/Config/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Add argument `$singular` to `NodeBuilder::arrayNode()` to decouple plurals/singulars from XML
99
* Add support for `defaultNull()` on `ArrayNodeDefinition`
1010
* Add `ArrayNodeDefinition::acceptAndWrap()` to list alternative types that should be accepted and wrapped in an array
11+
* Add array-shapes to generated config builders
1112

1213
7.3
1314
---

src/Symfony/Component/Config/Definition/NumericNode.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ protected function finalizeValue(mixed $value): mixed
5050
return $value;
5151
}
5252

53+
public function getMin(): float|int|null
54+
{
55+
return $this->min;
56+
}
57+
58+
public function getMax(): float|int|null
59+
{
60+
return $this->max;
61+
}
62+
5363
protected function isValueEmpty(mixed $value): bool
5464
{
5565
// a numeric value cannot be empty

0 commit comments

Comments
 (0)