OptionsResolver::formatValue()   B
last analyzed

Complexity

Conditions 8
Paths 8

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 15
c 1
b 0
f 0
dl 0
loc 31
rs 8.4444
cc 8
nc 8
nop 1
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\OptionsResolver;
13
14
use Symfony\Component\OptionsResolver\Exception\AccessException;
15
use Symfony\Component\OptionsResolver\Exception\InvalidArgumentException;
16
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
17
use Symfony\Component\OptionsResolver\Exception\MissingOptionsException;
18
use Symfony\Component\OptionsResolver\Exception\NoSuchOptionException;
19
use Symfony\Component\OptionsResolver\Exception\OptionDefinitionException;
20
use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
21
22
/**
23
 * Validates options and merges them with default values.
24
 *
25
 * @author Bernhard Schussek <[email protected]>
26
 * @author Tobias Schultze <http://tobion.de>
27
 */
28
class OptionsResolver implements Options
29
{
30
    private const VALIDATION_FUNCTIONS = [
31
        'bool' => 'is_bool',
32
        'boolean' => 'is_bool',
33
        'int' => 'is_int',
34
        'integer' => 'is_int',
35
        'long' => 'is_int',
36
        'float' => 'is_float',
37
        'double' => 'is_float',
38
        'real' => 'is_float',
39
        'numeric' => 'is_numeric',
40
        'string' => 'is_string',
41
        'scalar' => 'is_scalar',
42
        'array' => 'is_array',
43
        'iterable' => 'is_iterable',
44
        'countable' => 'is_countable',
45
        'callable' => 'is_callable',
46
        'object' => 'is_object',
47
        'resource' => 'is_resource',
48
    ];
49
50
    /**
51
     * The names of all defined options.
52
     */
53
    private $defined = [];
54
55
    /**
56
     * The default option values.
57
     */
58
    private $defaults = [];
59
60
    /**
61
     * A list of closure for nested options.
62
     *
63
     * @var \Closure[][]
64
     */
65
    private $nested = [];
66
67
    /**
68
     * The names of required options.
69
     */
70
    private $required = [];
71
72
    /**
73
     * The resolved option values.
74
     */
75
    private $resolved = [];
76
77
    /**
78
     * A list of normalizer closures.
79
     *
80
     * @var \Closure[][]
81
     */
82
    private $normalizers = [];
83
84
    /**
85
     * A list of accepted values for each option.
86
     */
87
    private $allowedValues = [];
88
89
    /**
90
     * A list of accepted types for each option.
91
     */
92
    private $allowedTypes = [];
93
94
    /**
95
     * A list of info messages for each option.
96
     */
97
    private $info = [];
98
99
    /**
100
     * A list of closures for evaluating lazy options.
101
     */
102
    private $lazy = [];
103
104
    /**
105
     * A list of lazy options whose closure is currently being called.
106
     *
107
     * This list helps detecting circular dependencies between lazy options.
108
     */
109
    private $calling = [];
110
111
    /**
112
     * A list of deprecated options.
113
     */
114
    private $deprecated = [];
115
116
    /**
117
     * The list of options provided by the user.
118
     */
119
    private $given = [];
120
121
    /**
122
     * Whether the instance is locked for reading.
123
     *
124
     * Once locked, the options cannot be changed anymore. This is
125
     * necessary in order to avoid inconsistencies during the resolving
126
     * process. If any option is changed after being read, all evaluated
127
     * lazy options that depend on this option would become invalid.
128
     */
129
    private $locked = false;
130
131
    private $parentsOptions = [];
132
133
    /**
134
     * Whether the whole options definition is marked as array prototype.
135
     */
136
    private $prototype;
137
138
    /**
139
     * The prototype array's index that is being read.
140
     */
141
    private $prototypeIndex;
142
143
    /**
144
     * Sets the default value of a given option.
145
     *
146
     * If the default value should be set based on other options, you can pass
147
     * a closure with the following signature:
148
     *
149
     *     function (Options $options) {
150
     *         // ...
151
     *     }
152
     *
153
     * The closure will be evaluated when {@link resolve()} is called. The
154
     * closure has access to the resolved values of other options through the
155
     * passed {@link Options} instance:
156
     *
157
     *     function (Options $options) {
158
     *         if (isset($options['port'])) {
159
     *             // ...
160
     *         }
161
     *     }
162
     *
163
     * If you want to access the previously set default value, add a second
164
     * argument to the closure's signature:
165
     *
166
     *     $options->setDefault('name', 'Default Name');
167
     *
168
     *     $options->setDefault('name', function (Options $options, $previousValue) {
169
     *         // 'Default Name' === $previousValue
170
     *     });
171
     *
172
     * This is mostly useful if the configuration of the {@link Options} object
173
     * is spread across different locations of your code, such as base and
174
     * sub-classes.
175
     *
176
     * If you want to define nested options, you can pass a closure with the
177
     * following signature:
178
     *
179
     *     $options->setDefault('database', function (OptionsResolver $resolver) {
180
     *         $resolver->setDefined(['dbname', 'host', 'port', 'user', 'pass']);
181
     *     }
182
     *
183
     * To get access to the parent options, add a second argument to the closure's
184
     * signature:
185
     *
186
     *     function (OptionsResolver $resolver, Options $parent) {
187
     *         // 'default' === $parent['connection']
188
     *     }
189
     *
190
     * @param string $option The name of the option
191
     * @param mixed  $value  The default value of the option
192
     *
193
     * @return $this
194
     *
195
     * @throws AccessException If called from a lazy option or normalizer
196
     */
197
    public function setDefault(string $option, $value)
198
    {
199
        // Setting is not possible once resolving starts, because then lazy
200
        // options could manipulate the state of the object, leading to
201
        // inconsistent results.
202
        if ($this->locked) {
203
            throw new AccessException('Default values cannot be set from a lazy option or normalizer.');
204
        }
205
206
        // If an option is a closure that should be evaluated lazily, store it
207
        // in the "lazy" property.
208
        if ($value instanceof \Closure) {
209
            $reflClosure = new \ReflectionFunction($value);
210
            $params = $reflClosure->getParameters();
211
212
            if (isset($params[0]) && Options::class === $this->getParameterClassName($params[0])) {
213
                // Initialize the option if no previous value exists
214
                if (!isset($this->defaults[$option])) {
215
                    $this->defaults[$option] = null;
216
                }
217
218
                // Ignore previous lazy options if the closure has no second parameter
219
                if (!isset($this->lazy[$option]) || !isset($params[1])) {
220
                    $this->lazy[$option] = [];
221
                }
222
223
                // Store closure for later evaluation
224
                $this->lazy[$option][] = $value;
225
                $this->defined[$option] = true;
226
227
                // Make sure the option is processed and is not nested anymore
228
                unset($this->resolved[$option], $this->nested[$option]);
229
230
                return $this;
231
            }
232
233
            if (isset($params[0]) && null !== ($type = $params[0]->getType()) && self::class === $type->getName() && (!isset($params[1]) || (($type = $params[1]->getType()) instanceof \ReflectionNamedType && Options::class === $type->getName()))) {
234
                // Store closure for later evaluation
235
                $this->nested[$option][] = $value;
236
                $this->defaults[$option] = [];
237
                $this->defined[$option] = true;
238
239
                // Make sure the option is processed and is not lazy anymore
240
                unset($this->resolved[$option], $this->lazy[$option]);
241
242
                return $this;
243
            }
244
        }
245
246
        // This option is not lazy nor nested anymore
247
        unset($this->lazy[$option], $this->nested[$option]);
248
249
        // Yet undefined options can be marked as resolved, because we only need
250
        // to resolve options with lazy closures, normalizers or validation
251
        // rules, none of which can exist for undefined options
252
        // If the option was resolved before, update the resolved value
253
        if (!isset($this->defined[$option]) || \array_key_exists($option, $this->resolved)) {
254
            $this->resolved[$option] = $value;
255
        }
256
257
        $this->defaults[$option] = $value;
258
        $this->defined[$option] = true;
259
260
        return $this;
261
    }
262
263
    /**
264
     * Sets a list of default values.
265
     *
266
     * @param array $defaults The default values to set
267
     *
268
     * @return $this
269
     *
270
     * @throws AccessException If called from a lazy option or normalizer
271
     */
272
    public function setDefaults(array $defaults)
273
    {
274
        foreach ($defaults as $option => $value) {
275
            $this->setDefault($option, $value);
276
        }
277
278
        return $this;
279
    }
280
281
    /**
282
     * Returns whether a default value is set for an option.
283
     *
284
     * Returns true if {@link setDefault()} was called for this option.
285
     * An option is also considered set if it was set to null.
286
     *
287
     * @param string $option The option name
288
     *
289
     * @return bool Whether a default value is set
290
     */
291
    public function hasDefault(string $option)
292
    {
293
        return \array_key_exists($option, $this->defaults);
294
    }
295
296
    /**
297
     * Marks one or more options as required.
298
     *
299
     * @param string|string[] $optionNames One or more option names
300
     *
301
     * @return $this
302
     *
303
     * @throws AccessException If called from a lazy option or normalizer
304
     */
305
    public function setRequired($optionNames)
306
    {
307
        if ($this->locked) {
308
            throw new AccessException('Options cannot be made required from a lazy option or normalizer.');
309
        }
310
311
        foreach ((array) $optionNames as $option) {
312
            $this->defined[$option] = true;
313
            $this->required[$option] = true;
314
        }
315
316
        return $this;
317
    }
318
319
    /**
320
     * Returns whether an option is required.
321
     *
322
     * An option is required if it was passed to {@link setRequired()}.
323
     *
324
     * @param string $option The name of the option
325
     *
326
     * @return bool Whether the option is required
327
     */
328
    public function isRequired(string $option)
329
    {
330
        return isset($this->required[$option]);
331
    }
332
333
    /**
334
     * Returns the names of all required options.
335
     *
336
     * @return string[] The names of the required options
337
     *
338
     * @see isRequired()
339
     */
340
    public function getRequiredOptions()
341
    {
342
        return array_keys($this->required);
343
    }
344
345
    /**
346
     * Returns whether an option is missing a default value.
347
     *
348
     * An option is missing if it was passed to {@link setRequired()}, but not
349
     * to {@link setDefault()}. This option must be passed explicitly to
350
     * {@link resolve()}, otherwise an exception will be thrown.
351
     *
352
     * @param string $option The name of the option
353
     *
354
     * @return bool Whether the option is missing
355
     */
356
    public function isMissing(string $option)
357
    {
358
        return isset($this->required[$option]) && !\array_key_exists($option, $this->defaults);
359
    }
360
361
    /**
362
     * Returns the names of all options missing a default value.
363
     *
364
     * @return string[] The names of the missing options
365
     *
366
     * @see isMissing()
367
     */
368
    public function getMissingOptions()
369
    {
370
        return array_keys(array_diff_key($this->required, $this->defaults));
371
    }
372
373
    /**
374
     * Defines a valid option name.
375
     *
376
     * Defines an option name without setting a default value. The option will
377
     * be accepted when passed to {@link resolve()}. When not passed, the
378
     * option will not be included in the resolved options.
379
     *
380
     * @param string|string[] $optionNames One or more option names
381
     *
382
     * @return $this
383
     *
384
     * @throws AccessException If called from a lazy option or normalizer
385
     */
386
    public function setDefined($optionNames)
387
    {
388
        if ($this->locked) {
389
            throw new AccessException('Options cannot be defined from a lazy option or normalizer.');
390
        }
391
392
        foreach ((array) $optionNames as $option) {
393
            $this->defined[$option] = true;
394
        }
395
396
        return $this;
397
    }
398
399
    /**
400
     * Returns whether an option is defined.
401
     *
402
     * Returns true for any option passed to {@link setDefault()},
403
     * {@link setRequired()} or {@link setDefined()}.
404
     *
405
     * @param string $option The option name
406
     *
407
     * @return bool Whether the option is defined
408
     */
409
    public function isDefined(string $option)
410
    {
411
        return isset($this->defined[$option]);
412
    }
413
414
    /**
415
     * Returns the names of all defined options.
416
     *
417
     * @return string[] The names of the defined options
418
     *
419
     * @see isDefined()
420
     */
421
    public function getDefinedOptions()
422
    {
423
        return array_keys($this->defined);
424
    }
425
426
    public function isNested(string $option): bool
427
    {
428
        return isset($this->nested[$option]);
429
    }
430
431
    /**
432
     * Deprecates an option, allowed types or values.
433
     *
434
     * Instead of passing the message, you may also pass a closure with the
435
     * following signature:
436
     *
437
     *     function (Options $options, $value): string {
438
     *         // ...
439
     *     }
440
     *
441
     * The closure receives the value as argument and should return a string.
442
     * Return an empty string to ignore the option deprecation.
443
     *
444
     * The closure is invoked when {@link resolve()} is called. The parameter
445
     * passed to the closure is the value of the option after validating it
446
     * and before normalizing it.
447
     *
448
     * @param string          $package The name of the composer package that is triggering the deprecation
449
     * @param string          $version The version of the package that introduced the deprecation
450
     * @param string|\Closure $message The deprecation message to use
451
     */
452
    public function setDeprecated(string $option/*, string $package, string $version, $message = 'The option "%name%" is deprecated.' */): self
453
    {
454
        if ($this->locked) {
455
            throw new AccessException('Options cannot be deprecated from a lazy option or normalizer.');
456
        }
457
458
        if (!isset($this->defined[$option])) {
459
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist, defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
460
        }
461
462
        $args = \func_get_args();
463
464
        if (\func_num_args() < 3) {
465
            trigger_deprecation('symfony/options-resolver', '5.1', 'The signature of method "%s()" requires 2 new arguments: "string $package, string $version", not defining them is deprecated.', __METHOD__);
466
467
            $message = $args[1] ?? 'The option "%name%" is deprecated.';
468
            $package = $version = '';
469
        } else {
470
            $package = $args[1];
471
            $version = $args[2];
472
            $message = $args[3] ?? 'The option "%name%" is deprecated.';
473
        }
474
475
        if (!\is_string($message) && !$message instanceof \Closure) {
476
            throw new InvalidArgumentException(sprintf('Invalid type for deprecation message argument, expected string or \Closure, but got "%s".', get_debug_type($message)));
477
        }
478
479
        // ignore if empty string
480
        if ('' === $message) {
481
            return $this;
482
        }
483
484
        $this->deprecated[$option] = [
485
            'package' => $package,
486
            'version' => $version,
487
            'message' => $message,
488
        ];
489
490
        // Make sure the option is processed
491
        unset($this->resolved[$option]);
492
493
        return $this;
494
    }
495
496
    public function isDeprecated(string $option): bool
497
    {
498
        return isset($this->deprecated[$option]);
499
    }
500
501
    /**
502
     * Sets the normalizer for an option.
503
     *
504
     * The normalizer should be a closure with the following signature:
505
     *
506
     *     function (Options $options, $value) {
507
     *         // ...
508
     *     }
509
     *
510
     * The closure is invoked when {@link resolve()} is called. The closure
511
     * has access to the resolved values of other options through the passed
512
     * {@link Options} instance.
513
     *
514
     * The second parameter passed to the closure is the value of
515
     * the option.
516
     *
517
     * The resolved option value is set to the return value of the closure.
518
     *
519
     * @param string   $option     The option name
520
     * @param \Closure $normalizer The normalizer
521
     *
522
     * @return $this
523
     *
524
     * @throws UndefinedOptionsException If the option is undefined
525
     * @throws AccessException           If called from a lazy option or normalizer
526
     */
527
    public function setNormalizer(string $option, \Closure $normalizer)
528
    {
529
        if ($this->locked) {
530
            throw new AccessException('Normalizers cannot be set from a lazy option or normalizer.');
531
        }
532
533
        if (!isset($this->defined[$option])) {
534
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
535
        }
536
537
        $this->normalizers[$option] = [$normalizer];
538
539
        // Make sure the option is processed
540
        unset($this->resolved[$option]);
541
542
        return $this;
543
    }
544
545
    /**
546
     * Adds a normalizer for an option.
547
     *
548
     * The normalizer should be a closure with the following signature:
549
     *
550
     *     function (Options $options, $value): mixed {
551
     *         // ...
552
     *     }
553
     *
554
     * The closure is invoked when {@link resolve()} is called. The closure
555
     * has access to the resolved values of other options through the passed
556
     * {@link Options} instance.
557
     *
558
     * The second parameter passed to the closure is the value of
559
     * the option.
560
     *
561
     * The resolved option value is set to the return value of the closure.
562
     *
563
     * @param string   $option       The option name
564
     * @param \Closure $normalizer   The normalizer
565
     * @param bool     $forcePrepend If set to true, prepend instead of appending
566
     *
567
     * @return $this
568
     *
569
     * @throws UndefinedOptionsException If the option is undefined
570
     * @throws AccessException           If called from a lazy option or normalizer
571
     */
572
    public function addNormalizer(string $option, \Closure $normalizer, bool $forcePrepend = false): self
573
    {
574
        if ($this->locked) {
575
            throw new AccessException('Normalizers cannot be set from a lazy option or normalizer.');
576
        }
577
578
        if (!isset($this->defined[$option])) {
579
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
580
        }
581
582
        if ($forcePrepend) {
583
            $this->normalizers[$option] = $this->normalizers[$option] ?? [];
584
            array_unshift($this->normalizers[$option], $normalizer);
585
        } else {
586
            $this->normalizers[$option][] = $normalizer;
587
        }
588
589
        // Make sure the option is processed
590
        unset($this->resolved[$option]);
591
592
        return $this;
593
    }
594
595
    /**
596
     * Sets allowed values for an option.
597
     *
598
     * Instead of passing values, you may also pass a closures with the
599
     * following signature:
600
     *
601
     *     function ($value) {
602
     *         // return true or false
603
     *     }
604
     *
605
     * The closure receives the value as argument and should return true to
606
     * accept the value and false to reject the value.
607
     *
608
     * @param string $option        The option name
609
     * @param mixed  $allowedValues One or more acceptable values/closures
610
     *
611
     * @return $this
612
     *
613
     * @throws UndefinedOptionsException If the option is undefined
614
     * @throws AccessException           If called from a lazy option or normalizer
615
     */
616
    public function setAllowedValues(string $option, $allowedValues)
617
    {
618
        if ($this->locked) {
619
            throw new AccessException('Allowed values cannot be set from a lazy option or normalizer.');
620
        }
621
622
        if (!isset($this->defined[$option])) {
623
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
624
        }
625
626
        $this->allowedValues[$option] = \is_array($allowedValues) ? $allowedValues : [$allowedValues];
627
628
        // Make sure the option is processed
629
        unset($this->resolved[$option]);
630
631
        return $this;
632
    }
633
634
    /**
635
     * Adds allowed values for an option.
636
     *
637
     * The values are merged with the allowed values defined previously.
638
     *
639
     * Instead of passing values, you may also pass a closures with the
640
     * following signature:
641
     *
642
     *     function ($value) {
643
     *         // return true or false
644
     *     }
645
     *
646
     * The closure receives the value as argument and should return true to
647
     * accept the value and false to reject the value.
648
     *
649
     * @param string $option        The option name
650
     * @param mixed  $allowedValues One or more acceptable values/closures
651
     *
652
     * @return $this
653
     *
654
     * @throws UndefinedOptionsException If the option is undefined
655
     * @throws AccessException           If called from a lazy option or normalizer
656
     */
657
    public function addAllowedValues(string $option, $allowedValues)
658
    {
659
        if ($this->locked) {
660
            throw new AccessException('Allowed values cannot be added from a lazy option or normalizer.');
661
        }
662
663
        if (!isset($this->defined[$option])) {
664
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
665
        }
666
667
        if (!\is_array($allowedValues)) {
668
            $allowedValues = [$allowedValues];
669
        }
670
671
        if (!isset($this->allowedValues[$option])) {
672
            $this->allowedValues[$option] = $allowedValues;
673
        } else {
674
            $this->allowedValues[$option] = array_merge($this->allowedValues[$option], $allowedValues);
675
        }
676
677
        // Make sure the option is processed
678
        unset($this->resolved[$option]);
679
680
        return $this;
681
    }
682
683
    /**
684
     * Sets allowed types for an option.
685
     *
686
     * Any type for which a corresponding is_<type>() function exists is
687
     * acceptable. Additionally, fully-qualified class or interface names may
688
     * be passed.
689
     *
690
     * @param string          $option       The option name
691
     * @param string|string[] $allowedTypes One or more accepted types
692
     *
693
     * @return $this
694
     *
695
     * @throws UndefinedOptionsException If the option is undefined
696
     * @throws AccessException           If called from a lazy option or normalizer
697
     */
698
    public function setAllowedTypes(string $option, $allowedTypes)
699
    {
700
        if ($this->locked) {
701
            throw new AccessException('Allowed types cannot be set from a lazy option or normalizer.');
702
        }
703
704
        if (!isset($this->defined[$option])) {
705
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
706
        }
707
708
        $this->allowedTypes[$option] = (array) $allowedTypes;
709
710
        // Make sure the option is processed
711
        unset($this->resolved[$option]);
712
713
        return $this;
714
    }
715
716
    /**
717
     * Adds allowed types for an option.
718
     *
719
     * The types are merged with the allowed types defined previously.
720
     *
721
     * Any type for which a corresponding is_<type>() function exists is
722
     * acceptable. Additionally, fully-qualified class or interface names may
723
     * be passed.
724
     *
725
     * @param string          $option       The option name
726
     * @param string|string[] $allowedTypes One or more accepted types
727
     *
728
     * @return $this
729
     *
730
     * @throws UndefinedOptionsException If the option is undefined
731
     * @throws AccessException           If called from a lazy option or normalizer
732
     */
733
    public function addAllowedTypes(string $option, $allowedTypes)
734
    {
735
        if ($this->locked) {
736
            throw new AccessException('Allowed types cannot be added from a lazy option or normalizer.');
737
        }
738
739
        if (!isset($this->defined[$option])) {
740
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
741
        }
742
743
        if (!isset($this->allowedTypes[$option])) {
744
            $this->allowedTypes[$option] = (array) $allowedTypes;
745
        } else {
746
            $this->allowedTypes[$option] = array_merge($this->allowedTypes[$option], (array) $allowedTypes);
747
        }
748
749
        // Make sure the option is processed
750
        unset($this->resolved[$option]);
751
752
        return $this;
753
    }
754
755
    /**
756
     * Defines an option configurator with the given name.
757
     */
758
    public function define(string $option): OptionConfigurator
759
    {
760
        if (isset($this->defined[$option])) {
761
            throw new OptionDefinitionException(sprintf('The option "%s" is already defined.', $option));
762
        }
763
764
        return new OptionConfigurator($option, $this);
765
    }
766
767
    /**
768
     * Sets an info message for an option.
769
     *
770
     * @return $this
771
     *
772
     * @throws UndefinedOptionsException If the option is undefined
773
     * @throws AccessException           If called from a lazy option or normalizer
774
     */
775
    public function setInfo(string $option, string $info): self
776
    {
777
        if ($this->locked) {
778
            throw new AccessException('The Info message cannot be set from a lazy option or normalizer.');
779
        }
780
781
        if (!isset($this->defined[$option])) {
782
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
783
        }
784
785
        $this->info[$option] = $info;
786
787
        return $this;
788
    }
789
790
    /**
791
     * Gets the info message for an option.
792
     */
793
    public function getInfo(string $option): ?string
794
    {
795
        if (!isset($this->defined[$option])) {
796
            throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
797
        }
798
799
        return $this->info[$option] ?? null;
800
    }
801
802
    /**
803
     * Marks the whole options definition as array prototype.
804
     *
805
     * @return $this
806
     *
807
     * @throws AccessException If called from a lazy option, a normalizer or a root definition
808
     */
809
    public function setPrototype(bool $prototype): self
810
    {
811
        if ($this->locked) {
812
            throw new AccessException('The prototype property cannot be set from a lazy option or normalizer.');
813
        }
814
815
        if (null === $this->prototype && $prototype) {
816
            throw new AccessException('The prototype property cannot be set from a root definition.');
817
        }
818
819
        $this->prototype = $prototype;
820
821
        return $this;
822
    }
823
824
    public function isPrototype(): bool
825
    {
826
        return $this->prototype ?? false;
827
    }
828
829
    /**
830
     * Removes the option with the given name.
831
     *
832
     * Undefined options are ignored.
833
     *
834
     * @param string|string[] $optionNames One or more option names
835
     *
836
     * @return $this
837
     *
838
     * @throws AccessException If called from a lazy option or normalizer
839
     */
840
    public function remove($optionNames)
841
    {
842
        if ($this->locked) {
843
            throw new AccessException('Options cannot be removed from a lazy option or normalizer.');
844
        }
845
846
        foreach ((array) $optionNames as $option) {
847
            unset($this->defined[$option], $this->defaults[$option], $this->required[$option], $this->resolved[$option]);
848
            unset($this->lazy[$option], $this->normalizers[$option], $this->allowedTypes[$option], $this->allowedValues[$option], $this->info[$option]);
849
        }
850
851
        return $this;
852
    }
853
854
    /**
855
     * Removes all options.
856
     *
857
     * @return $this
858
     *
859
     * @throws AccessException If called from a lazy option or normalizer
860
     */
861
    public function clear()
862
    {
863
        if ($this->locked) {
864
            throw new AccessException('Options cannot be cleared from a lazy option or normalizer.');
865
        }
866
867
        $this->defined = [];
868
        $this->defaults = [];
869
        $this->nested = [];
870
        $this->required = [];
871
        $this->resolved = [];
872
        $this->lazy = [];
873
        $this->normalizers = [];
874
        $this->allowedTypes = [];
875
        $this->allowedValues = [];
876
        $this->deprecated = [];
877
        $this->info = [];
878
879
        return $this;
880
    }
881
882
    /**
883
     * Merges options with the default values stored in the container and
884
     * validates them.
885
     *
886
     * Exceptions are thrown if:
887
     *
888
     *  - Undefined options are passed;
889
     *  - Required options are missing;
890
     *  - Options have invalid types;
891
     *  - Options have invalid values.
892
     *
893
     * @param array $options A map of option names to values
894
     *
895
     * @return array The merged and validated options
896
     *
897
     * @throws UndefinedOptionsException If an option name is undefined
898
     * @throws InvalidOptionsException   If an option doesn't fulfill the
899
     *                                   specified validation rules
900
     * @throws MissingOptionsException   If a required option is missing
901
     * @throws OptionDefinitionException If there is a cyclic dependency between
902
     *                                   lazy options and/or normalizers
903
     * @throws NoSuchOptionException     If a lazy option reads an unavailable option
904
     * @throws AccessException           If called from a lazy option or normalizer
905
     */
906
    public function resolve(array $options = [])
907
    {
908
        if ($this->locked) {
909
            throw new AccessException('Options cannot be resolved from a lazy option or normalizer.');
910
        }
911
912
        // Allow this method to be called multiple times
913
        $clone = clone $this;
914
915
        // Make sure that no unknown options are passed
916
        $diff = array_diff_key($options, $clone->defined);
917
918
        if (\count($diff) > 0) {
919
            ksort($clone->defined);
920
            ksort($diff);
921
922
            throw new UndefinedOptionsException(sprintf((\count($diff) > 1 ? 'The options "%s" do not exist.' : 'The option "%s" does not exist.').' Defined options are: "%s".', $this->formatOptions(array_keys($diff)), implode('", "', array_keys($clone->defined))));
923
        }
924
925
        // Override options set by the user
926
        foreach ($options as $option => $value) {
927
            $clone->given[$option] = true;
928
            $clone->defaults[$option] = $value;
929
            unset($clone->resolved[$option], $clone->lazy[$option]);
930
        }
931
932
        // Check whether any required option is missing
933
        $diff = array_diff_key($clone->required, $clone->defaults);
934
935
        if (\count($diff) > 0) {
936
            ksort($diff);
937
938
            throw new MissingOptionsException(sprintf(\count($diff) > 1 ? 'The required options "%s" are missing.' : 'The required option "%s" is missing.', $this->formatOptions(array_keys($diff))));
939
        }
940
941
        // Lock the container
942
        $clone->locked = true;
943
944
        // Now process the individual options. Use offsetGet(), which resolves
945
        // the option itself and any options that the option depends on
946
        foreach ($clone->defaults as $option => $_) {
947
            $clone->offsetGet($option);
948
        }
949
950
        return $clone->resolved;
951
    }
952
953
    /**
954
     * Returns the resolved value of an option.
955
     *
956
     * @param string $option             The option name
957
     * @param bool   $triggerDeprecation Whether to trigger the deprecation or not (true by default)
958
     *
959
     * @return mixed The option value
960
     *
961
     * @throws AccessException           If accessing this method outside of
962
     *                                   {@link resolve()}
963
     * @throws NoSuchOptionException     If the option is not set
964
     * @throws InvalidOptionsException   If the option doesn't fulfill the
965
     *                                   specified validation rules
966
     * @throws OptionDefinitionException If there is a cyclic dependency between
967
     *                                   lazy options and/or normalizers
968
     */
969
    public function offsetGet($option, bool $triggerDeprecation = true)
970
    {
971
        if (!$this->locked) {
972
            throw new AccessException('Array access is only supported within closures of lazy options and normalizers.');
973
        }
974
975
        // Shortcut for resolved options
976
        if (isset($this->resolved[$option]) || \array_key_exists($option, $this->resolved)) {
977
            if ($triggerDeprecation && isset($this->deprecated[$option]) && (isset($this->given[$option]) || $this->calling) && \is_string($this->deprecated[$option]['message'])) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->calling of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
978
                trigger_deprecation($this->deprecated[$option]['package'], $this->deprecated[$option]['version'], strtr($this->deprecated[$option]['message'], ['%name%' => $option]));
979
            }
980
981
            return $this->resolved[$option];
982
        }
983
984
        // Check whether the option is set at all
985
        if (!isset($this->defaults[$option]) && !\array_key_exists($option, $this->defaults)) {
986
            if (!isset($this->defined[$option])) {
987
                throw new NoSuchOptionException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined))));
988
            }
989
990
            throw new NoSuchOptionException(sprintf('The optional option "%s" has no value set. You should make sure it is set with "isset" before reading it.', $this->formatOptions([$option])));
991
        }
992
993
        $value = $this->defaults[$option];
994
995
        // Resolve the option if it is a nested definition
996
        if (isset($this->nested[$option])) {
997
            // If the closure is already being called, we have a cyclic dependency
998
            if (isset($this->calling[$option])) {
999
                throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling))));
1000
            }
1001
1002
            if (!\is_array($value)) {
1003
                throw new InvalidOptionsException(sprintf('The nested option "%s" with value %s is expected to be of type array, but is of type "%s".', $this->formatOptions([$option]), $this->formatValue($value), get_debug_type($value)));
1004
            }
1005
1006
            // The following section must be protected from cyclic calls.
1007
            $this->calling[$option] = true;
1008
            try {
1009
                $resolver = new self();
1010
                $resolver->prototype = false;
1011
                $resolver->parentsOptions = $this->parentsOptions;
1012
                $resolver->parentsOptions[] = $option;
1013
                foreach ($this->nested[$option] as $closure) {
1014
                    $closure($resolver, $this);
1015
                }
1016
1017
                if ($resolver->prototype) {
1018
                    $values = [];
1019
                    foreach ($value as $index => $prototypeValue) {
1020
                        if (!\is_array($prototypeValue)) {
1021
                            throw new InvalidOptionsException(sprintf('The value of the option "%s" is expected to be of type array of array, but is of type array of "%s".', $this->formatOptions([$option]), get_debug_type($prototypeValue)));
1022
                        }
1023
1024
                        $resolver->prototypeIndex = $index;
1025
                        $values[$index] = $resolver->resolve($prototypeValue);
1026
                    }
1027
                    $value = $values;
1028
                } else {
1029
                    $value = $resolver->resolve($value);
1030
                }
1031
            } finally {
1032
                $resolver->prototypeIndex = null;
1033
                unset($this->calling[$option]);
1034
            }
1035
        }
1036
1037
        // Resolve the option if the default value is lazily evaluated
1038
        if (isset($this->lazy[$option])) {
1039
            // If the closure is already being called, we have a cyclic
1040
            // dependency
1041
            if (isset($this->calling[$option])) {
1042
                throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling))));
1043
            }
1044
1045
            // The following section must be protected from cyclic
1046
            // calls. Set $calling for the current $option to detect a cyclic
1047
            // dependency
1048
            // BEGIN
1049
            $this->calling[$option] = true;
1050
            try {
1051
                foreach ($this->lazy[$option] as $closure) {
1052
                    $value = $closure($this, $value);
1053
                }
1054
            } finally {
1055
                unset($this->calling[$option]);
1056
            }
1057
            // END
1058
        }
1059
1060
        // Validate the type of the resolved option
1061
        if (isset($this->allowedTypes[$option])) {
1062
            $valid = true;
1063
            $invalidTypes = [];
1064
1065
            foreach ($this->allowedTypes[$option] as $type) {
1066
                if ($valid = $this->verifyTypes($type, $value, $invalidTypes)) {
1067
                    break;
1068
                }
1069
            }
1070
1071
            if (!$valid) {
1072
                $fmtActualValue = $this->formatValue($value);
1073
                $fmtAllowedTypes = implode('" or "', $this->allowedTypes[$option]);
1074
                $fmtProvidedTypes = implode('|', array_keys($invalidTypes));
1075
                $allowedContainsArrayType = \count(array_filter($this->allowedTypes[$option], static function ($item) {
1076
                    return '[]' === substr($item, -2);
1077
                })) > 0;
1078
1079
                if (\is_array($value) && $allowedContainsArrayType) {
1080
                    throw new InvalidOptionsException(sprintf('The option "%s" with value %s is expected to be of type "%s", but one of the elements is of type "%s".', $this->formatOptions([$option]), $fmtActualValue, $fmtAllowedTypes, $fmtProvidedTypes));
1081
                }
1082
1083
                throw new InvalidOptionsException(sprintf('The option "%s" with value %s is expected to be of type "%s", but is of type "%s".', $this->formatOptions([$option]), $fmtActualValue, $fmtAllowedTypes, $fmtProvidedTypes));
1084
            }
1085
        }
1086
1087
        // Validate the value of the resolved option
1088
        if (isset($this->allowedValues[$option])) {
1089
            $success = false;
1090
            $printableAllowedValues = [];
1091
1092
            foreach ($this->allowedValues[$option] as $allowedValue) {
1093
                if ($allowedValue instanceof \Closure) {
1094
                    if ($allowedValue($value)) {
1095
                        $success = true;
1096
                        break;
1097
                    }
1098
1099
                    // Don't include closures in the exception message
1100
                    continue;
1101
                }
1102
1103
                if ($value === $allowedValue) {
1104
                    $success = true;
1105
                    break;
1106
                }
1107
1108
                $printableAllowedValues[] = $allowedValue;
1109
            }
1110
1111
            if (!$success) {
1112
                $message = sprintf(
1113
                    'The option "%s" with value %s is invalid.',
1114
                    $option,
1115
                    $this->formatValue($value)
1116
                );
1117
1118
                if (\count($printableAllowedValues) > 0) {
1119
                    $message .= sprintf(
1120
                        ' Accepted values are: %s.',
1121
                        $this->formatValues($printableAllowedValues)
1122
                    );
1123
                }
1124
1125
                if (isset($this->info[$option])) {
1126
                    $message .= sprintf(' Info: %s.', $this->info[$option]);
1127
                }
1128
1129
                throw new InvalidOptionsException($message);
1130
            }
1131
        }
1132
1133
        // Check whether the option is deprecated
1134
        // and it is provided by the user or is being called from a lazy evaluation
1135
        if ($triggerDeprecation && isset($this->deprecated[$option]) && (isset($this->given[$option]) || ($this->calling && \is_string($this->deprecated[$option]['message'])))) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->calling of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1136
            $deprecation = $this->deprecated[$option];
1137
            $message = $this->deprecated[$option]['message'];
1138
1139
            if ($message instanceof \Closure) {
1140
                // If the closure is already being called, we have a cyclic dependency
1141
                if (isset($this->calling[$option])) {
1142
                    throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling))));
1143
                }
1144
1145
                $this->calling[$option] = true;
1146
                try {
1147
                    if (!\is_string($message = $message($this, $value))) {
1148
                        throw new InvalidOptionsException(sprintf('Invalid type for deprecation message, expected string but got "%s", return an empty string to ignore.', get_debug_type($message)));
1149
                    }
1150
                } finally {
1151
                    unset($this->calling[$option]);
1152
                }
1153
            }
1154
1155
            if ('' !== $message) {
1156
                trigger_deprecation($deprecation['package'], $deprecation['version'], strtr($message, ['%name%' => $option]));
1157
            }
1158
        }
1159
1160
        // Normalize the validated option
1161
        if (isset($this->normalizers[$option])) {
1162
            // If the closure is already being called, we have a cyclic
1163
            // dependency
1164
            if (isset($this->calling[$option])) {
1165
                throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling))));
1166
            }
1167
1168
            // The following section must be protected from cyclic
1169
            // calls. Set $calling for the current $option to detect a cyclic
1170
            // dependency
1171
            // BEGIN
1172
            $this->calling[$option] = true;
1173
            try {
1174
                foreach ($this->normalizers[$option] as $normalizer) {
1175
                    $value = $normalizer($this, $value);
1176
                }
1177
            } finally {
1178
                unset($this->calling[$option]);
1179
            }
1180
            // END
1181
        }
1182
1183
        // Mark as resolved
1184
        $this->resolved[$option] = $value;
1185
1186
        return $value;
1187
    }
1188
1189
    private function verifyTypes(string $type, $value, array &$invalidTypes, int $level = 0): bool
1190
    {
1191
        if (\is_array($value) && '[]' === substr($type, -2)) {
1192
            $type = substr($type, 0, -2);
1193
            $valid = true;
1194
1195
            foreach ($value as $val) {
1196
                if (!$this->verifyTypes($type, $val, $invalidTypes, $level + 1)) {
1197
                    $valid = false;
1198
                }
1199
            }
1200
1201
            return $valid;
1202
        }
1203
1204
        if (('null' === $type && null === $value) || (isset(self::VALIDATION_FUNCTIONS[$type]) ? self::VALIDATION_FUNCTIONS[$type]($value) : $value instanceof $type)) {
1205
            return true;
1206
        }
1207
1208
        if (!$invalidTypes || $level > 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $invalidTypes of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1209
            $invalidTypes[get_debug_type($value)] = true;
1210
        }
1211
1212
        return false;
1213
    }
1214
1215
    /**
1216
     * Returns whether a resolved option with the given name exists.
1217
     *
1218
     * @param string $option The option name
1219
     *
1220
     * @return bool Whether the option is set
1221
     *
1222
     * @throws AccessException If accessing this method outside of {@link resolve()}
1223
     *
1224
     * @see \ArrayAccess::offsetExists()
1225
     */
1226
    public function offsetExists($option)
1227
    {
1228
        if (!$this->locked) {
1229
            throw new AccessException('Array access is only supported within closures of lazy options and normalizers.');
1230
        }
1231
1232
        return \array_key_exists($option, $this->defaults);
1233
    }
1234
1235
    /**
1236
     * Not supported.
1237
     *
1238
     * @throws AccessException
1239
     */
1240
    public function offsetSet($option, $value)
1241
    {
1242
        throw new AccessException('Setting options via array access is not supported. Use setDefault() instead.');
1243
    }
1244
1245
    /**
1246
     * Not supported.
1247
     *
1248
     * @throws AccessException
1249
     */
1250
    public function offsetUnset($option)
1251
    {
1252
        throw new AccessException('Removing options via array access is not supported. Use remove() instead.');
1253
    }
1254
1255
    /**
1256
     * Returns the number of set options.
1257
     *
1258
     * This may be only a subset of the defined options.
1259
     *
1260
     * @return int Number of options
1261
     *
1262
     * @throws AccessException If accessing this method outside of {@link resolve()}
1263
     *
1264
     * @see \Countable::count()
1265
     */
1266
    public function count()
1267
    {
1268
        if (!$this->locked) {
1269
            throw new AccessException('Counting is only supported within closures of lazy options and normalizers.');
1270
        }
1271
1272
        return \count($this->defaults);
1273
    }
1274
1275
    /**
1276
     * Returns a string representation of the value.
1277
     *
1278
     * This method returns the equivalent PHP tokens for most scalar types
1279
     * (i.e. "false" for false, "1" for 1 etc.). Strings are always wrapped
1280
     * in double quotes (").
1281
     *
1282
     * @param mixed $value The value to format as string
1283
     */
1284
    private function formatValue($value): string
1285
    {
1286
        if (\is_object($value)) {
1287
            return \get_class($value);
1288
        }
1289
1290
        if (\is_array($value)) {
1291
            return 'array';
1292
        }
1293
1294
        if (\is_string($value)) {
1295
            return '"'.$value.'"';
1296
        }
1297
1298
        if (\is_resource($value)) {
1299
            return 'resource';
1300
        }
1301
1302
        if (null === $value) {
1303
            return 'null';
1304
        }
1305
1306
        if (false === $value) {
1307
            return 'false';
1308
        }
1309
1310
        if (true === $value) {
1311
            return 'true';
1312
        }
1313
1314
        return (string) $value;
1315
    }
1316
1317
    /**
1318
     * Returns a string representation of a list of values.
1319
     *
1320
     * Each of the values is converted to a string using
1321
     * {@link formatValue()}. The values are then concatenated with commas.
1322
     *
1323
     * @see formatValue()
1324
     */
1325
    private function formatValues(array $values): string
1326
    {
1327
        foreach ($values as $key => $value) {
1328
            $values[$key] = $this->formatValue($value);
1329
        }
1330
1331
        return implode(', ', $values);
1332
    }
1333
1334
    private function formatOptions(array $options): string
1335
    {
1336
        if ($this->parentsOptions) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->parentsOptions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
1337
            $prefix = array_shift($this->parentsOptions);
1338
            if ($this->parentsOptions) {
1339
                $prefix .= sprintf('[%s]', implode('][', $this->parentsOptions));
1340
            }
1341
1342
            if ($this->prototype && null !== $this->prototypeIndex) {
1343
                $prefix .= sprintf('[%s]', $this->prototypeIndex);
1344
            }
1345
1346
            $options = array_map(static function (string $option) use ($prefix): string {
1347
                return sprintf('%s[%s]', $prefix, $option);
1348
            }, $options);
1349
        }
1350
1351
        return implode('", "', $options);
1352
    }
1353
1354
    private function getParameterClassName(\ReflectionParameter $parameter): ?string
1355
    {
1356
        if (!($type = $parameter->getType()) instanceof \ReflectionNamedType || $type->isBuiltin()) {
1357
            return null;
1358
        }
1359
1360
        return $type->getName();
1361
    }
1362
}
1363