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    const DEFAULT_PATH_SEPARATOR = '.';
28
29    private static $placeholderUniquePrefix;
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 $deprecationMessage = null;
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 (false !== strpos($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     * Sets 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::$placeholderUniquePrefix = $prefix;
87    }
88
89    /**
90     * Resets all current placeholders available.
91     *
92     * @internal
93     */
94    public static function resetPlaceholders(): void
95    {
96        self::$placeholderUniquePrefix = null;
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 isset($this->attributes[$key]) ? $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 The info text
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 The example
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     * @param bool $boolean Required node
192     */
193    public function setRequired(bool $boolean)
194    {
195        $this->required = $boolean;
196    }
197
198    /**
199     * Sets this node as deprecated.
200     *
201     * You can use %node% and %path% placeholders in your message to display,
202     * respectively, the node name and its complete path.
203     */
204    public function setDeprecated(?string $message)
205    {
206        $this->deprecationMessage = $message;
207    }
208
209    /**
210     * Sets if this node can be overridden.
211     */
212    public function setAllowOverwrite(bool $allow)
213    {
214        $this->allowOverwrite = $allow;
215    }
216
217    /**
218     * Sets the closures used for normalization.
219     *
220     * @param \Closure[] $closures An array of Closures used for normalization
221     */
222    public function setNormalizationClosures(array $closures)
223    {
224        $this->normalizationClosures = $closures;
225    }
226
227    /**
228     * Sets the closures used for final validation.
229     *
230     * @param \Closure[] $closures An array of Closures used for final validation
231     */
232    public function setFinalValidationClosures(array $closures)
233    {
234        $this->finalValidationClosures = $closures;
235    }
236
237    /**
238     * {@inheritdoc}
239     */
240    public function isRequired()
241    {
242        return $this->required;
243    }
244
245    /**
246     * Checks if this node is deprecated.
247     *
248     * @return bool
249     */
250    public function isDeprecated()
251    {
252        return null !== $this->deprecationMessage;
253    }
254
255    /**
256     * Returns the deprecated message.
257     *
258     * @param string $node the configuration node name
259     * @param string $path the path of the node
260     *
261     * @return string
262     */
263    public function getDeprecationMessage(string $node, string $path)
264    {
265        return strtr($this->deprecationMessage, ['%node%' => $node, '%path%' => $path]);
266    }
267
268    /**
269     * {@inheritdoc}
270     */
271    public function getName()
272    {
273        return $this->name;
274    }
275
276    /**
277     * {@inheritdoc}
278     */
279    public function getPath()
280    {
281        if (null !== $this->parent) {
282            return $this->parent->getPath().$this->pathSeparator.$this->name;
283        }
284
285        return $this->name;
286    }
287
288    /**
289     * {@inheritdoc}
290     */
291    final public function merge($leftSide, $rightSide)
292    {
293        if (!$this->allowOverwrite) {
294            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()));
295        }
296
297        if ($leftSide !== $leftPlaceholders = self::resolvePlaceholderValue($leftSide)) {
298            foreach ($leftPlaceholders as $leftPlaceholder) {
299                $this->handlingPlaceholder = $leftSide;
300                try {
301                    $this->merge($leftPlaceholder, $rightSide);
302                } finally {
303                    $this->handlingPlaceholder = null;
304                }
305            }
306
307            return $rightSide;
308        }
309
310        if ($rightSide !== $rightPlaceholders = self::resolvePlaceholderValue($rightSide)) {
311            foreach ($rightPlaceholders as $rightPlaceholder) {
312                $this->handlingPlaceholder = $rightSide;
313                try {
314                    $this->merge($leftSide, $rightPlaceholder);
315                } finally {
316                    $this->handlingPlaceholder = null;
317                }
318            }
319
320            return $rightSide;
321        }
322
323        $this->doValidateType($leftSide);
324        $this->doValidateType($rightSide);
325
326        return $this->mergeValues($leftSide, $rightSide);
327    }
328
329    /**
330     * {@inheritdoc}
331     */
332    final public function normalize($value)
333    {
334        $value = $this->preNormalize($value);
335
336        // run custom normalization closures
337        foreach ($this->normalizationClosures as $closure) {
338            $value = $closure($value);
339        }
340
341        // resolve placeholder value
342        if ($value !== $placeholders = self::resolvePlaceholderValue($value)) {
343            foreach ($placeholders as $placeholder) {
344                $this->handlingPlaceholder = $value;
345                try {
346                    $this->normalize($placeholder);
347                } finally {
348                    $this->handlingPlaceholder = null;
349                }
350            }
351
352            return $value;
353        }
354
355        // replace value with their equivalent
356        foreach ($this->equivalentValues as $data) {
357            if ($data[0] === $value) {
358                $value = $data[1];
359            }
360        }
361
362        // validate type
363        $this->doValidateType($value);
364
365        // normalize value
366        return $this->normalizeValue($value);
367    }
368
369    /**
370     * Normalizes the value before any other normalization is applied.
371     *
372     * @param mixed $value
373     *
374     * @return mixed The normalized array value
375     */
376    protected function preNormalize($value)
377    {
378        return $value;
379    }
380
381    /**
382     * Returns parent node for this node.
383     *
384     * @return NodeInterface|null
385     */
386    public function getParent()
387    {
388        return $this->parent;
389    }
390
391    /**
392     * {@inheritdoc}
393     */
394    final public function finalize($value)
395    {
396        if ($value !== $placeholders = self::resolvePlaceholderValue($value)) {
397            foreach ($placeholders as $placeholder) {
398                $this->handlingPlaceholder = $value;
399                try {
400                    $this->finalize($placeholder);
401                } finally {
402                    $this->handlingPlaceholder = null;
403                }
404            }
405
406            return $value;
407        }
408
409        $this->doValidateType($value);
410
411        $value = $this->finalizeValue($value);
412
413        // Perform validation on the final value if a closure has been set.
414        // The closure is also allowed to return another value.
415        foreach ($this->finalValidationClosures as $closure) {
416            try {
417                $value = $closure($value);
418            } catch (Exception $e) {
419                if ($e instanceof UnsetKeyException && null !== $this->handlingPlaceholder) {
420                    continue;
421                }
422
423                throw $e;
424            } catch (\Exception $e) {
425                throw new InvalidConfigurationException(sprintf('Invalid configuration for path "%s": %s.', $this->getPath(), $e->getMessage()), $e->getCode(), $e);
426            }
427        }
428
429        return $value;
430    }
431
432    /**
433     * Validates the type of a Node.
434     *
435     * @param mixed $value The value to validate
436     *
437     * @throws InvalidTypeException when the value is invalid
438     */
439    abstract protected function validateType($value);
440
441    /**
442     * Normalizes the value.
443     *
444     * @param mixed $value The value to normalize
445     *
446     * @return mixed The normalized value
447     */
448    abstract protected function normalizeValue($value);
449
450    /**
451     * Merges two values together.
452     *
453     * @param mixed $leftSide
454     * @param mixed $rightSide
455     *
456     * @return mixed The merged value
457     */
458    abstract protected function mergeValues($leftSide, $rightSide);
459
460    /**
461     * Finalizes a value.
462     *
463     * @param mixed $value The value to finalize
464     *
465     * @return mixed The finalized value
466     */
467    abstract protected function finalizeValue($value);
468
469    /**
470     * Tests if placeholder values are allowed for this node.
471     */
472    protected function allowPlaceholders(): bool
473    {
474        return true;
475    }
476
477    /**
478     * Tests if a placeholder is being handled currently.
479     */
480    protected function isHandlingPlaceholder(): bool
481    {
482        return null !== $this->handlingPlaceholder;
483    }
484
485    /**
486     * Gets allowed dynamic types for this node.
487     */
488    protected function getValidPlaceholderTypes(): array
489    {
490        return [];
491    }
492
493    private static function resolvePlaceholderValue($value)
494    {
495        if (\is_string($value)) {
496            if (isset(self::$placeholders[$value])) {
497                return self::$placeholders[$value];
498            }
499
500            if (self::$placeholderUniquePrefix && 0 === strpos($value, self::$placeholderUniquePrefix)) {
501                return [];
502            }
503        }
504
505        return $value;
506    }
507
508    private function doValidateType($value): void
509    {
510        if (null !== $this->handlingPlaceholder && !$this->allowPlaceholders()) {
511            $e = new InvalidTypeException(sprintf('A dynamic value is not compatible with a "%s" node type at path "%s".', static::class, $this->getPath()));
512            $e->setPath($this->getPath());
513
514            throw $e;
515        }
516
517        if (null === $this->handlingPlaceholder || null === $value) {
518            $this->validateType($value);
519
520            return;
521        }
522
523        $knownTypes = array_keys(self::$placeholders[$this->handlingPlaceholder]);
524        $validTypes = $this->getValidPlaceholderTypes();
525
526        if ($validTypes && array_diff($knownTypes, $validTypes)) {
527            $e = new InvalidTypeException(sprintf(
528                'Invalid type for path "%s". Expected %s, but got %s.',
529                $this->getPath(),
530                1 === \count($validTypes) ? '"'.reset($validTypes).'"' : 'one of "'.implode('", "', $validTypes).'"',
531                1 === \count($knownTypes) ? '"'.reset($knownTypes).'"' : 'one of "'.implode('", "', $knownTypes).'"'
532            ));
533            if ($hint = $this->getInfo()) {
534                $e->addHint($hint);
535            }
536            $e->setPath($this->getPath());
537
538            throw $e;
539        }
540
541        $this->validateType($value);
542    }
543}
544