1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Config\Definition;
13
14use Symfony\Component\Config\Definition\Exception\Exception;
15use Symfony\Component\Config\Definition\Exception\ForbiddenOverwriteException;
16use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
17use Symfony\Component\Config\Definition\Exception\InvalidTypeException;
18use Symfony\Component\Config\Definition\Exception\UnsetKeyException;
19
20/**
21 * The base node class.
22 *
23 * @author Johannes M. Schmitt <schmittjoh@gmail.com>
24 */
25abstract class BaseNode implements NodeInterface
26{
27    public const DEFAULT_PATH_SEPARATOR = '.';
28
29    private static $placeholderUniquePrefixes = [];
30    private static $placeholders = [];
31
32    protected $name;
33    protected $parent;
34    protected $normalizationClosures = [];
35    protected $finalValidationClosures = [];
36    protected $allowOverwrite = true;
37    protected $required = false;
38    protected $deprecation = [];
39    protected $equivalentValues = [];
40    protected $attributes = [];
41    protected $pathSeparator;
42
43    private $handlingPlaceholder;
44
45    /**
46     * @throws \InvalidArgumentException if the name contains a period
47     */
48    public function __construct(?string $name, NodeInterface $parent = null, string $pathSeparator = self::DEFAULT_PATH_SEPARATOR)
49    {
50        if (str_contains($name = (string) $name, $pathSeparator)) {
51            throw new \InvalidArgumentException('The name must not contain ".'.$pathSeparator.'".');
52        }
53
54        $this->name = $name;
55        $this->parent = $parent;
56        $this->pathSeparator = $pathSeparator;
57    }
58
59    /**
60     * Register possible (dummy) values for a dynamic placeholder value.
61     *
62     * Matching configuration values will be processed with a provided value, one by one. After a provided value is
63     * successfully processed the configuration value is returned as is, thus preserving the placeholder.
64     *
65     * @internal
66     */
67    public static function setPlaceholder(string $placeholder, array $values): void
68    {
69        if (!$values) {
70            throw new \InvalidArgumentException('At least one value must be provided.');
71        }
72
73        self::$placeholders[$placeholder] = $values;
74    }
75
76    /**
77     * Adds a common prefix for dynamic placeholder values.
78     *
79     * Matching configuration values will be skipped from being processed and are returned as is, thus preserving the
80     * placeholder. An exact match provided by {@see setPlaceholder()} might take precedence.
81     *
82     * @internal
83     */
84    public static function setPlaceholderUniquePrefix(string $prefix): void
85    {
86        self::$placeholderUniquePrefixes[] = $prefix;
87    }
88
89    /**
90     * Resets all current placeholders available.
91     *
92     * @internal
93     */
94    public static function resetPlaceholders(): void
95    {
96        self::$placeholderUniquePrefixes = [];
97        self::$placeholders = [];
98    }
99
100    public function setAttribute(string $key, $value)
101    {
102        $this->attributes[$key] = $value;
103    }
104
105    /**
106     * @return mixed
107     */
108    public function getAttribute(string $key, $default = null)
109    {
110        return $this->attributes[$key] ?? $default;
111    }
112
113    /**
114     * @return bool
115     */
116    public function hasAttribute(string $key)
117    {
118        return isset($this->attributes[$key]);
119    }
120
121    /**
122     * @return array
123     */
124    public function getAttributes()
125    {
126        return $this->attributes;
127    }
128
129    public function setAttributes(array $attributes)
130    {
131        $this->attributes = $attributes;
132    }
133
134    public function removeAttribute(string $key)
135    {
136        unset($this->attributes[$key]);
137    }
138
139    /**
140     * Sets an info message.
141     */
142    public function setInfo(string $info)
143    {
144        $this->setAttribute('info', $info);
145    }
146
147    /**
148     * Returns info message.
149     *
150     * @return string|null
151     */
152    public function getInfo()
153    {
154        return $this->getAttribute('info');
155    }
156
157    /**
158     * Sets the example configuration for this node.
159     *
160     * @param string|array $example
161     */
162    public function setExample($example)
163    {
164        $this->setAttribute('example', $example);
165    }
166
167    /**
168     * Retrieves the example configuration for this node.
169     *
170     * @return string|array|null
171     */
172    public function getExample()
173    {
174        return $this->getAttribute('example');
175    }
176
177    /**
178     * Adds an equivalent value.
179     *
180     * @param mixed $originalValue
181     * @param mixed $equivalentValue
182     */
183    public function addEquivalentValue($originalValue, $equivalentValue)
184    {
185        $this->equivalentValues[] = [$originalValue, $equivalentValue];
186    }
187
188    /**
189     * Set this node as required.
190     */
191    public function setRequired(bool $boolean)
192    {
193        $this->required = $boolean;
194    }
195
196    /**
197     * Sets this node as deprecated.
198     *
199     * @param string $package The name of the composer package that is triggering the deprecation
200     * @param string $version The version of the package that introduced the deprecation
201     * @param string $message the deprecation message to use
202     *
203     * You can use %node% and %path% placeholders in your message to display,
204     * respectively, the node name and its complete path
205     */
206    public function setDeprecated(?string $package/*, string $version, string $message = 'The child node "%node%" at path "%path%" is deprecated.' */)
207    {
208        $args = \func_get_args();
209
210        if (\func_num_args() < 2) {
211            trigger_deprecation('symfony/config', '5.1', 'The signature of method "%s()" requires 3 arguments: "string $package, string $version, string $message", not defining them is deprecated.', __METHOD__);
212
213            if (!isset($args[0])) {
214                trigger_deprecation('symfony/config', '5.1', 'Passing a null message to un-deprecate a node is deprecated.');
215
216                $this->deprecation = [];
217
218                return;
219            }
220
221            $message = (string) $args[0];
222            $package = $version = '';
223        } else {
224            $package = (string) $args[0];
225            $version = (string) $args[1];
226            $message = (string) ($args[2] ?? 'The child node "%node%" at path "%path%" is deprecated.');
227        }
228
229        $this->deprecation = [
230            'package' => $package,
231            'version' => $version,
232            'message' => $message,
233        ];
234    }
235
236    /**
237     * Sets if this node can be overridden.
238     */
239    public function setAllowOverwrite(bool $allow)
240    {
241        $this->allowOverwrite = $allow;
242    }
243
244    /**
245     * Sets the closures used for normalization.
246     *
247     * @param \Closure[] $closures An array of Closures used for normalization
248     */
249    public function setNormalizationClosures(array $closures)
250    {
251        $this->normalizationClosures = $closures;
252    }
253
254    /**
255     * Sets the closures used for final validation.
256     *
257     * @param \Closure[] $closures An array of Closures used for final validation
258     */
259    public function setFinalValidationClosures(array $closures)
260    {
261        $this->finalValidationClosures = $closures;
262    }
263
264    /**
265     * {@inheritdoc}
266     */
267    public function isRequired()
268    {
269        return $this->required;
270    }
271
272    /**
273     * Checks if this node is deprecated.
274     *
275     * @return bool
276     */
277    public function isDeprecated()
278    {
279        return (bool) $this->deprecation;
280    }
281
282    /**
283     * Returns the deprecated message.
284     *
285     * @param string $node the configuration node name
286     * @param string $path the path of the node
287     *
288     * @return string
289     *
290     * @deprecated since Symfony 5.1, use "getDeprecation()" instead.
291     */
292    public function getDeprecationMessage(string $node, string $path)
293    {
294        trigger_deprecation('symfony/config', '5.1', 'The "%s()" method is deprecated, use "getDeprecation()" instead.', __METHOD__);
295
296        return $this->getDeprecation($node, $path)['message'];
297    }
298
299    /**
300     * @param string $node The configuration node name
301     * @param string $path The path of the node
302     */
303    public function getDeprecation(string $node, string $path): array
304    {
305        return [
306            'package' => $this->deprecation['package'] ?? '',
307            'version' => $this->deprecation['version'] ?? '',
308            'message' => strtr($this->deprecation['message'] ?? '', ['%node%' => $node, '%path%' => $path]),
309        ];
310    }
311
312    /**
313     * {@inheritdoc}
314     */
315    public function getName()
316    {
317        return $this->name;
318    }
319
320    /**
321     * {@inheritdoc}
322     */
323    public function getPath()
324    {
325        if (null !== $this->parent) {
326            return $this->parent->getPath().$this->pathSeparator.$this->name;
327        }
328
329        return $this->name;
330    }
331
332    /**
333     * {@inheritdoc}
334     */
335    final public function merge($leftSide, $rightSide)
336    {
337        if (!$this->allowOverwrite) {
338            throw new ForbiddenOverwriteException(sprintf('Configuration path "%s" cannot be overwritten. You have to define all options for this path, and any of its sub-paths in one configuration section.', $this->getPath()));
339        }
340
341        if ($leftSide !== $leftPlaceholders = self::resolvePlaceholderValue($leftSide)) {
342            foreach ($leftPlaceholders as $leftPlaceholder) {
343                $this->handlingPlaceholder = $leftSide;
344                try {
345                    $this->merge($leftPlaceholder, $rightSide);
346                } finally {
347                    $this->handlingPlaceholder = null;
348                }
349            }
350
351            return $rightSide;
352        }
353
354        if ($rightSide !== $rightPlaceholders = self::resolvePlaceholderValue($rightSide)) {
355            foreach ($rightPlaceholders as $rightPlaceholder) {
356                $this->handlingPlaceholder = $rightSide;
357                try {
358                    $this->merge($leftSide, $rightPlaceholder);
359                } finally {
360                    $this->handlingPlaceholder = null;
361                }
362            }
363
364            return $rightSide;
365        }
366
367        $this->doValidateType($leftSide);
368        $this->doValidateType($rightSide);
369
370        return $this->mergeValues($leftSide, $rightSide);
371    }
372
373    /**
374     * {@inheritdoc}
375     */
376    final public function normalize($value)
377    {
378        $value = $this->preNormalize($value);
379
380        // run custom normalization closures
381        foreach ($this->normalizationClosures as $closure) {
382            $value = $closure($value);
383        }
384
385        // resolve placeholder value
386        if ($value !== $placeholders = self::resolvePlaceholderValue($value)) {
387            foreach ($placeholders as $placeholder) {
388                $this->handlingPlaceholder = $value;
389                try {
390                    $this->normalize($placeholder);
391                } finally {
392                    $this->handlingPlaceholder = null;
393                }
394            }
395
396            return $value;
397        }
398
399        // replace value with their equivalent
400        foreach ($this->equivalentValues as $data) {
401            if ($data[0] === $value) {
402                $value = $data[1];
403            }
404        }
405
406        // validate type
407        $this->doValidateType($value);
408
409        // normalize value
410        return $this->normalizeValue($value);
411    }
412
413    /**
414     * Normalizes the value before any other normalization is applied.
415     *
416     * @param mixed $value
417     *
418     * @return mixed
419     */
420    protected function preNormalize($value)
421    {
422        return $value;
423    }
424
425    /**
426     * Returns parent node for this node.
427     *
428     * @return NodeInterface|null
429     */
430    public function getParent()
431    {
432        return $this->parent;
433    }
434
435    /**
436     * {@inheritdoc}
437     */
438    final public function finalize($value)
439    {
440        if ($value !== $placeholders = self::resolvePlaceholderValue($value)) {
441            foreach ($placeholders as $placeholder) {
442                $this->handlingPlaceholder = $value;
443                try {
444                    $this->finalize($placeholder);
445                } finally {
446                    $this->handlingPlaceholder = null;
447                }
448            }
449
450            return $value;
451        }
452
453        $this->doValidateType($value);
454
455        $value = $this->finalizeValue($value);
456
457        // Perform validation on the final value if a closure has been set.
458        // The closure is also allowed to return another value.
459        foreach ($this->finalValidationClosures as $closure) {
460            try {
461                $value = $closure($value);
462            } catch (Exception $e) {
463                if ($e instanceof UnsetKeyException && null !== $this->handlingPlaceholder) {
464                    continue;
465                }
466
467                throw $e;
468            } catch (\Exception $e) {
469                throw new InvalidConfigurationException(sprintf('Invalid configuration for path "%s": ', $this->getPath()).$e->getMessage(), $e->getCode(), $e);
470            }
471        }
472
473        return $value;
474    }
475
476    /**
477     * Validates the type of a Node.
478     *
479     * @param mixed $value The value to validate
480     *
481     * @throws InvalidTypeException when the value is invalid
482     */
483    abstract protected function validateType($value);
484
485    /**
486     * Normalizes the value.
487     *
488     * @param mixed $value The value to normalize
489     *
490     * @return mixed
491     */
492    abstract protected function normalizeValue($value);
493
494    /**
495     * Merges two values together.
496     *
497     * @param mixed $leftSide
498     * @param mixed $rightSide
499     *
500     * @return mixed
501     */
502    abstract protected function mergeValues($leftSide, $rightSide);
503
504    /**
505     * Finalizes a value.
506     *
507     * @param mixed $value The value to finalize
508     *
509     * @return mixed
510     */
511    abstract protected function finalizeValue($value);
512
513    /**
514     * Tests if placeholder values are allowed for this node.
515     */
516    protected function allowPlaceholders(): bool
517    {
518        return true;
519    }
520
521    /**
522     * Tests if a placeholder is being handled currently.
523     */
524    protected function isHandlingPlaceholder(): bool
525    {
526        return null !== $this->handlingPlaceholder;
527    }
528
529    /**
530     * Gets allowed dynamic types for this node.
531     */
532    protected function getValidPlaceholderTypes(): array
533    {
534        return [];
535    }
536
537    private static function resolvePlaceholderValue($value)
538    {
539        if (\is_string($value)) {
540            if (isset(self::$placeholders[$value])) {
541                return self::$placeholders[$value];
542            }
543
544            foreach (self::$placeholderUniquePrefixes as $placeholderUniquePrefix) {
545                if (str_starts_with($value, $placeholderUniquePrefix)) {
546                    return [];
547                }
548            }
549        }
550
551        return $value;
552    }
553
554    private function doValidateType($value): void
555    {
556        if (null !== $this->handlingPlaceholder && !$this->allowPlaceholders()) {
557            $e = new InvalidTypeException(sprintf('A dynamic value is not compatible with a "%s" node type at path "%s".', static::class, $this->getPath()));
558            $e->setPath($this->getPath());
559
560            throw $e;
561        }
562
563        if (null === $this->handlingPlaceholder || null === $value) {
564            $this->validateType($value);
565
566            return;
567        }
568
569        $knownTypes = array_keys(self::$placeholders[$this->handlingPlaceholder]);
570        $validTypes = $this->getValidPlaceholderTypes();
571
572        if ($validTypes && array_diff($knownTypes, $validTypes)) {
573            $e = new InvalidTypeException(sprintf(
574                'Invalid type for path "%s". Expected %s, but got %s.',
575                $this->getPath(),
576                1 === \count($validTypes) ? '"'.reset($validTypes).'"' : 'one of "'.implode('", "', $validTypes).'"',
577                1 === \count($knownTypes) ? '"'.reset($knownTypes).'"' : 'one of "'.implode('", "', $knownTypes).'"'
578            ));
579            if ($hint = $this->getInfo()) {
580                $e->addHint($hint);
581            }
582            $e->setPath($this->getPath());
583
584            throw $e;
585        }
586
587        $this->validateType($value);
588    }
589}
590