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