Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/Symfony/Component/Config/Builder/ArrayShapeGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Config\Builder;

use Symfony\Component\Config\Definition\ArrayNode;
use Symfony\Component\Config\Definition\BaseNode;
use Symfony\Component\Config\Definition\BooleanNode;
use Symfony\Component\Config\Definition\EnumNode;
use Symfony\Component\Config\Definition\FloatNode;
use Symfony\Component\Config\Definition\IntegerNode;
use Symfony\Component\Config\Definition\NodeInterface;
use Symfony\Component\Config\Definition\NumericNode;
use Symfony\Component\Config\Definition\PrototypedArrayNode;
use Symfony\Component\Config\Definition\ScalarNode;
use Symfony\Component\Config\Definition\StringNode;

/**
* @author Alexandre Daubois <alex.daubois@gmail.com>
*
* @internal
*/
final class ArrayShapeGenerator
{
public static function generate(NodeInterface $node): string
{
return str_replace("\n", "\n * ", self::doGeneratePhpDoc($node));
}

private static function doGeneratePhpDoc(NodeInterface $node, int $nestingLevel = 1): string
{
if (!$node instanceof ArrayNode) {
return match (true) {
$node instanceof BooleanNode => $node->hasDefaultValue() && null === $node->getDefaultValue() ? 'bool|null' : 'bool',
$node instanceof StringNode => 'string',
$node instanceof NumericNode => self::handleNumericNode($node),
$node instanceof EnumNode => $node->getPermissibleValues('|'),
$node instanceof ScalarNode => 'scalar|null',
default => 'mixed',
};
}

if ($node instanceof PrototypedArrayNode) {
$isHashmap = (bool) $node->getKeyAttribute();
$arrayType = ($isHashmap ? 'array<string, ' : 'list<').self::doGeneratePhpDoc($node->getPrototype(), 1 + $nestingLevel).'>';

return $node->hasDefaultValue() && null === $node->getDefaultValue() ? $arrayType.'|null' : $arrayType;
}

if (!($children = $node->getChildren()) && !$node->getParent() instanceof PrototypedArrayNode) {
return $node->hasDefaultValue() && null === $node->getDefaultValue() ? 'array<mixed>|null' : 'array<mixed>';
}

$arrayShape = \sprintf("array{%s\n", self::generateInlinePhpDocForNode($node));

foreach ($children as $child) {
$arrayShape .= str_repeat(' ', $nestingLevel).self::dumpNodeKey($child).': ';

if ($child instanceof PrototypedArrayNode) {
$isHashmap = (bool) $child->getKeyAttribute();
$childArrayType = ($isHashmap ? 'array<string, ' : 'list<').self::doGeneratePhpDoc($child->getPrototype(), 1 + $nestingLevel).'>';
$arrayShape .= $child->hasDefaultValue() && null === $child->getDefaultValue() ? $childArrayType.'|null' : $childArrayType;
} else {
$arrayShape .= self::doGeneratePhpDoc($child, 1 + $nestingLevel);
}

$arrayShape .= \sprintf(",%s\n", !$child instanceof ArrayNode ? self::generateInlinePhpDocForNode($child) : '');
}

if ($node->shouldIgnoreExtraKeys()) {
$arrayShape .= str_repeat(' ', $nestingLevel)."...<mixed>\n";
}

$arrayShape = $arrayShape.str_repeat(' ', $nestingLevel - 1).'}';

return $node->hasDefaultValue() && null === $node->getDefaultValue() ? $arrayShape.'|null' : $arrayShape;
}

private static function dumpNodeKey(NodeInterface $node): string
{
$name = $node->getName();
$quoted = str_starts_with($name, '@')
|| \in_array(strtolower($name), ['int', 'float', 'bool', 'null', 'scalar'], true)
|| strpbrk($name, '\'"');

if ($quoted) {
$name = "'".addslashes($name)."'";
}

return $name.($node->isRequired() ? '' : '?');
}

private static function handleNumericNode(NumericNode $node): string
{
$min = $node->getMin() ?? 'min';
$max = $node->getMax() ?? 'max';

if ($node instanceof IntegerNode) {
return \sprintf('int<%s, %s>', $min, $max);
}
if ($node instanceof FloatNode) {
return 'float';
}

return \sprintf('int<%s, %s>|float', $min, $max);
}

private static function generateInlinePhpDocForNode(BaseNode $node): string
{
$comment = '';
if ($node->isDeprecated()) {
$comment .= ' // Deprecated: '.$node->getDeprecation($node->getName(), $node->getPath())['message'];
}

if ($info = $node->getInfo()) {
$comment .= ' // '.$info;
}

if ($node->hasDefaultValue()) {
$comment .= ' // Default: '.json_encode($node->getDefaultValue(), \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRESERVE_ZERO_FRACTION);
}

return rtrim(preg_replace('/\s+/', ' ', $comment));
}
}
43 changes: 23 additions & 20 deletions src/Symfony/Component/Config/Builder/ConfigBuilderGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public function NAME(): string
return \'ALIAS\';
}', ['ALIAS' => $rootNode->getPath()]);

$this->writeClasses();
$this->writeClasses($rootNode);
}

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

private function writeClasses(): void
private function writeClasses(NodeInterface $node): void
{
foreach ($this->classes as $class) {
$this->buildConstructor($class);
$this->buildConstructor($class, $node);
$this->buildToArray($class);
if ($class->getProperties()) {
$class->addProperty('_usedProperties', null, '[]');
Expand All @@ -114,7 +114,7 @@ private function buildNode(NodeInterface $node, ClassBuilder $class, string $nam
$child instanceof PrototypedArrayNode => $this->handlePrototypedArrayNode($child, $class, $namespace),
$child instanceof VariableNode => $this->handleVariableNode($child, $class),
$child instanceof ArrayNode => $this->handleArrayNode($child, $class, $namespace),
default => throw new \RuntimeException(\sprintf('Unknown node "%s".', $child::class)),
default => throw new \RuntimeException(\sprintf('Unknown node "%s".', get_debug_type($child))),
};
}
}
Expand Down Expand Up @@ -503,8 +503,8 @@ private function buildToArray(ClassBuilder $class): void

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

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

private function buildConstructor(ClassBuilder $class): void
private function buildConstructor(ClassBuilder $class, NodeInterface $node): void
{
$body = '';
foreach ($class->getProperties() as $p) {
$code = '$value[\'ORG_NAME\']';
$code = '$config[\'ORIG_NAME\']';
if (null !== $p->getType()) {
if ($p->isArray()) {
$code = $p->areScalarsAllowed()
? 'array_map(fn ($v) => \is_array($v) ? new '.$p->getType().'($v) : $v, $value[\'ORG_NAME\'])'
: 'array_map(fn ($v) => new '.$p->getType().'($v), $value[\'ORG_NAME\'])'
? 'array_map(fn ($v) => \is_array($v) ? new '.$p->getType().'($v) : $v, $config[\'ORIG_NAME\'])'
: 'array_map(fn ($v) => new '.$p->getType().'($v), $config[\'ORIG_NAME\'])'
;
} else {
$code = $p->areScalarsAllowed()
? '\is_array($value[\'ORG_NAME\']) ? new '.$p->getType().'($value[\'ORG_NAME\']) : $value[\'ORG_NAME\']'
: 'new '.$p->getType().'($value[\'ORG_NAME\'])'
? '\is_array($config[\'ORIG_NAME\']) ? new '.$p->getType().'($config[\'ORIG_NAME\']) : $config[\'ORIG_NAME\']'
: 'new '.$p->getType().'($config[\'ORIG_NAME\'])'
;
}
}

$body .= strtr('
if (array_key_exists(\'ORG_NAME\', $value)) {
if (array_key_exists(\'ORIG_NAME\', $config)) {
$this->_usedProperties[\'PROPERTY\'] = true;
$this->PROPERTY = '.$code.';
unset($value[\'ORG_NAME\']);
unset($config[\'ORIG_NAME\']);
}
', ['PROPERTY' => $p->getName(), 'ORG_NAME' => $p->getOriginalName()]);
', ['PROPERTY' => $p->getName(), 'ORIG_NAME' => $p->getOriginalName()]);
}

if ($class->shouldAllowExtraKeys()) {
$body .= '
$this->_extraKeys = $value;
$this->_extraKeys = $config;
';
} else {
$body .= '
if ([] !== $value) {
throw new InvalidConfigurationException(sprintf(\'The following keys are not supported by "%s": \', __CLASS__).implode(\', \', array_keys($value)));
if ($config) {
throw new InvalidConfigurationException(sprintf(\'The following keys are not supported by "%s": \', __CLASS__).implode(\', \', array_keys($config)));
}';

$class->addUse(InvalidConfigurationException::class);
}

$class->addMethod('__construct', '
public function __construct(array $value = [])
/**
* @param PARAM_TYPE $config
*/
public function __construct(array $config = [])
{'.$body.'
}');
}', ['PARAM_TYPE' => ArrayShapeGenerator::generate($node)]);
}

private function buildSetExtraKey(ClassBuilder $class): void
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/Config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CHANGELOG
* Add argument `$singular` to `NodeBuilder::arrayNode()` to decouple plurals/singulars from XML
* Add support for `defaultNull()` on `ArrayNodeDefinition`
* Add `ArrayNodeDefinition::acceptAndWrap()` to list alternative types that should be accepted and wrapped in an array
* Add array-shapes to generated config builders

7.3
---
Expand Down
10 changes: 10 additions & 0 deletions src/Symfony/Component/Config/Definition/NumericNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ protected function finalizeValue(mixed $value): mixed
return $value;
}

public function getMin(): float|int|null
{
return $this->min;
}

public function getMax(): float|int|null
{
return $this->max;
}

protected function isValueEmpty(mixed $value): bool
{
// a numeric value cannot be empty
Expand Down
Loading
Loading