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 \[RFC9562]-compliant UUIDv4 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 \[RFC9562]-compliant UUIDv4 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_a (bits 0-31)
346: random_bytes(4),
347: // random_a (bits 32-47)
348: random_bytes(2),
349: // ver (bits 48-51 = 0b0100 = 4), random_b (bits 52-63)
350: chr(random_int(0, 0xF) | 0x40) . random_bytes(1),
351: // var (bits 64-65 = 0b10 = 2), random_c (bits 66-79)
352: chr(random_int(0, 0x3F) | 0x80) . random_bytes(1),
353: // random_c (bits 80-127)
354: random_bytes(6),
355: ];
356:
357: if ($binary) {
358: return implode('', $uuid);
359: }
360:
361: foreach ($uuid as $bin) {
362: $hex[] = bin2hex($bin);
363: }
364:
365: return implode('-', $hex);
366: }
367:
368: private static function normaliseUuid(string $uuid, bool $binary): string
369: {
370: $length = strlen($uuid);
371:
372: if ($length !== 16) {
373: $uuid = str_replace('-', '', $uuid);
374:
375: if (!Regex::match('/^[0-9a-f]{32}$/i', $uuid)) {
376: throw new InvalidArgumentException(sprintf(
377: 'Invalid UUID: %s',
378: $uuid,
379: ));
380: }
381:
382: if ($binary) {
383: /** @var string */
384: return hex2bin($uuid);
385: }
386:
387: $uuid = Str::lower($uuid);
388:
389: return implode('-', [
390: substr($uuid, 0, 8),
391: substr($uuid, 8, 4),
392: substr($uuid, 12, 4),
393: substr($uuid, 16, 4),
394: substr($uuid, 20, 12),
395: ]);
396: }
397:
398: if ($binary) {
399: return $uuid;
400: }
401:
402: $uuid = [
403: substr($uuid, 0, 4),
404: substr($uuid, 4, 2),
405: substr($uuid, 6, 2),
406: substr($uuid, 8, 2),
407: substr($uuid, 10, 6),
408: ];
409:
410: foreach ($uuid as $bin) {
411: $hex[] = bin2hex($bin);
412: }
413:
414: return implode('-', $hex);
415: }
416:
417: /**
418: * Get a sequence of random characters
419: *
420: * @param non-empty-string $chars
421: */
422: public static function randomText(int $length, string $chars = Str::ALPHANUMERIC): string
423: {
424: $max = strlen($chars) - 1;
425: $text = '';
426: for ($i = 0; $i < $length; $i++) {
427: $text .= $chars[random_int(0, $max)];
428: }
429: return $text;
430: }
431:
432: /**
433: * Get the hash of a value in raw binary form
434: *
435: * @param int|float|string|bool|Stringable|null $value
436: */
437: public static function binaryHash($value): string
438: {
439: // xxHash isn't supported until PHP 8.1, so MD5 is the best fit
440: return hash('md5', (string) $value, true);
441: }
442:
443: /**
444: * Get the hash of a value in hexadecimal form
445: *
446: * @param int|float|string|bool|Stringable|null $value
447: */
448: public static function hash($value): string
449: {
450: return hash('md5', (string) $value);
451: }
452:
453: /**
454: * Get the type of a variable
455: *
456: * @param mixed $value
457: */
458: public static function type($value): string
459: {
460: if (is_object($value)) {
461: return (new ReflectionClass($value))->isAnonymous()
462: ? 'class@anonymous'
463: : get_class($value);
464: }
465:
466: if (is_resource($value)) {
467: return sprintf('resource (%s)', get_resource_type($value));
468: }
469:
470: $type = gettype($value);
471: return [
472: 'boolean' => 'bool',
473: 'integer' => 'int',
474: 'double' => 'float',
475: 'NULL' => 'null',
476: ][$type] ?? $type;
477: }
478:
479: /**
480: * Get php.ini values like "128M" in bytes
481: *
482: * From the PHP FAQ: "The available options are K (for Kilobytes), M (for
483: * Megabytes) and G (for Gigabytes), and are all case-insensitive. Anything
484: * else assumes bytes. 1M equals one Megabyte or 1048576 bytes. 1K equals
485: * one Kilobyte or 1024 bytes."
486: */
487: public static function bytes(string $size): int
488: {
489: // PHP is very forgiving with the syntax of these values
490: $size = rtrim($size);
491: $exp = [
492: 'K' => 1,
493: 'k' => 1,
494: 'M' => 2,
495: 'm' => 2,
496: 'G' => 3,
497: 'g' => 3,
498: ][$size[-1] ?? ''] ?? 0;
499: return (int) $size * 1024 ** $exp;
500: }
501:
502: /**
503: * Convert a value to PHP code
504: *
505: * Similar to {@see var_export()}, but with more economical output.
506: *
507: * @param mixed $value
508: * @param non-empty-string[] $classes Strings in this array are output as
509: * `<string>::class` instead of `'<string>'`.
510: * @param array<non-empty-string,string> $constants An array that maps
511: * strings to constant identifiers, e.g. `[\PHP_EOL => '\PHP_EOL']`.
512: */
513: public static function code(
514: $value,
515: string $delimiter = ', ',
516: string $arrow = ' => ',
517: ?string $escapeCharacters = null,
518: string $tab = ' ',
519: array $classes = [],
520: array $constants = []
521: ): string {
522: return (new Exporter(
523: $delimiter,
524: $arrow,
525: $escapeCharacters,
526: $tab,
527: $classes,
528: $constants,
529: ))->export($value);
530: }
531:
532: /**
533: * Get the end-of-line sequence used in a string
534: *
535: * Recognised line endings are LF (`"\n"`), CRLF (`"\r\n"`) and CR (`"\r"`).
536: *
537: * @see File::getEol()
538: * @see Str::setEol()
539: *
540: * @return non-empty-string|null `null` if there are no recognised newline
541: * characters in `$string`.
542: */
543: public static function eol(string $string): ?string
544: {
545: $lfPos = strpos($string, "\n");
546:
547: if ($lfPos === false) {
548: return strpos($string, "\r") === false
549: ? null
550: : "\r";
551: }
552:
553: if ($lfPos && $string[$lfPos - 1] === "\r") {
554: return "\r\n";
555: }
556:
557: return "\n";
558: }
559:
560: /**
561: * Get a deep copy of a value
562: *
563: * @template T
564: *
565: * @param T $value
566: * @param class-string[]|(Closure(object): (object|bool)) $skip A list of
567: * classes to skip, or a closure that returns:
568: * - `true` if the object should be skipped
569: * - `false` if the object should be copied normally, or
570: * - a copy of the object
571: * @param int-mask-of<Get::COPY_*> $flags
572: * @return T
573: */
574: public static function copy(
575: $value,
576: $skip = [],
577: int $flags = Get::COPY_SKIP_UNCLONEABLE | Get::COPY_BY_REFERENCE
578: ) {
579: return (new Copier($skip, $flags))->copy($value);
580: }
581: }
582: