1: <?php declare(strict_types=1);
2:
3: namespace Salient\Utility;
4:
5: use Salient\Contract\Core\Arrayable;
6: use Salient\Utility\Internal\Copier;
7: use Salient\Utility\Internal\Exporter;
8: use Closure;
9: use Countable;
10: use InvalidArgumentException;
11: use ReflectionClass;
12: use Stringable;
13: use Traversable;
14:
15: /**
16: * Extract, convert and generate data
17: *
18: * @api
19: */
20: final class Get extends AbstractUtility
21: {
22: /**
23: * Do not throw an exception if an uncloneable object is encountered
24: */
25: public const COPY_SKIP_UNCLONEABLE = 1;
26:
27: /**
28: * Assign values to properties by reference
29: *
30: * Required if an object graph contains nodes with properties passed or
31: * assigned by reference.
32: */
33: public const COPY_BY_REFERENCE = 2;
34:
35: /**
36: * Take a shallow copy of objects with a __clone method
37: */
38: public const COPY_TRUST_CLONE = 4;
39:
40: /**
41: * Copy service containers
42: */
43: public const COPY_CONTAINERS = 8;
44:
45: /**
46: * Copy singletons
47: */
48: public const COPY_SINGLETONS = 16;
49:
50: /**
51: * Cast a value to boolean, converting boolean strings and preserving null
52: *
53: * @param mixed $value
54: * @return ($value is null ? null : bool)
55: */
56: public static function boolean($value): ?bool
57: {
58: if ($value === null || is_bool($value)) {
59: return $value;
60: }
61:
62: if (is_string($value) && Regex::match(
63: '/^' . Regex::BOOLEAN_STRING . '$/',
64: trim($value),
65: $matches,
66: \PREG_UNMATCHED_AS_NULL
67: )) {
68: return $matches['true'] !== null;
69: }
70:
71: return (bool) $value;
72: }
73:
74: /**
75: * Cast a value to integer, preserving null
76: *
77: * @param int|float|string|bool|null $value
78: * @return ($value is null ? null : int)
79: */
80: public static function integer($value): ?int
81: {
82: if ($value === null) {
83: return null;
84: }
85:
86: return (int) $value;
87: }
88:
89: /**
90: * Cast a value to the array-key it appears to be, preserving null
91: *
92: * @param int|string|null $value
93: * @return ($value is null ? null : ($value is int ? int : int|string))
94: */
95: public static function arrayKey($value)
96: {
97: if ($value === null || is_int($value)) {
98: return $value;
99: }
100:
101: if (Regex::match('/^' . Regex::INTEGER_STRING . '$/', trim($value))) {
102: return (int) $value;
103: }
104:
105: return $value;
106: }
107:
108: /**
109: * Convert a callable to a closure
110: *
111: * @return ($callable is null ? null : Closure)
112: */
113: public static function closure(?callable $callable): ?Closure
114: {
115: return $callable === null || $callable instanceof Closure
116: ? $callable
117: : Closure::fromCallable($callable);
118: }
119:
120: /**
121: * Resolve a closure to its return value
122: *
123: * @template T
124: * @template TArg
125: *
126: * @param T|Closure(TArg...): T $value
127: * @param TArg ...$args Passed to `$value` if it is a closure.
128: * @return T
129: */
130: public static function value($value, ...$args)
131: {
132: return $value instanceof Closure
133: ? $value(...$args)
134: : $value;
135: }
136:
137: /**
138: * Convert "key[=value]" pairs to an associative array
139: *
140: * @param string[] $values
141: * @return mixed[]
142: */
143: public static function filter(array $values, bool $discardInvalid = false): array
144: {
145: $valid = Regex::grep('/^[^ .=]++/', $values);
146: if (!$discardInvalid && $valid !== $values) {
147: $invalid = array_diff($values, $valid);
148: throw new InvalidArgumentException(Inflect::format(
149: $invalid,
150: "Invalid key[=value] {{#:pair}}: '%s'",
151: implode("', '", $invalid),
152: ));
153: }
154:
155: /** @var int|null */
156: static $maxInputVars;
157: $maxInputVars ??= (int) ini_get('max_input_vars');
158: if (count($valid) > $maxInputVars) {
159: throw new InvalidArgumentException(sprintf(
160: 'Key-value pairs exceed max_input_vars (%d)',
161: $maxInputVars,
162: ));
163: }
164:
165: $values = Regex::replaceCallback(
166: '/^([^=]++)(?:=(.++))?/s',
167: fn($matches) =>
168: rawurlencode((string) $matches[1])
169: . ($matches[2] === null
170: ? ''
171: : '=' . rawurlencode($matches[2])),
172: $valid,
173: -1,
174: $count,
175: \PREG_UNMATCHED_AS_NULL,
176: );
177: $query = [];
178: parse_str(implode('&', $values), $query);
179: return $query;
180: }
181:
182: /**
183: * Get the first value that is not null, or return the last value
184: *
185: * @template T
186: *
187: * @param T|null ...$values
188: * @return T|null
189: */
190: public static function coalesce(...$values)
191: {
192: $value = null;
193: foreach ($values as $value) {
194: if ($value !== null) {
195: return $value;
196: }
197: }
198: return $value;
199: }
200:
201: /**
202: * Resolve a value to an array
203: *
204: * @template TKey of array-key
205: * @template TValue
206: *
207: * @param Arrayable<TKey,TValue>|iterable<TKey,TValue> $value
208: * @return array<TKey,TValue>
209: */
210: public static function array($value): array
211: {
212: if (is_array($value)) {
213: return $value;
214: }
215: if ($value instanceof Traversable) {
216: return iterator_to_array($value);
217: }
218: return $value->toArray();
219: }
220:
221: /**
222: * Resolve a value to a list
223: *
224: * @template TValue
225: *
226: * @param Arrayable<array-key,TValue>|iterable<TValue> $value
227: * @return list<TValue>
228: */
229: public static function list($value): array
230: {
231: if (is_array($value)) {
232: return array_values($value);
233: }
234: if ($value instanceof Traversable) {
235: return iterator_to_array($value, false);
236: }
237: return $value->toArray(false);
238: }
239:
240: /**
241: * Resolve a value to an item count
242: *
243: * @param Arrayable<array-key,mixed>|iterable<array-key,mixed>|Countable|int $value
244: */
245: public static function count($value): int
246: {
247: if (is_int($value)) {
248: return $value;
249: }
250: if (is_array($value) || $value instanceof Countable) {
251: return count($value);
252: }
253: if ($value instanceof Arrayable) {
254: return count($value->toArray(false));
255: }
256: return iterator_count($value);
257: }
258:
259: /**
260: * Get the unqualified name of a class, optionally removing the first
261: * matching suffix
262: *
263: * @return ($class is class-string ? non-empty-string : string)
264: */
265: public static function basename(string $class, string ...$suffix): string
266: {
267: /** @var string */
268: $class = strrchr('\\' . $class, '\\');
269: $class = substr($class, 1);
270:
271: if (!$suffix) {
272: return $class;
273: }
274:
275: foreach ($suffix as $suffix) {
276: if ($suffix === $class) {
277: continue;
278: }
279: $length = strlen($suffix);
280: if (substr($class, -$length) === $suffix) {
281: return substr($class, 0, -$length);
282: }
283: }
284:
285: return $class;
286: }
287:
288: /**
289: * Get the namespace of a class
290: */
291: public static function namespace(string $class): string
292: {
293: $length = strrpos('\\' . $class, '\\') - 1;
294:
295: return $length < 1
296: ? ''
297: : trim(substr($class, 0, $length), '\\');
298: }
299:
300: /**
301: * Normalise a class name for comparison
302: *
303: * @template T of object
304: *
305: * @param class-string<T> $class
306: * @return class-string<T>
307: */
308: public static function fqcn(string $class): string
309: {
310: /** @var class-string<T> */
311: return Str::lower(ltrim($class, '\\'));
312: }
313:
314: /**
315: * Get a UUID in raw binary form
316: *
317: * If `$uuid` is not given, an \[RFC4122]-compliant UUID is generated.
318: *
319: * @throws InvalidArgumentException if an invalid UUID is given.
320: */
321: public static function binaryUuid(?string $uuid = null): string
322: {
323: return $uuid === null
324: ? self::getUuid(true)
325: : self::normaliseUuid($uuid, true);
326: }
327:
328: /**
329: * Get a UUID in hexadecimal form
330: *
331: * If `$uuid` is not given, an \[RFC4122]-compliant UUID is generated.
332: *
333: * @throws InvalidArgumentException if an invalid UUID is given.
334: */
335: public static function uuid(?string $uuid = null): string
336: {
337: return $uuid === null
338: ? self::getUuid(false)
339: : self::normaliseUuid($uuid, false);
340: }
341:
342: private static function getUuid(bool $binary): string
343: {
344: $uuid = [
345: random_bytes(4),
346: random_bytes(2),
347: // Version 4 (most significant 4 bits = 0b0100)
348: chr(random_int(0, 0xF) | 0x40) . random_bytes(1),
349: // Variant 1 (most significant 2 bits = 0b10)
350: chr(random_int(0, 0x3F) | 0x80) . random_bytes(1),
351: random_bytes(6),
352: ];
353:
354: if ($binary) {
355: return implode('', $uuid);
356: }
357:
358: foreach ($uuid as $bin) {
359: $hex[] = bin2hex($bin);
360: }
361:
362: return implode('-', $hex);
363: }
364:
365: private static function normaliseUuid(string $uuid, bool $binary): string
366: {
367: $length = strlen($uuid);
368:
369: if ($length !== 16) {
370: $uuid = str_replace('-', '', $uuid);
371:
372: if (!Regex::match('/^[0-9a-f]{32}$/i', $uuid)) {
373: throw new InvalidArgumentException(sprintf(
374: 'Invalid UUID: %s',
375: $uuid,
376: ));
377: }
378:
379: if ($binary) {
380: /** @var string */
381: return hex2bin($uuid);
382: }
383:
384: $uuid = Str::lower($uuid);
385:
386: return implode('-', [
387: substr($uuid, 0, 8),
388: substr($uuid, 8, 4),
389: substr($uuid, 12, 4),
390: substr($uuid, 16, 4),
391: substr($uuid, 20, 12),
392: ]);
393: }
394:
395: if ($binary) {
396: return $uuid;
397: }
398:
399: $uuid = [
400: substr($uuid, 0, 4),
401: substr($uuid, 4, 2),
402: substr($uuid, 6, 2),
403: substr($uuid, 8, 2),
404: substr($uuid, 10, 6),
405: ];
406:
407: foreach ($uuid as $bin) {
408: $hex[] = bin2hex($bin);
409: }
410:
411: return implode('-', $hex);
412: }
413:
414: /**
415: * Get a sequence of random characters
416: *
417: * @param non-empty-string $chars
418: */
419: public static function randomText(int $length, string $chars = Str::ALPHANUMERIC): string
420: {
421: $max = strlen($chars) - 1;
422: $text = '';
423: for ($i = 0; $i < $length; $i++) {
424: $text .= $chars[random_int(0, $max)];
425: }
426: return $text;
427: }
428:
429: /**
430: * Get the hash of a value in raw binary form
431: *
432: * @param int|float|string|bool|Stringable|null $value
433: */
434: public static function binaryHash($value): string
435: {
436: // xxHash isn't supported until PHP 8.1, so MD5 is the best fit
437: return hash('md5', (string) $value, true);
438: }
439:
440: /**
441: * Get the hash of a value in hexadecimal form
442: *
443: * @param int|float|string|bool|Stringable|null $value
444: */
445: public static function hash($value): string
446: {
447: return hash('md5', (string) $value);
448: }
449:
450: /**
451: * Get the type of a variable
452: *
453: * @param mixed $value
454: */
455: public static function type($value): string
456: {
457: if (is_object($value)) {
458: return (new ReflectionClass($value))->isAnonymous()
459: ? 'class@anonymous'
460: : get_class($value);
461: }
462:
463: if (is_resource($value)) {
464: return sprintf('resource (%s)', get_resource_type($value));
465: }
466:
467: $type = gettype($value);
468: return [
469: 'boolean' => 'bool',
470: 'integer' => 'int',
471: 'double' => 'float',
472: 'NULL' => 'null',
473: ][$type] ?? $type;
474: }
475:
476: /**
477: * Get php.ini values like "128M" in bytes
478: *
479: * From the PHP FAQ: "The available options are K (for Kilobytes), M (for
480: * Megabytes) and G (for Gigabytes), and are all case-insensitive. Anything
481: * else assumes bytes. 1M equals one Megabyte or 1048576 bytes. 1K equals
482: * one Kilobyte or 1024 bytes."
483: */
484: public static function bytes(string $size): int
485: {
486: // PHP is very forgiving with the syntax of these values
487: $size = rtrim($size);
488: $exp = [
489: 'K' => 1,
490: 'k' => 1,
491: 'M' => 2,
492: 'm' => 2,
493: 'G' => 3,
494: 'g' => 3,
495: ][$size[-1] ?? ''] ?? 0;
496: return (int) $size * 1024 ** $exp;
497: }
498:
499: /**
500: * Convert a value to PHP code
501: *
502: * Similar to {@see var_export()}, but with more economical output.
503: *
504: * @param mixed $value
505: * @param non-empty-string[] $classes Strings in this array are output as
506: * `<string>::class` instead of `'<string>'`.
507: * @param array<non-empty-string,string> $constants An array that maps
508: * strings to constant identifiers, e.g. `[\PHP_EOL => '\PHP_EOL']`.
509: */
510: public static function code(
511: $value,
512: string $delimiter = ', ',
513: string $arrow = ' => ',
514: ?string $escapeCharacters = null,
515: string $tab = ' ',
516: array $classes = [],
517: array $constants = []
518: ): string {
519: return (new Exporter(
520: $delimiter,
521: $arrow,
522: $escapeCharacters,
523: $tab,
524: $classes,
525: $constants,
526: ))->export($value);
527: }
528:
529: /**
530: * Get the end-of-line sequence used in a string
531: *
532: * Recognised line endings are LF (`"\n"`), CRLF (`"\r\n"`) and CR (`"\r"`).
533: *
534: * @see File::getEol()
535: * @see Str::setEol()
536: *
537: * @return non-empty-string|null `null` if there are no recognised newline
538: * characters in `$string`.
539: */
540: public static function eol(string $string): ?string
541: {
542: $lfPos = strpos($string, "\n");
543:
544: if ($lfPos === false) {
545: return strpos($string, "\r") === false
546: ? null
547: : "\r";
548: }
549:
550: if ($lfPos && $string[$lfPos - 1] === "\r") {
551: return "\r\n";
552: }
553:
554: return "\n";
555: }
556:
557: /**
558: * Get a deep copy of a value
559: *
560: * @template T
561: *
562: * @param T $value
563: * @param class-string[]|(Closure(object): (object|bool)) $skip A list of
564: * classes to skip, or a closure that returns:
565: * - `true` if the object should be skipped
566: * - `false` if the object should be copied normally, or
567: * - a copy of the object
568: * @param int-mask-of<Get::COPY_*> $flags
569: * @return T
570: */
571: public static function copy(
572: $value,
573: $skip = [],
574: int $flags = Get::COPY_SKIP_UNCLONEABLE | Get::COPY_BY_REFERENCE
575: ) {
576: return (new Copier($skip, $flags))->copy($value);
577: }
578: }
579: