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 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 | } |