1<?php
2
3/**
4 * This file is part of the Nette Framework (https://nette.org)
5 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6 */
7
8declare(strict_types=1);
9
10namespace Nette\Utils;
11
12use Nette;
13
14
15/**
16 * Validation utilities.
17 */
18class Validators
19{
20	use Nette\StaticClass;
21
22	private const BuiltinTypes = [
23		'string' => 1, 'int' => 1, 'float' => 1, 'bool' => 1, 'array' => 1, 'object' => 1,
24		'callable' => 1, 'iterable' => 1, 'void' => 1, 'null' => 1, 'mixed' => 1, 'false' => 1,
25		'never' => 1, 'true' => 1,
26	];
27
28	/** @var array<string,?callable> */
29	protected static $validators = [
30		// PHP types
31		'array' => 'is_array',
32		'bool' => 'is_bool',
33		'boolean' => 'is_bool',
34		'float' => 'is_float',
35		'int' => 'is_int',
36		'integer' => 'is_int',
37		'null' => 'is_null',
38		'object' => 'is_object',
39		'resource' => 'is_resource',
40		'scalar' => 'is_scalar',
41		'string' => 'is_string',
42
43		// pseudo-types
44		'callable' => [self::class, 'isCallable'],
45		'iterable' => 'is_iterable',
46		'list' => [Arrays::class, 'isList'],
47		'mixed' => [self::class, 'isMixed'],
48		'none' => [self::class, 'isNone'],
49		'number' => [self::class, 'isNumber'],
50		'numeric' => [self::class, 'isNumeric'],
51		'numericint' => [self::class, 'isNumericInt'],
52
53		// string patterns
54		'alnum' => 'ctype_alnum',
55		'alpha' => 'ctype_alpha',
56		'digit' => 'ctype_digit',
57		'lower' => 'ctype_lower',
58		'pattern' => null,
59		'space' => 'ctype_space',
60		'unicode' => [self::class, 'isUnicode'],
61		'upper' => 'ctype_upper',
62		'xdigit' => 'ctype_xdigit',
63
64		// syntax validation
65		'email' => [self::class, 'isEmail'],
66		'identifier' => [self::class, 'isPhpIdentifier'],
67		'uri' => [self::class, 'isUri'],
68		'url' => [self::class, 'isUrl'],
69
70		// environment validation
71		'class' => 'class_exists',
72		'interface' => 'interface_exists',
73		'directory' => 'is_dir',
74		'file' => 'is_file',
75		'type' => [self::class, 'isType'],
76	];
77
78	/** @var array<string,callable> */
79	protected static $counters = [
80		'string' => 'strlen',
81		'unicode' => [Strings::class, 'length'],
82		'array' => 'count',
83		'list' => 'count',
84		'alnum' => 'strlen',
85		'alpha' => 'strlen',
86		'digit' => 'strlen',
87		'lower' => 'strlen',
88		'space' => 'strlen',
89		'upper' => 'strlen',
90		'xdigit' => 'strlen',
91	];
92
93
94	/**
95	 * Verifies that the value is of expected types separated by pipe.
96	 * @throws AssertionException
97	 */
98	public static function assert(mixed $value, string $expected, string $label = 'variable'): void
99	{
100		if (!static::is($value, $expected)) {
101			$expected = str_replace(['|', ':'], [' or ', ' in range '], $expected);
102			$translate = ['boolean' => 'bool', 'integer' => 'int', 'double' => 'float', 'NULL' => 'null'];
103			$type = $translate[gettype($value)] ?? gettype($value);
104			if (is_int($value) || is_float($value) || (is_string($value) && strlen($value) < 40)) {
105				$type .= ' ' . var_export($value, true);
106			} elseif (is_object($value)) {
107				$type .= ' ' . $value::class;
108			}
109
110			throw new AssertionException("The $label expects to be $expected, $type given.");
111		}
112	}
113
114
115	/**
116	 * Verifies that element $key in array is of expected types separated by pipe.
117	 * @param  mixed[]  $array
118	 * @throws AssertionException
119	 */
120	public static function assertField(
121		array $array,
122		$key,
123		?string $expected = null,
124		string $label = "item '%' in array",
125	): void
126	{
127		if (!array_key_exists($key, $array)) {
128			throw new AssertionException('Missing ' . str_replace('%', $key, $label) . '.');
129
130		} elseif ($expected) {
131			static::assert($array[$key], $expected, str_replace('%', $key, $label));
132		}
133	}
134
135
136	/**
137	 * Verifies that the value is of expected types separated by pipe.
138	 */
139	public static function is(mixed $value, string $expected): bool
140	{
141		foreach (explode('|', $expected) as $item) {
142			if (str_ends_with($item, '[]')) {
143				if (is_iterable($value) && self::everyIs($value, substr($item, 0, -2))) {
144					return true;
145				}
146
147				continue;
148			} elseif (str_starts_with($item, '?')) {
149				$item = substr($item, 1);
150				if ($value === null) {
151					return true;
152				}
153			}
154
155			[$type] = $item = explode(':', $item, 2);
156			if (isset(static::$validators[$type])) {
157				try {
158					if (!static::$validators[$type]($value)) {
159						continue;
160					}
161				} catch (\TypeError $e) {
162					continue;
163				}
164			} elseif ($type === 'pattern') {
165				if (Strings::match($value, '|^' . ($item[1] ?? '') . '$|D')) {
166					return true;
167				}
168
169				continue;
170			} elseif (!$value instanceof $type) {
171				continue;
172			}
173
174			if (isset($item[1])) {
175				$length = $value;
176				if (isset(static::$counters[$type])) {
177					$length = static::$counters[$type]($value);
178				}
179
180				$range = explode('..', $item[1]);
181				if (!isset($range[1])) {
182					$range[1] = $range[0];
183				}
184
185				if (($range[0] !== '' && $length < $range[0]) || ($range[1] !== '' && $length > $range[1])) {
186					continue;
187				}
188			}
189
190			return true;
191		}
192
193		return false;
194	}
195
196
197	/**
198	 * Finds whether all values are of expected types separated by pipe.
199	 * @param  mixed[]  $values
200	 */
201	public static function everyIs(iterable $values, string $expected): bool
202	{
203		foreach ($values as $value) {
204			if (!static::is($value, $expected)) {
205				return false;
206			}
207		}
208
209		return true;
210	}
211
212
213	/**
214	 * Checks if the value is an integer or a float.
215	 * @return ($value is int|float ? true : false)
216	 */
217	public static function isNumber(mixed $value): bool
218	{
219		return is_int($value) || is_float($value);
220	}
221
222
223	/**
224	 * Checks if the value is an integer or a integer written in a string.
225	 * @return ($value is non-empty-string ? bool : ($value is int ? true : false))
226	 */
227	public static function isNumericInt(mixed $value): bool
228	{
229		return is_int($value) || (is_string($value) && preg_match('#^[+-]?[0-9]+$#D', $value));
230	}
231
232
233	/**
234	 * Checks if the value is a number or a number written in a string.
235	 * @return ($value is non-empty-string ? bool : ($value is int|float ? true : false))
236	 */
237	public static function isNumeric(mixed $value): bool
238	{
239		return is_float($value) || is_int($value) || (is_string($value) && preg_match('#^[+-]?([0-9]++\.?[0-9]*|\.[0-9]+)$#D', $value));
240	}
241
242
243	/**
244	 * Checks if the value is a syntactically correct callback.
245	 */
246	public static function isCallable(mixed $value): bool
247	{
248		return $value && is_callable($value, true);
249	}
250
251
252	/**
253	 * Checks if the value is a valid UTF-8 string.
254	 */
255	public static function isUnicode(mixed $value): bool
256	{
257		return is_string($value) && preg_match('##u', $value);
258	}
259
260
261	/**
262	 * Checks if the value is 0, '', false or null.
263	 * @return ($value is 0|''|false|null ? true : false)
264	 */
265	public static function isNone(mixed $value): bool
266	{
267		return $value == null; // intentionally ==
268	}
269
270
271	/** @internal */
272	public static function isMixed(): bool
273	{
274		return true;
275	}
276
277
278	/**
279	 * Checks if a variable is a zero-based integer indexed array.
280	 * @deprecated  use Nette\Utils\Arrays::isList
281	 * @return ($value is list ? true : false)
282	 */
283	public static function isList(mixed $value): bool
284	{
285		return Arrays::isList($value);
286	}
287
288
289	/**
290	 * Checks if the value is in the given range [min, max], where the upper or lower limit can be omitted (null).
291	 * Numbers, strings and DateTime objects can be compared.
292	 */
293	public static function isInRange(mixed $value, array $range): bool
294	{
295		if ($value === null || !(isset($range[0]) || isset($range[1]))) {
296			return false;
297		}
298
299		$limit = $range[0] ?? $range[1];
300		if (is_string($limit)) {
301			$value = (string) $value;
302		} elseif ($limit instanceof \DateTimeInterface) {
303			if (!$value instanceof \DateTimeInterface) {
304				return false;
305			}
306		} elseif (is_numeric($value)) {
307			$value *= 1;
308		} else {
309			return false;
310		}
311
312		return (!isset($range[0]) || ($value >= $range[0])) && (!isset($range[1]) || ($value <= $range[1]));
313	}
314
315
316	/**
317	 * Checks if the value is a valid email address. It does not verify that the domain actually exists, only the syntax is verified.
318	 */
319	public static function isEmail(string $value): bool
320	{
321		$atom = "[-a-z0-9!#$%&'*+/=?^_`{|}~]"; // RFC 5322 unquoted characters in local-part
322		$alpha = "a-z\x80-\xFF"; // superset of IDN
323		return (bool) preg_match(<<<XX
324			(^(?n)
325				("([ !#-[\\]-~]*|\\\\[ -~])+"|$atom+(\\.$atom+)*)  # quoted or unquoted
326				@
327				([0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)+  # domain - RFC 1034
328				[$alpha]([-0-9$alpha]{0,17}[$alpha])?              # top domain
329			$)Dix
330			XX, $value);
331	}
332
333
334	/**
335	 * Checks if the value is a valid URL address.
336	 */
337	public static function isUrl(string $value): bool
338	{
339		$alpha = "a-z\x80-\xFF";
340		return (bool) preg_match(<<<XX
341			(^(?n)
342				https?://(
343					(([-_0-9$alpha]+\\.)*                       # subdomain
344						[0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)?  # domain
345						[$alpha]([-0-9$alpha]{0,17}[$alpha])?   # top domain
346					|\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}  # IPv4
347					|\\[[0-9a-f:]{3,39}\\]                      # IPv6
348				)(:\\d{1,5})?                                   # port
349				(/\\S*)?                                        # path
350				(\\?\\S*)?                                      # query
351				(\\#\\S*)?                                      # fragment
352			$)Dix
353			XX, $value);
354	}
355
356
357	/**
358	 * Checks if the value is a valid URI address, that is, actually a string beginning with a syntactically valid schema.
359	 */
360	public static function isUri(string $value): bool
361	{
362		return (bool) preg_match('#^[a-z\d+\.-]+:\S+$#Di', $value);
363	}
364
365
366	/**
367	 * Checks whether the input is a class, interface or trait.
368	 * @deprecated
369	 */
370	public static function isType(string $type): bool
371	{
372		return class_exists($type) || interface_exists($type) || trait_exists($type);
373	}
374
375
376	/**
377	 * Checks whether the input is a valid PHP identifier.
378	 */
379	public static function isPhpIdentifier(string $value): bool
380	{
381		return preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $value) === 1;
382	}
383
384
385	/**
386	 * Determines if type is PHP built-in type. Otherwise, it is the class name.
387	 */
388	public static function isBuiltinType(string $type): bool
389	{
390		return isset(self::BuiltinTypes[strtolower($type)]);
391	}
392
393
394	/**
395	 * Determines if type is special class name self/parent/static.
396	 */
397	public static function isClassKeyword(string $name): bool
398	{
399		return (bool) preg_match('#^(self|parent|static)$#Di', $name);
400	}
401
402
403	/**
404	 * Checks whether the given type declaration is syntactically valid.
405	 */
406	public static function isTypeDeclaration(string $type): bool
407	{
408		return (bool) preg_match(<<<'XX'
409			~((?n)
410				\?? (?<type> \\? (?<name> [a-zA-Z_\x7f-\xff][\w\x7f-\xff]*) (\\ (?&name))* ) |
411				(?<intersection> (?&type) (& (?&type))+ ) |
412				(?<upart> (?&type) | \( (?&intersection) \) )  (\| (?&upart))+
413			)$~xAD
414			XX, $type);
415	}
416}
417