Merge remote-tracking branch 'origin/5.44' into master-544-fwd
[civicrm-core.git] / CRM / Extension / MixinLoader.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 MixinLoader tracks a list of extensions and mixins.
14 */
15 class CRM_Extension_MixinLoader {
16
17 /**
18 * @var \CRM_Extension_MixInfo[]
19 */
20 protected $mixInfos = [];
21
22 /**
23 * @var array|null
24 * If we have not scanned for live funcs, then NULL.
25 * Otherwise, every live version-requirement is mapped to the corresponding file.
26 * Ex: ['civix@1' => 'path/to/civix@1.0.0.mixin.php']
27 */
28 protected $liveFuncFiles = NULL;
29
30 /**
31 * @var array
32 * Ex: ['civix' => ['1.0.0' => 'path/to/civix@1.0.0.mixin.php']]
33 */
34 protected $allFuncFiles = [];
35
36 /**
37 * @param CRM_Extension_MixInfo $mix
38 * @return static
39 * @throws \CRM_Extension_Exception_ParseException
40 */
41 public function addMixInfo(CRM_Extension_MixInfo $mix) {
42 $this->mixInfos[$mix->longName] = $mix;
43 return $this;
44 }
45
46 /**
47 * @param array|string $files
48 * Ex: 'path/to/some/file@1.0.0.mixin.php'
49 * @param bool $deepRead
50 * If TRUE, then the file will be read to find metadata.
51 * @return $this
52 */
53 public function addFunctionFiles($files, $deepRead = FALSE) {
54 $files = (array) $files;
55 foreach ($files as $file) {
56 if (preg_match(';^([^@]+)@([^@]+)\.mixin\.php$;', basename($file), $m)) {
57 $this->allFuncFiles[$m[1]][$m[2]] = $file;
58 continue;
59 }
60
61 if ($deepRead) {
62 $header = $this->loadFunctionFileHeader($file);
63 if (isset($header['mixinName'], $header['mixinVersion'])) {
64 $this->allFuncFiles[$header['mixinName']][$header['mixinVersion']] = $file;
65 continue;
66 }
67 else {
68 error_log(sprintf('MixinLoader: Invalid mixin header for "%s". @mixinName and @mixinVersion required.', $file));
69 continue;
70 }
71 }
72
73 error_log(sprintf('MixinLoader: File \"%s\" cannot be parsed.', $file));
74 }
75 return $this;
76 }
77
78 private function loadFunctionFileHeader($file) {
79 $php = file_get_contents($file, TRUE);
80 foreach (token_get_all($php) as $token) {
81 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])) {
82 return \Civi\Api4\Utils\ReflectionUtils::parseDocBlock($token[1]);
83 }
84 }
85 return [];
86 }
87
88 /**
89 * Optimize the metadata, removing information that is not needed at runtime.
90 *
91 * Steps:
92 *
93 * - Remove any unnecessary $mixInfos (ie they have no mixins).
94 * - Given the available versions and expectations, pick the best $liveFuncFiles.
95 * - Drop $allFuncFiles.
96 */
97 public function compile() {
98 $this->liveFuncFiles = [];
99 $allFuncs = $this->allFuncFiles ?? [];
100
101 $sortByVer = function ($a, $b) {
102 return version_compare($a, $b /* ignore third arg */);
103 };
104 foreach (array_keys($allFuncs) as $name) {
105 uksort($allFuncs[$name], $sortByVer);
106 }
107
108 $this->mixInfos = array_filter($this->mixInfos, function(CRM_Extension_MixInfo $mixInfo) {
109 return !empty($mixInfo->mixins);
110 });
111
112 foreach ($this->mixInfos as $ext) {
113 /** @var \CRM_Extension_MixInfo $ext */
114 foreach ($ext->mixins as $verExpr) {
115 list ($name, $expectVer) = explode('@', $verExpr);
116 $matchFile = NULL;
117 // NOTE: allFuncs[$name] is sorted by increasing version number. Choose highest satisfactory match.
118 foreach ($allFuncs[$name] ?? [] as $availVer => $availFile) {
119 if (static::satisfies($expectVer, $availVer)) {
120 $matchFile = $availFile;
121 }
122 }
123 if ($matchFile) {
124 $this->liveFuncFiles[$verExpr] = $matchFile;
125 }
126 else {
127 error_log(sprintf('MixinLoader: Failed to locate match for "%s"', $verExpr));
128 }
129 }
130 }
131
132 $this->allFuncFiles = NULL;
133
134 return $this;
135 }
136
137 /**
138 * Load all extensions and call their respective function-files.
139 *
140 * @return static
141 * @throws \CRM_Core_Exception
142 */
143 public function run(CRM_Extension_BootCache $bootCache) {
144 if ($this->liveFuncFiles === NULL) {
145 throw new CRM_Core_Exception("Premature initialization. MixinLoader has not identified live functions.");
146 }
147
148 // == WIP ==
149 //
150 //Do mixins run strictly once (during boot)? Or could they run twice? Or incrementally? Some edge-cases:
151 // - Mixins should make changes via dispatcher() and container(). If there's a Civi::reset(), then these things go away. We'll need to
152 // re-register. (Example scenario: unit-testing)
153 // - Mixins register for every active module. If a new module is enabled, then we haven't had a chance to run on the new extension.
154 // - Mixins register for every active module. If an old module is disabled, then there may be old listeners/services lingering.
155 if (!isset(\Civi::$statics[__CLASS__]['done'])) {
156 \Civi::$statics[__CLASS__]['done'] = [];
157 }
158 $done = &\Civi::$statics[__CLASS__]['done'];
159
160 // Read each live func-file once, even if there's some kind of Civi::reset(). This avoids hard-crash where the func-file registers a PHP class/function/interface.
161 // Granted, PHP symbols require care to avoid conflicts between `mymixin@1.0` and `mymixin@2.0` -- but you can deal with that. For minor-versions, you're
162 // safe because we deduplicate.
163 static $funcsByFile = [];
164 foreach ($this->liveFuncFiles as $verExpr => $file) {
165 if (!isset($funcsByFile[$file])) {
166 $func = include_once $file;
167 if (is_callable($func)) {
168 $funcsByFile[$file] = $func;
169 }
170 else {
171 error_log(sprintf('MixinLoader: Received invalid callback from \"%s\"', $file));
172 }
173 }
174 }
175
176 foreach ($this->mixInfos as $ext) {
177 /** @var \CRM_Extension_MixInfo $ext */
178 foreach ($ext->mixins as $verExpr) {
179 $doneId = $ext->longName . '::' . $verExpr;
180 if (isset($done[$doneId])) {
181 continue;
182 }
183 if (isset($funcsByFile[$this->liveFuncFiles[$verExpr]])) {
184 call_user_func($funcsByFile[$this->liveFuncFiles[$verExpr]], $ext, $bootCache);
185 $done[$doneId] = 1;
186 }
187 else {
188 error_log(sprintf('MixinLoader: Failed to load "%s" for extension "%s"', $verExpr, $ext->longName));
189 }
190 }
191 }
192
193 return $this;
194 }
195
196 /**
197 * @param string $expectVer
198 * @param string $actualVer
199 * @return bool
200 */
201 private static function satisfies($expectVer, $actualVer) {
202 [$expectMajor] = explode('.', $expectVer);
203 [$actualMajor] = explode('.', $actualVer);
204 return ($expectMajor == $actualMajor) && version_compare($actualVer, $expectVer, '>=');
205 }
206
207 }