Commit | Line | Data |
---|---|---|
92ee7b19 TO |
1 | <?php |
2 | /* | |
3 | +--------------------------------------------------------------------+ | |
4 | | Copyright CiviCRM LLC. All rights reserved. | | |
5 | | | | |
6 | | This work is published under the GNU AGPLv3 license with some | | |
7 | | permitted exceptions and without any warranty. For full license | | |
8 | | and copyright information, see https://civicrm.org/licensing | | |
9 | +--------------------------------------------------------------------+ | |
10 | */ | |
11 | ||
12 | /** | |
13 | * The MixinScanner scans the list of actives extensions and their required mixins. | |
14 | */ | |
15 | class CRM_Extension_MixinScanner { | |
16 | ||
17 | /** | |
18 | * @var CRM_Extension_Mapper | |
19 | */ | |
20 | protected $mapper; | |
21 | ||
22 | /** | |
23 | * @var CRM_Extension_Manager | |
24 | */ | |
25 | protected $manager; | |
26 | ||
27 | /** | |
28 | * @var string[]|null | |
29 | * A list of base-paths which are implicitly supported by 'include' directives. | |
30 | * Sorted with the longest paths first. | |
31 | */ | |
32 | protected $relativeBases; | |
33 | ||
1795aaf5 TO |
34 | /** |
35 | * @var array | |
36 | * Ex: ['civix' => ['1.0.0' => 'path/to/civix@1.0.0.mixin.php']] | |
37 | */ | |
38 | protected $allFuncFiles = []; | |
39 | ||
40 | /** | |
41 | * @var array|null | |
42 | * If we have not scanned for live funcs, then NULL. | |
43 | * Otherwise, every live version-requirement is mapped to the corresponding file. | |
44 | * Ex: ['civix@1' => 'path/to/civix@1.0.0.mixin.php'] | |
45 | */ | |
46 | protected $liveFuncFiles = NULL; | |
47 | ||
48 | /** | |
49 | * @var \CRM_Extension_MixInfo[] | |
50 | */ | |
51 | protected $mixInfos = []; | |
52 | ||
92ee7b19 TO |
53 | /** |
54 | * CRM_Extension_ClassLoader constructor. | |
2024d5b9 EM |
55 | * @param \CRM_Extension_Mapper|null $mapper |
56 | * @param \CRM_Extension_Manager|null $manager | |
92ee7b19 TO |
57 | * @param bool $relativize |
58 | * Whether to store paths in relative form. | |
59 | * Enabling this may slow-down scanning a bit, and it has no benefit when for on-demand loaders. | |
60 | * However, if the loader is cached, then it may make for smaller, more portable cache-file. | |
61 | */ | |
62 | public function __construct(?\CRM_Extension_Mapper $mapper = NULL, \CRM_Extension_Manager $manager = NULL, $relativize = TRUE) { | |
63 | $this->mapper = $mapper ?: CRM_Extension_System::singleton()->getMapper(); | |
64 | $this->manager = $manager ?: CRM_Extension_System::singleton()->getManager(); | |
65 | if ($relativize) { | |
66 | $this->relativeBases = [Civi::paths()->getVariable('civicrm.root', 'path')]; | |
67 | // Previous drafts used `relativeBases=explode(include_path)`. However, this produces unstable results | |
68 | // when flip through the phases of the lifecycle - because the include_path changes throughout the lifecycle. | |
69 | usort($this->relativeBases, function($a, $b) { | |
70 | return strlen($b) - strlen($a); | |
71 | }); | |
72 | } | |
73 | else { | |
74 | $this->relativeBases = NULL; | |
75 | } | |
76 | } | |
77 | ||
78 | /** | |
1795aaf5 | 79 | * @return array{0: funcFiles, 1: mixInfos} |
92ee7b19 | 80 | */ |
1795aaf5 TO |
81 | public function build() { |
82 | $this->scan(); | |
83 | return $this->compile(); | |
84 | } | |
92ee7b19 | 85 | |
1795aaf5 TO |
86 | /** |
87 | * Search through known extensions | |
88 | */ | |
89 | protected function scan() { | |
92ee7b19 TO |
90 | foreach ($this->getInstalledKeys() as $key) { |
91 | try { | |
92 | $path = $this->mapper->keyToBasePath($key); | |
1795aaf5 TO |
93 | $this->addMixInfo($this->createMixInfo($path . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME)); |
94 | $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*.mixin.php")); | |
95 | $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*/mixin.php"), TRUE); | |
92ee7b19 TO |
96 | } |
97 | catch (CRM_Extension_Exception_ParseException $e) { | |
98 | error_log(sprintf('MixinScanner: Failed to read extension (%s)', $key)); | |
99 | } | |
100 | } | |
101 | ||
1795aaf5 TO |
102 | $this->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*.mixin.php'))); |
103 | $this->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*/mixin.php')), TRUE); | |
104 | } | |
105 | ||
106 | /** | |
107 | * Optimize the metadata, removing information that is not needed at runtime. | |
108 | * | |
109 | * Steps: | |
110 | * | |
111 | * - Remove any unnecessary $mixInfos (ie they have no mixins). | |
112 | * - Given the available versions and expectations, pick the best $liveFuncFiles. | |
113 | * - Drop $allFuncFiles. | |
114 | */ | |
115 | protected function compile() { | |
116 | $this->liveFuncFiles = []; | |
117 | $allFuncs = $this->allFuncFiles ?? []; | |
118 | ||
119 | $sortByVer = function ($a, $b) { | |
120 | return version_compare($a, $b /* ignore third arg */); | |
121 | }; | |
122 | foreach (array_keys($allFuncs) as $name) { | |
123 | uksort($allFuncs[$name], $sortByVer); | |
124 | } | |
125 | ||
126 | $this->mixInfos = array_filter($this->mixInfos, function(CRM_Extension_MixInfo $mixInfo) { | |
127 | return !empty($mixInfo->mixins); | |
128 | }); | |
129 | ||
130 | foreach ($this->mixInfos as $ext) { | |
131 | /** @var \CRM_Extension_MixInfo $ext */ | |
132 | foreach ($ext->mixins as $verExpr) { | |
133 | list ($name, $expectVer) = explode('@', $verExpr); | |
134 | $matchFile = NULL; | |
135 | // NOTE: allFuncs[$name] is sorted by increasing version number. Choose highest satisfactory match. | |
136 | foreach ($allFuncs[$name] ?? [] as $availVer => $availFile) { | |
137 | if (static::satisfies($expectVer, $availVer)) { | |
138 | $matchFile = $availFile; | |
139 | } | |
140 | } | |
141 | if ($matchFile) { | |
142 | $this->liveFuncFiles[$verExpr] = $matchFile; | |
143 | } | |
144 | else { | |
145 | error_log(sprintf('MixinLoader: Failed to locate match for "%s"', $verExpr)); | |
146 | } | |
147 | } | |
148 | } | |
92ee7b19 | 149 | |
1795aaf5 TO |
150 | $this->allFuncFiles = NULL; |
151 | ||
152 | return [$this->liveFuncFiles, $this->mixInfos]; | |
153 | } | |
154 | ||
155 | /** | |
156 | * @param CRM_Extension_MixInfo $mix | |
157 | * @return static | |
158 | * @throws \CRM_Extension_Exception_ParseException | |
159 | */ | |
160 | public function addMixInfo(CRM_Extension_MixInfo $mix) { | |
161 | $this->mixInfos[$mix->longName] = $mix; | |
162 | return $this; | |
163 | } | |
164 | ||
165 | /** | |
166 | * @param array|string $files | |
167 | * Ex: 'path/to/some/file@1.0.0.mixin.php' | |
168 | * @param bool $deepRead | |
169 | * If TRUE, then the file will be read to find metadata. | |
170 | * @return $this | |
171 | */ | |
172 | public function addFunctionFiles($files, $deepRead = FALSE) { | |
173 | $files = (array) $files; | |
174 | foreach ($files as $file) { | |
175 | if (preg_match(';^([^@]+)@([^@]+)\.mixin\.php$;', basename($file), $m)) { | |
176 | $this->allFuncFiles[$m[1]][$m[2]] = $file; | |
177 | continue; | |
178 | } | |
179 | ||
180 | if ($deepRead) { | |
181 | $header = $this->loadFunctionFileHeader($file); | |
182 | if (isset($header['mixinName'], $header['mixinVersion'])) { | |
183 | $this->allFuncFiles[$header['mixinName']][$header['mixinVersion']] = $file; | |
184 | continue; | |
185 | } | |
186 | else { | |
187 | error_log(sprintf('MixinLoader: Invalid mixin header for "%s". @mixinName and @mixinVersion required.', $file)); | |
188 | continue; | |
189 | } | |
190 | } | |
191 | ||
192 | error_log(sprintf('MixinLoader: File \"%s\" cannot be parsed.', $file)); | |
193 | } | |
194 | return $this; | |
195 | } | |
196 | ||
197 | private function loadFunctionFileHeader($file) { | |
198 | $php = file_get_contents($file, TRUE); | |
199 | foreach (token_get_all($php) as $token) { | |
200 | if (is_array($token) && in_array($token[0], [T_DOC_COMMENT, T_COMMENT, T_FUNC_C, T_METHOD_C, T_TRAIT_C, T_CLASS_C])) { | |
201 | return \Civi\Api4\Utils\ReflectionUtils::parseDocBlock($token[1]); | |
202 | } | |
203 | } | |
204 | return []; | |
92ee7b19 TO |
205 | } |
206 | ||
207 | /** | |
208 | * @return array | |
209 | */ | |
210 | private function getInstalledKeys() { | |
211 | $keys = []; | |
212 | ||
213 | $statuses = $this->manager->getStatuses(); | |
214 | ksort($statuses); | |
215 | foreach ($statuses as $key => $status) { | |
216 | if ($status === CRM_Extension_Manager::STATUS_INSTALLED) { | |
217 | $keys[] = $key; | |
218 | } | |
219 | } | |
220 | ||
221 | return $keys; | |
222 | } | |
223 | ||
224 | /** | |
225 | * @param string $infoFile | |
226 | * Path to the 'info.xml' file | |
227 | * @return \CRM_Extension_MixInfo | |
228 | * @throws \CRM_Extension_Exception_ParseException | |
229 | */ | |
230 | private function createMixInfo(string $infoFile) { | |
231 | $info = CRM_Extension_Info::loadFromFile($infoFile); | |
232 | $instance = new CRM_Extension_MixInfo(); | |
233 | $instance->longName = $info->key; | |
234 | $instance->shortName = $info->file; | |
235 | $instance->path = rtrim(dirname($infoFile), '/' . DIRECTORY_SEPARATOR); | |
236 | $instance->mixins = $info->mixins; | |
237 | return $instance; | |
238 | } | |
239 | ||
240 | /** | |
241 | * @param string $globPat | |
242 | * @return array | |
243 | * Ex: ['mix/xml-menu-autoload@1.0.mixin.php'] | |
244 | */ | |
245 | private function findFunctionFiles($globPat) { | |
246 | $useRel = $this->relativeBases !== NULL; | |
247 | $result = []; | |
248 | $funcFiles = (array) glob($globPat); | |
249 | sort($funcFiles); | |
250 | foreach ($funcFiles as $shimFile) { | |
251 | $shimFileRel = $useRel ? $this->relativize($shimFile) : $shimFile; | |
252 | $result[] = $shimFileRel; | |
253 | } | |
254 | return $result; | |
255 | } | |
256 | ||
257 | /** | |
258 | * Convert the absolute $file to an expression that is supported by 'include'. | |
259 | * | |
260 | * @param string $file | |
261 | * @return string | |
262 | */ | |
263 | private function relativize($file) { | |
264 | foreach ($this->relativeBases as $relativeBase) { | |
265 | if (CRM_Utils_File::isChildPath($relativeBase, $file)) { | |
266 | return ltrim(CRM_Utils_File::relativize($file, $relativeBase), '/' . DIRECTORY_SEPARATOR); | |
267 | } | |
268 | } | |
269 | return $file; | |
270 | } | |
271 | ||
1795aaf5 TO |
272 | /** |
273 | * @param string $expectVer | |
274 | * @param string $actualVer | |
275 | * @return bool | |
276 | */ | |
277 | private static function satisfies($expectVer, $actualVer) { | |
278 | [$expectMajor] = explode('.', $expectVer); | |
279 | [$actualMajor] = explode('.', $actualVer); | |
280 | return ($expectMajor == $actualMajor) && version_compare($actualVer, $expectVer, '>='); | |
281 | } | |
282 | ||
92ee7b19 | 283 | } |