Lines 100.00% 31 / 31
Functions and Methods 100.00% 6 / 6
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
EnvironmentPlaceholders 100.00% 31 / 31 100.00% 6 / 6 22 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 resolve 100.00% 1 / 1 100.00% 1 / 1 1
 substituteInTree 100.00% 7 / 7 100.00% 1 / 1 6
 findChildNode 100.00% 4 / 4 100.00% 1 / 1 6
 resolveValue 100.00% 4 / 4 100.00% 1 / 1 3
 substitute 100.00% 14 / 14 100.00% 1 / 1 5
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Config;
6
7use Stewart\Runtime\Config\Environment\EnvironmentValueParser;
8use Stewart\Runtime\Exception\ConfigurationError;
9use Stewart\Runtime\Exception\ConfigurationException;
10use Symfony\Component\Config\Definition\ArrayNode;
11use Symfony\Component\Config\Definition\NodeInterface;
12use Symfony\Component\Config\Definition\PrototypedArrayNode;
13use Symfony\Component\Config\Definition\VariableNode;
14
15final readonly class EnvironmentPlaceholders
16{
17    private const string PLACEHOLDER = '/\$\$\{|\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/';
18
19    private const string WHOLE_VALUE_PLACEHOLDER = '/\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}\z/';
20
21    public function __construct(
22        private EnvironmentVariables $variables,
23        private EnvironmentValueParser $environmentValueParser,
24    ) {}
25
26    /**
27     * @param array<array-key, mixed> $file
28     * @return array<array-key, mixed>
29     * @throws ConfigurationException
30     */
31    public function resolve(NodeInterface $tree, array $file): array
32    {
33        return $this->substituteInTree($file, $tree, '');
34    }
35
36    /**
37     * @param array<array-key, mixed> $file
38     * @return array<array-key, mixed>
39     * @throws ConfigurationException
40     */
41    private function substituteInTree(array $file, ?NodeInterface $node, string $path): array
42    {
43        foreach ($file as $key => $value) {
44            $setting = $path === '' ? (string) $key : $path . '.' . $key;
45            $child = $this->findChildNode($node, (string) $key);
46
47            $file[$key] = match (true) {
48                \is_array($value) => $this->substituteInTree($value, $child, $setting),
49                \is_string($value) => $this->resolveValue($value, $child, $setting),
50                default => $value,
51            };
52        }
53
54        return $file;
55    }
56
57    private function findChildNode(?NodeInterface $node, string $key): ?NodeInterface
58    {
59        return match (true) {
60            $node instanceof PrototypedArrayNode => $node->getPrototype(),
61            $node instanceof ArrayNode => $node->getChildren()[$key] ?? $node->getChildren()[strtr($key, '-', '_')] ?? null,
62            // ScalarNode extends VariableNode, so only a free-form node passes its type on to nested values.
63            $node !== null && $node::class === VariableNode::class => $node,
64            default => null,
65        };
66    }
67
68    /** @throws ConfigurationException */
69    private function resolveValue(string $value, ?NodeInterface $node, string $setting): mixed
70    {
71        $substituted = $this->substitute($value, $setting);
72
73        if ($node === null || preg_match(self::WHOLE_VALUE_PLACEHOLDER, $value, $match) !== 1) {
74            return $substituted;
75        }
76
77        return $this->environmentValueParser->parseForNode($node, \sprintf('%s (${%s})', $setting, $match[1]), $substituted);
78    }
79
80    /** @throws ConfigurationException */
81    private function substitute(string $value, string $setting): string
82    {
83        return preg_replace_callback(
84            self::PLACEHOLDER,
85            function (array $match) use ($setting): string {
86                $name = $match[1] ?? null;
87
88                if ($name === null) {
89                    return '${';
90                }
91
92                $variable = $this->variables->get($name);
93                $default = $match[2] ?? null;
94
95                if ($default !== null && ($variable === null || $variable === '')) {
96                    return $default;
97                }
98
99                return $variable ?? throw ConfigurationException::createForReason(ConfigurationError::PlaceholderUnset, setting: $setting, variable: $name);
100            },
101            $value,
102        ) ?? $value;
103    }
104}