1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is part of the league/config package.
7 *
8 * (c) Colin O'Dell <colinodell@gmail.com>
9 *
10 * For the full copyright and license information, please view the LICENSE
11 * file that was distributed with this source code.
12 */
13
14namespace League\Config;
15
16use Dflydev\DotAccessData\Data;
17use Dflydev\DotAccessData\DataInterface;
18use Dflydev\DotAccessData\Exception\DataException;
19use Dflydev\DotAccessData\Exception\InvalidPathException;
20use Dflydev\DotAccessData\Exception\MissingPathException;
21use League\Config\Exception\UnknownOptionException;
22use League\Config\Exception\ValidationException;
23use Nette\Schema\Expect;
24use Nette\Schema\Processor;
25use Nette\Schema\Schema;
26use Nette\Schema\ValidationException as NetteValidationException;
27
28final class Configuration implements ConfigurationBuilderInterface, ConfigurationInterface
29{
30    /** @psalm-readonly */
31    private Data $userConfig;
32
33    /**
34     * @var array<string, Schema>
35     *
36     * @psalm-allow-private-mutation
37     */
38    private array $configSchemas = [];
39
40    /** @psalm-allow-private-mutation */
41    private Data $finalConfig;
42
43    /**
44     * @var array<string, mixed>
45     *
46     * @psalm-allow-private-mutation
47     */
48    private array $cache = [];
49
50    /** @psalm-readonly */
51    private ConfigurationInterface $reader;
52
53    /**
54     * @param array<string, Schema> $baseSchemas
55     */
56    public function __construct(array $baseSchemas = [])
57    {
58        $this->configSchemas = $baseSchemas;
59        $this->userConfig    = new Data();
60        $this->finalConfig   = new Data();
61
62        $this->reader = new ReadOnlyConfiguration($this);
63    }
64
65    /**
66     * Registers a new configuration schema at the given top-level key
67     *
68     * @psalm-allow-private-mutation
69     */
70    public function addSchema(string $key, Schema $schema): void
71    {
72        $this->invalidate();
73
74        $this->configSchemas[$key] = $schema;
75    }
76
77    /**
78     * {@inheritDoc}
79     *
80     * @psalm-allow-private-mutation
81     */
82    public function merge(array $config = []): void
83    {
84        $this->invalidate();
85
86        $this->userConfig->import($config, DataInterface::REPLACE);
87    }
88
89    /**
90     * {@inheritDoc}
91     *
92     * @psalm-allow-private-mutation
93     */
94    public function set(string $key, $value): void
95    {
96        $this->invalidate();
97
98        try {
99            $this->userConfig->set($key, $value);
100        } catch (DataException $ex) {
101            throw new UnknownOptionException($ex->getMessage(), $key, (int) $ex->getCode(), $ex);
102        }
103    }
104
105    /**
106     * {@inheritDoc}
107     *
108     * @psalm-external-mutation-free
109     */
110    public function get(string $key)
111    {
112        if (\array_key_exists($key, $this->cache)) {
113            return $this->cache[$key];
114        }
115
116        try {
117            $this->build(self::getTopLevelKey($key));
118
119            return $this->cache[$key] = $this->finalConfig->get($key);
120        } catch (InvalidPathException | MissingPathException $ex) {
121            throw new UnknownOptionException($ex->getMessage(), $key, (int) $ex->getCode(), $ex);
122        }
123    }
124
125    /**
126     * {@inheritDoc}
127     *
128     * @psalm-external-mutation-free
129     */
130    public function exists(string $key): bool
131    {
132        if (\array_key_exists($key, $this->cache)) {
133            return true;
134        }
135
136        try {
137            $this->build(self::getTopLevelKey($key));
138
139            return $this->finalConfig->has($key);
140        } catch (InvalidPathException | UnknownOptionException $ex) {
141            return false;
142        }
143    }
144
145    /**
146     * @psalm-mutation-free
147     */
148    public function reader(): ConfigurationInterface
149    {
150        return $this->reader;
151    }
152
153    /**
154     * @psalm-external-mutation-free
155     */
156    private function invalidate(): void
157    {
158        $this->cache       = [];
159        $this->finalConfig = new Data();
160    }
161
162    /**
163     * Applies the schema against the configuration to return the final configuration
164     *
165     * @throws ValidationException|UnknownOptionException|InvalidPathException
166     *
167     * @psalm-allow-private-mutation
168     */
169    private function build(string $topLevelKey): void
170    {
171        if ($this->finalConfig->has($topLevelKey)) {
172            return;
173        }
174
175        if (! isset($this->configSchemas[$topLevelKey])) {
176            throw new UnknownOptionException(\sprintf('Missing config schema for "%s"', $topLevelKey), $topLevelKey);
177        }
178
179        try {
180            $userData = [$topLevelKey => $this->userConfig->get($topLevelKey)];
181        } catch (DataException $ex) {
182            $userData = [];
183        }
184
185        try {
186            $schema    = $this->configSchemas[$topLevelKey];
187            $processor = new Processor();
188
189            $processed = $processor->process(Expect::structure([$topLevelKey => $schema]), $userData);
190
191            $this->raiseAnyDeprecationNotices($processor->getWarnings());
192
193            $this->finalConfig->import((array) self::convertStdClassesToArrays($processed));
194        } catch (NetteValidationException $ex) {
195            throw new ValidationException($ex);
196        }
197    }
198
199    /**
200     * Recursively converts stdClass instances to arrays
201     *
202     * @phpstan-template T
203     *
204     * @param T $data
205     *
206     * @return mixed
207     *
208     * @phpstan-return ($data is \stdClass ? array<string, mixed> : T)
209     *
210     * @psalm-pure
211     */
212    private static function convertStdClassesToArrays($data)
213    {
214        if ($data instanceof \stdClass) {
215            $data = (array) $data;
216        }
217
218        if (\is_array($data)) {
219            foreach ($data as $k => $v) {
220                $data[$k] = self::convertStdClassesToArrays($v);
221            }
222        }
223
224        return $data;
225    }
226
227    /**
228     * @param string[] $warnings
229     */
230    private function raiseAnyDeprecationNotices(array $warnings): void
231    {
232        foreach ($warnings as $warning) {
233            @\trigger_error($warning, \E_USER_DEPRECATED);
234        }
235    }
236
237    /**
238     * @throws InvalidPathException
239     */
240    private static function getTopLevelKey(string $path): string
241    {
242        if (\strlen($path) === 0) {
243            throw new InvalidPathException('Path cannot be an empty string');
244        }
245
246        $path = \str_replace(['.', '/'], '.', $path);
247
248        $firstDelimiter = \strpos($path, '.');
249        if ($firstDelimiter === false) {
250            return $path;
251        }
252
253        return \substr($path, 0, $firstDelimiter);
254    }
255}
256