3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
13 * The MixinLoader tracks a list of extensions and mixins.
15 class CRM_Extension_MixinLoader
{
18 * @var \CRM_Extension_MixInfo[]
20 protected $mixInfos = [];
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']
28 protected $liveFuncFiles = NULL;
32 * Ex: ['civix' => ['1.0.0' => 'path/to/civix@1.0.0.mixin.php']]
34 protected $allFuncFiles = [];
37 * @param CRM_Extension_MixInfo $mix
39 * @throws \CRM_Extension_Exception_ParseException
41 public function addMixInfo(CRM_Extension_MixInfo
$mix) {
42 $this->mixInfos
[$mix->longName
] = $mix;
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.
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;
62 $header = $this->loadFunctionFileHeader($file);
63 if (isset($header['mixinName'], $header['mixinVersion'])) {
64 $this->allFuncFiles
[$header['mixinName']][$header['mixinVersion']] = $file;
68 error_log(sprintf('MixinLoader: Invalid mixin header for "%s". @mixinName and @mixinVersion required.', $file));
73 error_log(sprintf('MixinLoader: File \"%s\" cannot be parsed.', $file));
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]);
89 * Optimize the metadata, removing information that is not needed at runtime.
93 * - Remove any unnecessary $mixInfos (ie they have no mixins).
94 * - Given the available versions and expectations, pick the best $liveFuncFiles.
95 * - Drop $allFuncFiles.
97 public function compile() {
98 $this->liveFuncFiles
= [];
99 $allFuncs = $this->allFuncFiles ??
[];
101 $sortByVer = function ($a, $b) {
102 return version_compare($a, $b /* ignore third arg */);
104 foreach (array_keys($allFuncs) as $name) {
105 uksort($allFuncs[$name], $sortByVer);
108 $this->mixInfos
= array_filter($this->mixInfos
, function(CRM_Extension_MixInfo
$mixInfo) {
109 return !empty($mixInfo->mixins
);
112 foreach ($this->mixInfos
as $ext) {
113 /** @var \CRM_Extension_MixInfo $ext */
114 foreach ($ext->mixins
as $verExpr) {
115 list ($name, $expectVer) = explode('@', $verExpr);
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;
124 $this->liveFuncFiles
[$verExpr] = $matchFile;
127 error_log(sprintf('MixinLoader: Failed to locate match for "%s"', $verExpr));
132 $this->allFuncFiles
= NULL;
138 * Load all extensions and call their respective function-files.
141 * @throws \CRM_Core_Exception
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.");
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'] = [];
158 $done = &\Civi
::$statics[__CLASS__
]['done'];
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;
171 error_log(sprintf('MixinLoader: Received invalid callback from \"%s\"', $file));
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])) {
183 if (isset($funcsByFile[$this->liveFuncFiles
[$verExpr]])) {
184 call_user_func($funcsByFile[$this->liveFuncFiles
[$verExpr]], $ext, $bootCache);
188 error_log(sprintf('MixinLoader: Failed to load "%s" for extension "%s"', $verExpr, $ext->longName
));
197 * @param string $expectVer
198 * @param string $actualVer
201 private static function satisfies($expectVer, $actualVer) {
202 [$expectMajor] = explode('.', $expectVer);
203 [$actualMajor] = explode('.', $actualVer);
204 return ($expectMajor == $actualMajor) && version_compare($actualVer, $expectVer, '>=');