Merge pull request #20437 from JMAConsulting/core_2633
[civicrm-core.git] / CRM / Extension / MixinScanner.php
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
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
53 /**
54 * CRM_Extension_ClassLoader constructor.
55 * @param \CRM_Extension_Mapper|null $mapper
56 * @param \CRM_Extension_Manager|null $manager
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 /**
79 * @return array{0: funcFiles, 1: mixInfos}
80 */
81 public function build() {
82 $this->scan();
83 return $this->compile();
84 }
85
86 /**
87 * Search through known extensions
88 */
89 protected function scan() {
90 foreach ($this->getInstalledKeys() as $key) {
91 try {
92 $path = $this->mapper->keyToBasePath($key);
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);
96 }
97 catch (CRM_Extension_Exception_ParseException $e) {
98 error_log(sprintf('MixinScanner: Failed to read extension (%s)', $key));
99 }
100 }
101
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 }
149
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 [];
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
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
283 }