1: <?php declare(strict_types=1);
2:
3: namespace Salient\Utility;
4:
5: use Composer\Autoload\ClassLoader as Loader;
6: use Composer\InstalledVersions as Installed;
7: use Salient\Core\Facade\Event;
8: use Salient\Utility\Event\PackageDataReceivedEvent;
9: use Salient\Utility\Exception\ShouldNotHappenException;
10: use Closure;
11:
12: /**
13: * Get information from Composer's runtime API
14: *
15: * @api
16: */
17: final class Package extends AbstractUtility
18: {
19: private const SHORT_REFERENCE_LENGTH = 8;
20:
21: /**
22: * Check if require-dev packages are installed
23: */
24: public static function hasDevPackages(): bool
25: {
26: /** @var bool */
27: return self::getRootPackageValue('dev');
28: }
29:
30: /**
31: * Get the name of the root package
32: */
33: public static function name(): string
34: {
35: /** @var string */
36: return self::getRootPackageValue('name');
37: }
38:
39: /**
40: * Get the commit reference of the root package, if known
41: */
42: public static function ref(bool $short = true): ?string
43: {
44: /** @var string|null */
45: $ref = self::getRootPackageValue('reference');
46: return self::formatRef($ref, $short);
47: }
48:
49: /**
50: * Get the version of the root package
51: *
52: * If Composer returns a version like `dev-*` or `v1.x-dev` and `$withRef`
53: * is not `false`, `@<reference>` is added. Otherwise, if `$withRef` is
54: * `true` and a commit reference is available, `-<reference>` is added.
55: *
56: * @param bool $pretty If `true`, return the original version number, e.g.
57: * `v1.2.3` instead of `1.2.3.0`.
58: */
59: public static function version(
60: bool $pretty = true,
61: ?bool $withRef = null
62: ): string {
63: /** @var string */
64: $version = self::getRootPackageValue($pretty ? 'pretty_version' : 'version');
65: return self::formatVersion($version, $withRef, fn() => self::ref());
66: }
67:
68: /**
69: * Get the canonical path of the root package
70: */
71: public static function path(): string
72: {
73: /** @var string */
74: $path = self::getRootPackageValue('install_path');
75: return File::realpath($path);
76: }
77:
78: /**
79: * Get the commit reference of an installed package, if known
80: */
81: public static function getPackageRef(string $package, bool $short = true): ?string
82: {
83: if (!self::isInstalled($package)) {
84: return null;
85: }
86:
87: $ref = self::filterData(
88: Installed::getReference($package),
89: Installed::class,
90: 'getReference',
91: $package,
92: );
93:
94: return self::formatRef($ref, $short);
95: }
96:
97: /**
98: * Get the version of an installed package, or null if it is not installed
99: *
100: * If Composer returns a version like `dev-*` or `v1.x-dev` and `$withRef`
101: * is not `false`, `@<reference>` is added. Otherwise, if `$withRef` is
102: * `true` and a commit reference is available, `-<reference>` is added.
103: *
104: * @param bool $pretty If `true`, return the original version number, e.g.
105: * `v1.2.3` instead of `1.2.3.0`.
106: */
107: public static function getPackageVersion(
108: string $package,
109: bool $pretty = true,
110: ?bool $withRef = null
111: ): ?string {
112: if (!self::isInstalled($package)) {
113: return null;
114: }
115:
116: return self::formatVersion(
117: (string) self::getVersion($package, $pretty),
118: $withRef,
119: fn() => self::getPackageRef($package),
120: );
121: }
122:
123: /**
124: * Get the canonical path of an installed package, or null if it is not
125: * installed
126: */
127: public static function getPackagePath(string $package): ?string
128: {
129: if (!self::isInstalled($package)) {
130: return null;
131: }
132:
133: return self::filterData(
134: Installed::getInstallPath($package),
135: Installed::class,
136: 'getInstallPath',
137: $package,
138: );
139: }
140:
141: /**
142: * Use ClassLoader to get the file where a class is defined, or null if it
143: * doesn't exist
144: *
145: * @param class-string $class
146: */
147: public static function getClassPath(string $class): ?string
148: {
149: $class = ltrim($class, '\\');
150: foreach (self::getRegisteredLoaders() as $loader) {
151: $file = self::filterData(
152: $loader->findFile($class),
153: Loader::class,
154: 'findFile',
155: $class,
156: );
157:
158: if ($file !== false) {
159: /** @var string */
160: return $file;
161: }
162: }
163:
164: return null;
165: }
166:
167: /**
168: * Use ClassLoader to get a directory for a namespace, or null if the
169: * namespace doesn't match a registered PSR-4 prefix
170: *
171: * Preference is given to the longest prefix where a directory for the
172: * namespace already exists. If no such prefix exists, preference is given
173: * to the longest prefix.
174: */
175: public static function getNamespacePath(string $namespace): ?string
176: {
177: $namespace = trim($namespace, '\\');
178:
179: $prefixes = [];
180: foreach (self::getRegisteredLoaders() as $loader) {
181: $loaderPrefixes = self::filterData(
182: $loader->getPrefixesPsr4(),
183: Loader::class,
184: 'getPrefixesPsr4',
185: );
186: $prefixes = array_merge_recursive($loaderPrefixes, $prefixes);
187: }
188:
189: // Sort prefixes from longest to shortest
190: uksort(
191: $prefixes,
192: fn(string $p1, string $p2): int => strlen($p2) <=> strlen($p1)
193: );
194:
195: foreach ($prefixes as $prefix => $dirs) {
196: if (strcasecmp(substr($namespace . '\\', 0, strlen($prefix)), $prefix)) {
197: continue;
198: }
199:
200: foreach ((array) $dirs as $dir) {
201: if (!is_dir($dir)) {
202: // @codeCoverageIgnoreStart
203: continue;
204: // @codeCoverageIgnoreEnd
205: }
206: $dir = File::realpath($dir);
207: $subdir = strtr(substr($namespace, strlen($prefix)), '\\', '/');
208: $path = Arr::implode('/', [$dir, $subdir], '');
209: if (is_dir($path)) {
210: return $path;
211: }
212: $fallback ??= $path;
213: }
214: }
215:
216: return $fallback ?? null;
217: }
218:
219: /**
220: * @return string[]|string|bool|null
221: */
222: private static function getRootPackageValue(string $key)
223: {
224: $values = self::filterData(
225: Installed::getRootPackage(),
226: Installed::class,
227: 'getRootPackage',
228: );
229:
230: if (!array_key_exists($key, $values)) {
231: // @codeCoverageIgnoreStart
232: throw new ShouldNotHappenException(
233: sprintf('Value not found in root package: %s', $key)
234: );
235: // @codeCoverageIgnoreEnd
236: }
237:
238: return $values[$key];
239: }
240:
241: private static function isInstalled(string $package): bool
242: {
243: return self::filterData(
244: Installed::isInstalled($package),
245: Installed::class,
246: 'isInstalled',
247: $package,
248: );
249: }
250:
251: private static function getVersion(string $package, bool $pretty): ?string
252: {
253: return self::filterData(
254: $pretty
255: ? Installed::getPrettyVersion($package)
256: : Installed::getVersion($package),
257: Installed::class,
258: $pretty ? 'getPrettyVersion' : 'getVersion',
259: $package,
260: );
261: }
262:
263: /**
264: * @return array<string,Loader>
265: */
266: private static function getRegisteredLoaders(): array
267: {
268: return self::filterData(
269: Loader::getRegisteredLoaders(),
270: Loader::class,
271: 'getRegisteredLoaders',
272: );
273: }
274:
275: /**
276: * @template TData
277: *
278: * @param TData $data
279: * @param class-string<Installed|Loader> $class
280: * @param mixed ...$args
281: * @return TData
282: */
283: private static function filterData($data, string $class, string $method, ...$args)
284: {
285: if (!class_exists(Event::class) || !Event::isLoaded()) {
286: // @codeCoverageIgnoreStart
287: return $data;
288: // @codeCoverageIgnoreEnd
289: }
290: $event = new PackageDataReceivedEvent($data, $class, $method, ...$args);
291: return Event::getInstance()->dispatch($event)->getData();
292: }
293:
294: /**
295: * @param Closure(): ?string $refCallback
296: */
297: private static function formatVersion(
298: string $version,
299: ?bool $withRef,
300: Closure $refCallback
301: ): string {
302: if ($withRef !== false && Regex::match('/(?:^dev-|-dev$)/', $version)) {
303: $ref = $refCallback();
304: if ($ref !== null) {
305: return $version . "@$ref";
306: }
307: return $version;
308: }
309:
310: if ($withRef) {
311: $ref = $refCallback();
312: if ($ref !== null) {
313: return $version . "-$ref";
314: }
315: }
316: return $version;
317: }
318:
319: private static function formatRef(?string $ref, bool $short): ?string
320: {
321: if ($ref === null || !$short) {
322: return $ref;
323: }
324: return substr($ref, 0, self::SHORT_REFERENCE_LENGTH);
325: }
326: }
327: