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: $path = self::filterData(
134: Installed::getInstallPath($package),
135: Installed::class,
136: 'getInstallPath',
137: $package,
138: );
139:
140: return $path !== null
141: ? File::realpath($path)
142: : null;
143: }
144:
145: /**
146: * Use ClassLoader to get the file where a class is defined, or null if it
147: * doesn't exist
148: *
149: * @param class-string $class
150: */
151: public static function getClassPath(string $class): ?string
152: {
153: $class = ltrim($class, '\\');
154: foreach (self::getRegisteredLoaders() as $loader) {
155: $file = self::filterData(
156: $loader->findFile($class),
157: Loader::class,
158: 'findFile',
159: $class,
160: );
161:
162: if ($file !== false) {
163: /** @var string */
164: return $file;
165: }
166: }
167:
168: return null;
169: }
170:
171: /**
172: * Use ClassLoader to get a directory for a namespace, or null if the
173: * namespace doesn't match a registered PSR-4 prefix
174: *
175: * Preference is given to the longest prefix where a directory for the
176: * namespace already exists. If no such prefix exists, preference is given
177: * to the longest prefix.
178: */
179: public static function getNamespacePath(string $namespace): ?string
180: {
181: $namespace = trim($namespace, '\\');
182:
183: $prefixes = [];
184: foreach (self::getRegisteredLoaders() as $loader) {
185: $loaderPrefixes = self::filterData(
186: $loader->getPrefixesPsr4(),
187: Loader::class,
188: 'getPrefixesPsr4',
189: );
190: $prefixes = array_merge_recursive($loaderPrefixes, $prefixes);
191: }
192:
193: // Sort prefixes from longest to shortest
194: uksort(
195: $prefixes,
196: fn($p1, $p2) => strlen($p2) <=> strlen($p1)
197: );
198:
199: foreach ($prefixes as $prefix => $dirs) {
200: if (strcasecmp(substr($namespace . '\\', 0, strlen($prefix)), $prefix)) {
201: continue;
202: }
203:
204: foreach ((array) $dirs as $dir) {
205: if (is_dir($dir)) {
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:
217: return $fallback ?? null;
218: }
219:
220: /**
221: * @return string[]|string|bool|null
222: */
223: private static function getRootPackageValue(string $key)
224: {
225: $values = self::filterData(
226: Installed::getRootPackage(),
227: Installed::class,
228: 'getRootPackage',
229: );
230:
231: if (!array_key_exists($key, $values)) {
232: // @codeCoverageIgnoreStart
233: throw new ShouldNotHappenException(sprintf(
234: 'Value not found in root package: %s',
235: $key,
236: ));
237: // @codeCoverageIgnoreEnd
238: }
239:
240: return $values[$key];
241: }
242:
243: /**
244: * Check if a package is installed
245: */
246: public static function isInstalled(string $package): bool
247: {
248: return self::filterData(
249: Installed::isInstalled($package),
250: Installed::class,
251: 'isInstalled',
252: $package,
253: );
254: }
255:
256: private static function getVersion(string $package, bool $pretty): ?string
257: {
258: return self::filterData(
259: $pretty
260: ? Installed::getPrettyVersion($package)
261: : Installed::getVersion($package),
262: Installed::class,
263: $pretty ? 'getPrettyVersion' : 'getVersion',
264: $package,
265: );
266: }
267:
268: /**
269: * @return array<string,Loader>
270: */
271: private static function getRegisteredLoaders(): array
272: {
273: return self::filterData(
274: Loader::getRegisteredLoaders(),
275: Loader::class,
276: 'getRegisteredLoaders',
277: );
278: }
279:
280: /**
281: * @template TData
282: *
283: * @param TData $data
284: * @param class-string<Installed|Loader> $class
285: * @param mixed ...$args
286: * @return TData
287: */
288: private static function filterData($data, string $class, string $method, ...$args)
289: {
290: if (class_exists(Event::class) && Event::isLoaded()) {
291: $event = new PackageDataReceivedEvent($data, $class, $method, ...$args);
292: $data = Event::getInstance()->dispatch($event)->getData();
293: }
294: return $data;
295: }
296:
297: /**
298: * @param Closure(): ?string $refCallback
299: */
300: private static function formatVersion(
301: string $version,
302: ?bool $withRef,
303: Closure $refCallback
304: ): string {
305: if ($withRef !== false && Regex::match('/(?:^dev-|-dev$)/D', $version)) {
306: $ref = $refCallback();
307: if ($ref !== null && !Str::startsWith($version, ['dev-' . $ref, $ref])) {
308: return $version . "@$ref";
309: }
310: return $version;
311: }
312: if ($withRef) {
313: $ref = $refCallback();
314: if ($ref !== null) {
315: return $version . "-$ref";
316: }
317: }
318: return $version;
319: }
320:
321: private static function formatRef(?string $ref, bool $short): ?string
322: {
323: if ($ref === null || !$short) {
324: return $ref;
325: }
326: return substr($ref, 0, self::SHORT_REFERENCE_LENGTH);
327: }
328: }
329: