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