Merge pull request #21483 from jmcclelland/leaky-honoree-variable
[civicrm-core.git] / tools / mixin / src / Mixlib.php
CommitLineData
b233b6ca
TO
1<?php
2
3/**
4 * The Mixlib class is a utility for downloading/listing available mixins.
5 * It is used by some test-infra and by civix.
6 */
7class Mixlib {
8
9 /**
10 * Local path to the mixlib folder.
11 *
12 * @var string|null
13 */
14 private $mixlibDir;
15
16 /**
17 * Public URL of the mixlib folder.
18 *
19 * @var string|null
20 */
21 private $mixlibUrl;
22
23 private $cache = [];
24
25 /**
26 * Mixlib constructor.
27 *
28 * @param string|NULL $mixlibDir
29 * Ex: Civi::paths()->getPath('[civicrm.root]/mixin')
30 * @param string|NULL $mixlibUrl
31 * Ex: "https://raw.githubusercontent.com/civicrm/civicrm-core/5.45/mixin"
32 * Ex: "https://raw.githubusercontent.com/totten/civicrm-core/master-mix-dec/mixin"
33 */
34 public function __construct(?string $mixlibDir = NULL, ?string $mixlibUrl = NULL) {
35 $this->mixlibDir = $mixlibDir ?: dirname(__DIR__, 3) . '/mixin';
36 $this->mixlibUrl = $mixlibUrl;
37 }
38
39 public function getList(): array {
40 if ($this->mixlibDir === NULL || !file_exists($this->mixlibDir)) {
41 throw new \RuntimeException("Cannot get list of available mixins");
42 }
43
44 if (isset($this->cache['getList'])) {
45 return $this->cache['getList'];
46 }
47
48 $dirs = (array) glob($this->mixlibDir . '/*@*');
49 $mixinNames = [];
50 foreach ($dirs as $dir) {
51 if (is_dir($dir)) {
52 $mixinNames[] = basename($dir);
53 }
54 }
55 sort($mixinNames);
56 $this->cache['getList'] = $mixinNames;
57 return $mixinNames;
58 }
59
60 /**
61 * @param string $mixin
62 *
63 * @return array
64 * Item with keys:
65 * - mixinName: string, eg 'mgd-php'
66 * - mixinVersion: string, eg '1.0.2'
67 * - mixinConstraint: string, eg 'mgd-php@1.0.2'
68 * - mixinFile: string, eg 'mgd-php@1.0.2.mixin.php'
69 * - src: string, unevaluated PHP source
70 */
71 public function get(string $mixin) {
72 if (isset($this->cache["parsed:$mixin"])) {
73 return $this->cache["parsed:$mixin"];
74 }
75
76 $phpCode = $this->getSourceCode($mixin);
77 $mixinSpec = $this->parseString($phpCode);
78 $mixinSpec['mixinName'] = $mixinSpec['mixinName'] ?? preg_replace(';@.*$;', '', $mixin);
79
80 $parts = explode('@', $mixin);
81 $effectiveVersion = !empty($mixinSpec['mixinVersion']) ? $mixinSpec['mixinVersion'] : ($parts[1] ?? '');
82 if ($effectiveVersion) {
83 $mixinSpec = array_merge([
84 'mixinConstraint' => $mixinSpec['mixinName'] . '@' . $effectiveVersion,
85 'mixinFile' => $mixinSpec['mixinName'] . '@' . $effectiveVersion . '.mixin.php',
86 ], $mixinSpec);
87 }
88 else {
89 $mixinSpec = array_merge([
90 'mixinFile' => $mixinSpec['mixinName'] . '.mixin.php',
91 ], $mixinSpec);
92 }
93 $mixinSpec['src'] = $phpCode;
94 $this->cache["parsed:$mixin"] = $mixinSpec;
95
96 return $this->cache["parsed:$mixin"];
97 }
98
99 /**
100 * Consolidate and retrieve the listed mixins.
101 *
102 * @param array $mixinConstraints
103 * Ex: ['foo@1.0', 'bar@1.2', 'bar@1.3']
104 * @return array
105 * Ex: ['foo@1.0' => array, 'bar@1.3' => array]
106 */
107 public function consolidate(array $mixinConstraints): array {
108 // Find and remove duplicate constraints. Pick tightest constraint.
109 // array(string $mixinName => string $mixinVersion)
110 $preferredVersions = [];
111 foreach ($mixinConstraints as $mixinName) {
112 [$name, $version] = explode('@', $mixinName);
113 if (!isset($preferredVersions[$name])) {
114 $preferredVersions[$name] = $version;
115 }
116 elseif (version_compare($version, $preferredVersions[$name], '>=')) {
117 $preferredVersions[$name] = $version;
118 }
119 }
120
121 // Resolve current versions matching constraint.
122 $result = [];
123 foreach ($preferredVersions as $mixinName => $mixinVersion) {
124 $result[] = $mixinName . '@' . $mixinVersion;
125 }
126 sort($result);
127 return $result;
128 }
129
130 /**
131 * Consolidate and retrieve the listed mixins.
132 *
133 * @param array $mixinConstraints
134 * Ex: ['foo@1.0', 'bar@1.2', 'bar@1.3']
135 * @return array
136 * Ex: ['foo@1.0' => array, 'bar@1.3' => array]
137 */
138 public function resolve(array $mixinConstraints): array {
139 $mixinConstraints = $this->consolidate($mixinConstraints);
140
141 $result = [];
142 foreach ($mixinConstraints as $mixinConstraint) {
143 [$expectName, $expectVersion] = explode('@', $mixinConstraint);
144 $mixin = $this->get($mixinConstraint);
145 $this->assertValid($mixin);
146 if (!version_compare($mixin['mixinVersion'], $expectVersion, '>=') || $mixin['mixinName'] !== $expectName) {
147 throw new \RuntimeException(sprintf("Received incompatible version (expected=\"%s@%s\", actual=\"%s@%s\")", $expectName, $expectVersion, $mixin['mixinName'], $mixin['mixinVersion']));
148 }
149 $result[$mixin['mixinConstraint']] = $mixin;
150 }
151 return $result;
152 }
153
154 /**
155 * @param string $mixin
156 * Ex: 'foo@1.2.3', 'foo-bar@4.5.6', 'polyfill',
157 * @return string
158 */
159 protected function getSourceCode(string $mixin): string {
160 if ($mixin === 'polyfill') {
161 $file = 'polyfill.php';
162 }
163 elseif (preg_match(';^([-\w]+)@(\d+)([\.\d]+)?;', $mixin, $m)) {
164 // Get the last revision within the major series.
165 $file = sprintf('%s@%s/mixin.php', $m[1], $m[2]);
166 }
167 else {
168 throw new \RuntimeException("Failed to parse mixin name ($mixin)");
169 }
170
171 if ($this->mixlibDir && file_exists($this->mixlibDir . '/' . $file)) {
172 return file_get_contents($this->mixlibDir . '/' . $file);
173 }
174
175 if ($this->mixlibUrl) {
176 $url = $this->mixlibUrl . '/' . $file;
177 $download = file_get_contents($url);
178 if (!empty($download)) {
179 $this->cache["src:$mixin"] = $download;
180 return $download;
181 }
182 }
183
184 throw new \RuntimeException("Failed to locate $file (mixlibDir={$this->mixlibDir}, mixlibUrl={$this->mixlibUrl})");
185 }
186
187 public function assertValid(array $mixin): array {
188 if (empty($mixin['mixinVersion'])) {
189 throw new \RuntimeException("Invalid {$mixin["file"]}. There is no @mixinVersion annotation.");
190 }
191 if (empty($mixin['mixinVersion'])) {
192 throw new \RuntimeException("Invalid {$mixin["file"]}. There is no @mixinName annotation.");
193 }
194 return $mixin;
195 }
196
197 /**
198 * @param string $phpCode
199 * @return array
200 */
201 protected function parseString(string $phpCode): array {
202 $commmentTokens = [T_DOC_COMMENT, T_COMMENT, T_FUNC_C, T_METHOD_C, T_TRAIT_C, T_CLASS_C];
203 $mixinSpec = [];
204 foreach (token_get_all($phpCode) as $token) {
205 if (is_array($token) && in_array($token[0], $commmentTokens)) {
206 $mixinSpec = $this->parseComment($token[1]);
207 break;
208 }
209 }
210 return $mixinSpec;
211 }
212
213 protected function parseComment(string $comment): array {
214 $info = [];
215 $param = NULL;
216 foreach (preg_split("/((\r?\n)|(\r\n?))/", $comment) as $num => $line) {
217 if (!$num || strpos($line, '*/') !== FALSE) {
218 continue;
219 }
220 $line = ltrim(trim($line), '*');
221 if (strlen($line) && $line[0] === ' ') {
222 $line = substr($line, 1);
223 }
224 if (strpos(ltrim($line), '@') === 0) {
225 $words = explode(' ', ltrim($line, ' @'));
226 $key = array_shift($words);
227 $param = NULL;
228 if ($key == 'var') {
229 $info['type'] = explode('|', $words[0]);
230 }
231 elseif ($key == 'return') {
232 $info['return'] = explode('|', $words[0]);
233 }
234 elseif ($key == 'options' || $key == 'ui_join_filters') {
235 $val = str_replace(', ', ',', implode(' ', $words));
236 $info[$key] = explode(',', $val);
237 }
238 elseif ($key == 'throws' || $key == 'see') {
239 $info[$key][] = implode(' ', $words);
240 }
241 elseif ($key == 'param' && $words) {
242 $type = $words[0][0] !== '$' ? explode('|', array_shift($words)) : NULL;
243 $param = rtrim(array_shift($words), '-:()/');
244 $info['params'][$param] = [
245 'type' => $type,
246 'description' => $words ? ltrim(implode(' ', $words), '-: ') : '',
247 'comment' => '',
248 ];
249 }
250 else {
251 // Unrecognized annotation, but we'll duly add it to the info array
252 $val = implode(' ', $words);
253 $info[$key] = strlen($val) ? $val : TRUE;
254 }
255 }
256 elseif ($param) {
257 $info['params'][$param]['comment'] .= $line . "\n";
258 }
259 elseif ($num == 1) {
260 $info['description'] = ucfirst($line);
261 }
262 elseif (!$line) {
263 if (isset($info['comment'])) {
264 $info['comment'] .= "\n";
265 }
266 else {
267 $info['comment'] = NULL;
268 }
269 }
270 // For multi-line description.
271 elseif (count($info) === 1 && isset($info['description']) && substr($info['description'], -1) !== '.') {
272 $info['description'] .= ' ' . $line;
273 }
274 else {
275 $info['comment'] = isset($info['comment']) ? "{$info['comment']}\n$line" : $line;
276 }
277 }
278 if (isset($info['comment'])) {
279 $info['comment'] = rtrim($info['comment']);
280 }
281 return $info;
282 }
283
284}