4651ea449ee2f61c20fdbc4a911cb815f9227e4c
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Configuration / Loader / YamlFileLoader.php
1 <?php
2 namespace TYPO3\CMS\Core\Configuration\Loader;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use Symfony\Component\Yaml\Yaml;
18 use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader\Configuration;
19 use TYPO3\CMS\Core\Utility\GeneralUtility;
20
21 /**
22 * A YAML file loader that allows to load YAML files, based on the Symfony/Yaml component
23 *
24 * In addition to just load a YAML file, it adds some special functionality.
25 *
26 * - A special "imports" key in the YAML file allows to include other YAML files recursively
27 * where the actual YAML file gets loaded after the import statements, which are interpreted at the very beginning
28 *
29 * - Merging configuration options of import files when having simple "lists" will add items to the list by default
30 * instead of overwriting them.
31 *
32 * - Special placeholder values set via %optionA.suboptionB% replace the value with the named path of the configuration
33 * The placeholders will act as a full replacement of this value.
34 */
35 class YamlFileLoader implements YamlFileLoaderInterface
36 {
37 /**
38 * @var Configuration
39 */
40 protected $configuration;
41
42 /**
43 * @param Configuration|null $configuration
44 */
45 public function __construct(Configuration $configuration = null)
46 {
47 $this->configuration = $configuration ?: GeneralUtility::makeInstance(Configuration::class);
48 }
49
50 /**
51 * Loads and parses a YAML file, returns an array with the data found
52 *
53 * @param string $fileName either relative to PATH_site or prefixed with EXT:...
54 * @return array the configuration as array
55 */
56 public function load($fileName): array
57 {
58 if (!is_string($fileName)) {
59 throw new \InvalidArgumentException('The argument "$fileName" must be a string ("' . gettype($fileName) . '" given)', 1512558206);
60 }
61 return $this->loadFromContent($this->getFileContents($fileName));
62 }
63
64 /**
65 * Parses a string as YAML, returns an array with the data found
66 *
67 * @param string $content
68 * @return array the configuration as array
69 * @throws \RuntimeException when the file is empty or is of invalid format
70 */
71 public function loadFromContent(string $content): array
72 {
73 $content = Yaml::parse($content);
74
75 if (!is_array($content)) {
76 throw new \RuntimeException('YAML content could not be parsed into valid syntax, probably empty?', 1497332874);
77 }
78
79 if ($this->configuration->getProcessImports()) {
80 $content = $this->processImports($content);
81 }
82
83 // Check for "%" placeholders
84 if ($this->configuration->getProcessPlaceholders()) {
85 $content = $this->processPlaceholders($content, $content);
86 }
87
88 return $content;
89 }
90
91 /**
92 * Put into a separate method to ease the pains with unit tests
93 *
94 * @param string $fileName either relative to PATH_site or prefixed with EXT:...
95 *
96 * @return string the contents of the file
97 * @throws \RuntimeException when the file was not accessible
98 */
99 protected function getFileContents($fileName): string
100 {
101 if (!is_string($fileName)) {
102 throw new \InvalidArgumentException('The argument "$fileName" must be a string ("' . gettype($fileName) . '" given)', 1512558207);
103 }
104 $streamlinedFileName = GeneralUtility::getFileAbsFileName($fileName);
105 if (!$streamlinedFileName) {
106 throw new \RuntimeException('YAML file "' . $fileName . '" could not be loaded', 1485784246);
107 }
108 return file_get_contents($streamlinedFileName);
109 }
110
111 /**
112 * Checks for the special "imports" key on the main level of a file,
113 * which calls "load" recursively.
114 * @param array $content
115 *
116 * @return array
117 */
118 protected function processImports(array $content): array
119 {
120 if (isset($content['imports']) && is_array($content['imports'])) {
121 foreach ($content['imports'] as $import) {
122 $importedContent = $this->load($import['resource']);
123 // override the imported content with the one from the current file
124 $content = $this->merge($importedContent, $content);
125 }
126 if ($this->configuration->getRemoveImportsProperty()) {
127 unset($content['imports']);
128 }
129 }
130 return $content;
131 }
132
133 /**
134 * Main function that gets called recursively to check for %...% placeholders
135 * inside the array
136 *
137 * @param array $content the current sub-level content array
138 * @param array $referenceArray the global configuration array
139 *
140 * @return array the modified sub-level content array
141 */
142 protected function processPlaceholders(array $content, array $referenceArray): array
143 {
144 foreach ($content as $k => $v) {
145 if ($this->isPlaceholder($v)) {
146 $content[$k] = $this->getValueFromReferenceArray($v, $referenceArray);
147 } elseif (is_array($v)) {
148 $content[$k] = $this->processPlaceholders($v, $referenceArray);
149 }
150 }
151 return $content;
152 }
153
154 /**
155 * Returns the value for a placeholder as fetched from the referenceArray
156 *
157 * @param string $placeholder the string to search for
158 * @param array $referenceArray the main configuration array where to look up the data
159 *
160 * @return array|mixed|string
161 */
162 protected function getValueFromReferenceArray(string $placeholder, array $referenceArray)
163 {
164 $pointer = trim($placeholder, '%');
165 $parts = explode('.', $pointer);
166 $referenceData = $referenceArray;
167 foreach ($parts as $part) {
168 if (isset($referenceData[$part])) {
169 $referenceData = $referenceData[$part];
170 } else {
171 // return unsubstituted placeholder
172 return $placeholder;
173 }
174 }
175 if ($this->isPlaceholder($referenceData)) {
176 $referenceData = $this->getValueFromReferenceArray($referenceData, $referenceArray);
177 }
178 return $referenceData;
179 }
180
181 /**
182 * Checks if a value is a string and begins and ends with %...%
183 *
184 * @param mixed $value the probe to check for
185 * @return bool
186 */
187 protected function isPlaceholder($value): bool
188 {
189 return is_string($value) && substr($value, 0, 1) === '%' && substr($value, -1) === '%';
190 }
191
192 /**
193 * Same as array_replace_recursive except that when in simple arrays (= YAML lists),
194 * the entries are appended (array_merge) configured accordingly
195 *
196 * @param array $val1
197 * @param array $val2
198 *
199 * @return array
200 */
201 protected function merge(array $val1, array $val2): array
202 {
203 // Simple lists get merged / added up
204 if ($this->configuration->getMergeLists()) {
205 if (count(array_filter(array_keys($val1), 'is_int')) === count($val1)) {
206 return array_merge($val1, $val2);
207 }
208 }
209 foreach ($val1 as $k => $v) {
210 // The key also exists in second array, if it is a simple value
211 // then $val2 will override the value, where an array is calling merge() recursively.
212 if (isset($val2[$k])) {
213 if (is_array($v) && isset($val2[$k])) {
214 if (is_array($val2[$k])) {
215 $val1[$k] = $this->merge($v, $val2[$k]);
216 } else {
217 $val1[$k] = $val2[$k];
218 }
219 } else {
220 $val1[$k] = $val2[$k];
221 }
222 unset($val2[$k]);
223 }
224 }
225 // If there are properties in the second array left, they are added up
226 if (!empty($val2)) {
227 foreach ($val2 as $k => $v) {
228 $val1[$k] = $v;
229 }
230 }
231
232 return $val1;
233 }
234 }