['1.0.0' => 'path/to/civix@1.0.0.mixin.php']] */ protected $allFuncFiles = []; /** * @var array|null * If we have not scanned for live funcs, then NULL. * Otherwise, every live version-requirement is mapped to the corresponding file. * Ex: ['civix@1' => 'path/to/civix@1.0.0.mixin.php'] */ protected $liveFuncFiles = NULL; /** * @var \CRM_Extension_MixInfo[] */ protected $mixInfos = []; /** * CRM_Extension_ClassLoader constructor. * @param \CRM_Extension_Mapper|null $mapper * @param \CRM_Extension_Manager|null $manager * @param bool $relativize * Whether to store paths in relative form. * Enabling this may slow-down scanning a bit, and it has no benefit when for on-demand loaders. * However, if the loader is cached, then it may make for smaller, more portable cache-file. */ public function __construct(?\CRM_Extension_Mapper $mapper = NULL, \CRM_Extension_Manager $manager = NULL, $relativize = TRUE) { $this->mapper = $mapper ?: CRM_Extension_System::singleton()->getMapper(); $this->manager = $manager ?: CRM_Extension_System::singleton()->getManager(); if ($relativize) { $this->relativeBases = [Civi::paths()->getVariable('civicrm.root', 'path')]; // Previous drafts used `relativeBases=explode(include_path)`. However, this produces unstable results // when flip through the phases of the lifecycle - because the include_path changes throughout the lifecycle. usort($this->relativeBases, function($a, $b) { return strlen($b) - strlen($a); }); } else { $this->relativeBases = NULL; } } /** * @return array{0: funcFiles, 1: mixInfos} */ public function build() { $this->scan(); return $this->compile(); } /** * Search through known extensions */ protected function scan() { foreach ($this->getInstalledKeys() as $key) { try { $path = $this->mapper->keyToBasePath($key); $this->addMixInfo($this->createMixInfo($path . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME)); $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*.mixin.php")); $this->addFunctionFiles($this->findFunctionFiles("$path/mixin/*@*/mixin.php"), TRUE); } catch (CRM_Extension_Exception_ParseException $e) { error_log(sprintf('MixinScanner: Failed to read extension (%s)', $key)); } } $this->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*.mixin.php'))); $this->addFunctionFiles($this->findFunctionFiles(Civi::paths()->getPath('[civicrm.root]/mixin/*@*/mixin.php')), TRUE); } /** * Optimize the metadata, removing information that is not needed at runtime. * * Steps: * * - Remove any unnecessary $mixInfos (ie they have no mixins). * - Given the available versions and expectations, pick the best $liveFuncFiles. * - Drop $allFuncFiles. */ protected function compile() { $this->liveFuncFiles = []; $allFuncs = $this->allFuncFiles ?? []; $sortByVer = function ($a, $b) { return version_compare($a, $b /* ignore third arg */); }; foreach (array_keys($allFuncs) as $name) { uksort($allFuncs[$name], $sortByVer); } $this->mixInfos = array_filter($this->mixInfos, function(CRM_Extension_MixInfo $mixInfo) { return !empty($mixInfo->mixins); }); foreach ($this->mixInfos as $ext) { /** @var \CRM_Extension_MixInfo $ext */ foreach ($ext->mixins as $verExpr) { list ($name, $expectVer) = explode('@', $verExpr); $matchFile = NULL; // NOTE: allFuncs[$name] is sorted by increasing version number. Choose highest satisfactory match. foreach ($allFuncs[$name] ?? [] as $availVer => $availFile) { if (static::satisfies($expectVer, $availVer)) { $matchFile = $availFile; } } if ($matchFile) { $this->liveFuncFiles[$verExpr] = $matchFile; } else { error_log(sprintf('MixinLoader: Failed to locate match for "%s"', $verExpr)); } } } $this->allFuncFiles = NULL; return [$this->liveFuncFiles, $this->mixInfos]; } /** * @param CRM_Extension_MixInfo $mix * @return static * @throws \CRM_Extension_Exception_ParseException */ public function addMixInfo(CRM_Extension_MixInfo $mix) { $this->mixInfos[$mix->longName] = $mix; return $this; } /** * @param array|string $files * Ex: 'path/to/some/file@1.0.0.mixin.php' * @param bool $deepRead * If TRUE, then the file will be read to find metadata. * @return $this */ public function addFunctionFiles($files, $deepRead = FALSE) { $files = (array) $files; foreach ($files as $file) { if (preg_match(';^([^@]+)@([^@]+)\.mixin\.php$;', basename($file), $m)) { $this->allFuncFiles[$m[1]][$m[2]] = $file; continue; } if ($deepRead) { $header = $this->loadFunctionFileHeader($file); if (isset($header['mixinName'], $header['mixinVersion'])) { $this->allFuncFiles[$header['mixinName']][$header['mixinVersion']] = $file; continue; } else { error_log(sprintf('MixinLoader: Invalid mixin header for "%s". @mixinName and @mixinVersion required.', $file)); continue; } } error_log(sprintf('MixinLoader: File \"%s\" cannot be parsed.', $file)); } return $this; } private function loadFunctionFileHeader($file) { $php = file_get_contents($file, TRUE); foreach (token_get_all($php) as $token) { 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])) { return \Civi\Api4\Utils\ReflectionUtils::parseDocBlock($token[1]); } } return []; } /** * @return array */ private function getInstalledKeys() { $keys = []; $statuses = $this->manager->getStatuses(); ksort($statuses); foreach ($statuses as $key => $status) { if ($status === CRM_Extension_Manager::STATUS_INSTALLED) { $keys[] = $key; } } return $keys; } /** * @param string $infoFile * Path to the 'info.xml' file * @return \CRM_Extension_MixInfo * @throws \CRM_Extension_Exception_ParseException */ private function createMixInfo(string $infoFile) { $info = CRM_Extension_Info::loadFromFile($infoFile); $instance = new CRM_Extension_MixInfo(); $instance->longName = $info->key; $instance->shortName = $info->file; $instance->path = rtrim(dirname($infoFile), '/' . DIRECTORY_SEPARATOR); $instance->mixins = $info->mixins; return $instance; } /** * @param string $globPat * @return array * Ex: ['mix/xml-menu-autoload@1.0.mixin.php'] */ private function findFunctionFiles($globPat) { $useRel = $this->relativeBases !== NULL; $result = []; $funcFiles = (array) glob($globPat); sort($funcFiles); foreach ($funcFiles as $shimFile) { $shimFileRel = $useRel ? $this->relativize($shimFile) : $shimFile; $result[] = $shimFileRel; } return $result; } /** * Convert the absolute $file to an expression that is supported by 'include'. * * @param string $file * @return string */ private function relativize($file) { foreach ($this->relativeBases as $relativeBase) { if (CRM_Utils_File::isChildPath($relativeBase, $file)) { return ltrim(CRM_Utils_File::relativize($file, $relativeBase), '/' . DIRECTORY_SEPARATOR); } } return $file; } /** * @param string $expectVer * @param string $actualVer * @return bool */ private static function satisfies($expectVer, $actualVer) { [$expectMajor] = explode('.', $expectVer); [$actualMajor] = explode('.', $actualVer); return ($expectMajor == $actualMajor) && version_compare($actualVer, $expectVer, '>='); } }