diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd59682 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +## ide +.idea + +## composer +vendor +composer.lock diff --git a/bin/.gitignore b/bin/.gitignore new file mode 100644 index 0000000..007ad75 --- /dev/null +++ b/bin/.gitignore @@ -0,0 +1,3 @@ +## composer +composer.lock +vendor diff --git a/bin/composer.json b/bin/composer.json new file mode 100644 index 0000000..551dc2e --- /dev/null +++ b/bin/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "php": ">=8.0", + "nette/neon": "^3.3", + "nette/php-generator": "^3.6", + "nette/schema": "^1.2" + } +} diff --git a/bin/generate.php b/bin/generate.php new file mode 100644 index 0000000..4cd2fd0 --- /dev/null +++ b/bin/generate.php @@ -0,0 +1,269 @@ +linesBetweenMethods = 1; + $this->linesBetweenProperties = 1; + } + + public function printFile(PhpFile $file): string + { + $namespaces = []; + foreach ($file->getNamespaces() as $namespace) { + $namespaces[] = $this->printNamespace($namespace); + } + + return Strings::normalize( + "hasStrictTypes() ? " declare(strict_types = 1);\n" : '') + . ($file->getComment() ? "\n" . Helpers::formatDocComment($file->getComment() . "\n") : '') + . "\n" + . implode("\n\n", $namespaces) + ) . "\n"; + } + + public function printClass(ClassType $class, PhpNamespace $namespace = null): string + { + $lines = explode("\n", parent::printClass($class, $namespace)); + foreach ($lines as $i => $line) { + if (preg_match('#^\s*(final|abstract)?\s*(class|interface|trait)#', $line)) { + array_splice($lines, $i + 2, 0, ''); + + break; + } + } + + array_splice($lines, -2, 0, ''); + + return implode("\n", $lines); + } + +} + +class TypeAssertionGenerator +{ + + public function __construct( + private string $arrayTypeAssertTraitName, + private string $typeAssertTraitName, + private string $typeAssertClassName, + private string $typeAssertionException, + private array $types, + ) + { + } + + public function runArray(array $types): string + { + $file = new PhpFile(); + $file->setStrictTypes(); + $namespace = $file->addNamespace(Helpers::extractNamespace($this->arrayTypeAssertTraitName)); + $namespace->addUse($this->typeAssertClassName); + $class = $namespace->addTrait(Helpers::extractShortName($this->arrayTypeAssertTraitName)); + $class->addComment('@internal'); + + foreach ($types as $type) { + $type = Type::fromString($type); + + if ($type->isIntersection()) { + throw new LogicException('Intersection is not supported.'); + } + + $this->generateArray($type, $class, $namespace); + } + + return (new DefaultPrinter())->printFile($file); + } + + public function run(array $types): string + { + $file = new PhpFile(); + $file->setStrictTypes(); + $namespace = $file->addNamespace(Helpers::extractNamespace($this->typeAssertTraitName)); + $namespace->addUse($this->typeAssertionException); + $class = $namespace->addTrait(Helpers::extractShortName($this->typeAssertTraitName)); + $class->addComment('@internal'); + + foreach ($types as $type) { + $type = Type::fromString($type); + + if ($type->isIntersection()) { + throw new LogicException('Intersection is not supported.'); + } + + $this->generate($type, $class, $namespace); + } + + return (new DefaultPrinter())->printFile($file); + } + + private function generateArray(Type $type, ClassType $class, PhpNamespace $namespace): void + { + $method = $class->addMethod($methodName = $this->generateName($type)); + $method->addParameter('array') + ->setType('mixed'); + $method->addParameter('key') + ->setType('int|string'); + $method->setReturnType($this->returnType($type)) + ->setStatic(); + + $method->addBody(sprintf('return %s::%s(self::get($array, $key));', $namespace->simplifyType($this->typeAssertClassName), $methodName)); + } + + private function generate(Type $type, ClassType $class, PhpNamespace $namespace): void + { + $method = $class->addMethod($this->generateName($type)); + $method->addParameter('value') + ->setType('mixed'); + $method->setReturnType($this->returnType($type)) + ->setStatic(); + + $expandedType = $this->typeToStringExpanded($type); + + $assertions = []; + $epilogs = []; + $prologs = []; + foreach ($type->getTypes() as $singleType) { + $struct = $this->types[$singleType->getSingleName()]; + $assertions = array_merge($assertions, $struct->assertions); + if ($struct->prolog) { + $prologs[] = $struct->prolog; + } + if ($struct->epilog) { + $epilogs[] = $struct->epilogs; + } + } + + if ($prologs) { + $method->addBody(implode("\n", $prologs)); + $method->addBody(''); + } + + $method->addBody( + sprintf('if (%s) {', $this->generateCondition($assertions)) + ); + $method->addBody( + sprintf( + "\tthrow new %s(self::createErrorMessage(\$value, ?));", + $namespace->simplifyName($this->typeAssertionException) + ), + [$expandedType] + ); + $method->addBody('}'); + + if ($epilogs) { + $method->addBody(''); + $method->addBody(implode("\n", $epilogs)); + } + + $method->addBody(''); + $method->addBody('return $value;'); + } + + private function typeToStringExpanded(Type $type): string + { + $types = []; + foreach ($type->getTypes() as $type) { + $types[] = $type->getSingleName(); + } + + return implode('|', $types); + } + + private function generateName(Type $type): string + { + $methodName = ''; + foreach ($type->getNames() as $name) { + $methodName .= ucfirst($name) . 'Or'; + } + + return lcfirst(substr($methodName, 0, -2)); + } + + private function generateCondition(array $validators): string + { + return implode(' && ', $validators); + } + + private function returnType(Type $type): string + { + $returnType = ''; + foreach ($type->getTypes() as $type) { + if ($type->isBuiltin()) { + $returnType .= $type->getSingleName() . '|'; + } else { + $struct = $this->types[$type->getSingleName()] ?? throw new LogicException( + sprintf('Return type for type "%s" does not exist.', $type->getSingleName()) + ); + + foreach ($struct->returns as $item) { + $returnType .= $item . '|'; + } + } + } + + return (string) Type::fromString(substr($returnType, 0, -1)); + } + +} + +$types = [ + 'array', + 'array|null', + 'object', + 'object|null', + 'string', + 'string|null', + 'int', + 'int|null', + 'float', + 'float|null', + 'int|float', + 'int|float|null', + 'numeric', + 'numericInt', + 'numericInt|null', + 'numericFloat', + 'numericFloat|null', +]; + +$data = (new Processor())->process(Expect::structure([ + 'types' => Expect::arrayOf(Expect::structure([ + 'assertions' => Expect::anyOf(Expect::string(), Expect::arrayOf('string'))->castTo('array')->default([]), + 'returns' => Expect::anyOf(Expect::string(), Expect::arrayOf('string'))->castTo('array')->default([]), + 'prolog' => Expect::string()->default(null), + 'epilog' => Expect::string()->default(null), + ])), +]), Neon::decode(FileSystem::read(__DIR__ . '/methods.neon'))); + +$generator = new TypeAssertionGenerator( + 'Utilitte\Asserts\Mixins\ArrayTypeAssertTrait', + 'Utilitte\Asserts\Mixins\TypeAssertTrait', + TypeAssert::class, + AssertionFailedException::class, + $data->types, +); + +FileSystem::write(__DIR__ . '/../src/Mixins/TypeAssertTrait.php', $generator->run($types)); +FileSystem::write(__DIR__ . '/../src/Mixins/ArrayTypeAssertTrait.php', $generator->runArray($types)); diff --git a/bin/methods.neon b/bin/methods.neon new file mode 100644 index 0000000..24e43ac --- /dev/null +++ b/bin/methods.neon @@ -0,0 +1,38 @@ +types: + int: + assertions: '!is_int($value)' + float: + assertions: '!is_float($value)' + array: + assertions: '!is_array($value)' + object: + assertions: '!is_object($value)' + string: + assertions: '!is_string($value)' + null: + assertions: '$value !== null' + + numeric: + assertions: '!is_float($value) && !is_int($value)' + returns: [float, int] + prolog: """ + if (is_string($value) && is_numeric($value)) { + $value = str_contains($value, '.') ? (float) $value : (int) $value; + } + """ + numericFloat: + assertions: '!is_float($value)' + returns: float + prolog: """ + if (is_string($value) && is_numeric($value)) { + $value = (float) preg_replace('#\\.0*$#D', '', $value); + } + """ + numericInt: + assertions: '!is_int($value)' + returns: int + prolog: """ + if (is_string($value) && is_numeric($value) && preg_match('#^[0-9]+$#D', $value)) { + $value = (int) preg_replace('#\\.0*$#D', '', $value); + } + """ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c2658c2 --- /dev/null +++ b/composer.json @@ -0,0 +1,13 @@ +{ + "name": "utilitte/asserts", + "autoload": { + "psr-4": { + "Utilitte\\Asserts\\": "src" + } + }, + "require": { + "php": ">= 8.0", + "nette/tester": "^2.4", + "nette/utils": "^3.2" + } +} diff --git a/src/ArrayTypeAssert.php b/src/ArrayTypeAssert.php new file mode 100644 index 0000000..96b64ba --- /dev/null +++ b/src/ArrayTypeAssert.php @@ -0,0 +1,68 @@ + $k) { + if (is_array($array) && array_key_exists($k, $array)) { + $array = $array[$k]; + } else { + throw new OutOfBoundsException(self::createOutOfBoundsErrorMessage($array, $index, $k, $key)); + } + } + + return $array; + } + + private static function didYouMean(array $array, string $key): string + { + $suggestion = Helpers::getSuggestion(array_filter(array_keys($array), 'is_string'), $key); + if (!$suggestion) { + return ''; + } + + return sprintf(', did you mean "%s"?', $suggestion); + } + + private static function withPath(array $path): string + { + if (!$path) { + return ''; + } + + return sprintf('[%s]', implode('][', $path)); + } + +} diff --git a/src/Exceptions/AssertionFailedException.php b/src/Exceptions/AssertionFailedException.php new file mode 100644 index 0000000..0907efd --- /dev/null +++ b/src/Exceptions/AssertionFailedException.php @@ -0,0 +1,10 @@ + $type + * @return T + */ + public static function instance(array $array, string|int $key, string $type): object + { + return TypeAssert::instance(self::get($array, $key), $type); + } + + /** + * @template T of object + * @param mixed[] $array + * @param class-string $type + * @return T|null + */ + public static function instanceOrNull(array $array, string|int $key, string $type): ?object + { + return TypeAssert::instanceOrNull(self::get($array, $key), $type); + } + +} diff --git a/src/Traits/TypeAssertTrait.php b/src/Traits/TypeAssertTrait.php new file mode 100644 index 0000000..fd9d35d --- /dev/null +++ b/src/Traits/TypeAssertTrait.php @@ -0,0 +1,71 @@ + $type + * @return T + */ + public static function instance(mixed $value, string $type): object + { + self::checkInstanceStruct($typeStruct = Type::fromString($type)); + + if (is_object($value)) { + foreach ($typeStruct->getNames() as $singleType) { + if ($value instanceof $singleType) { + return $value; + } + } + } + + throw new AssertionFailedException(self::createErrorMessage($value, $type)); + } + + /** + * @template T of object + * @param mixed $value + * @param class-string $type + * @return T|null + */ + public static function instanceOrNull(mixed $value, string $type): ?object + { + self::checkInstanceStruct($typeStruct = Type::fromString($type)); + + if ($value === null) { + return null; + } + + if (is_object($value)) { + foreach ($typeStruct->getNames() as $singleType) { + if ($value instanceof $singleType) { + return $value; + } + } + } + + throw new AssertionFailedException(self::createErrorMessage($value, $type)); + } + + private static function checkInstanceStruct(Type $type): void + { + if ($type->isIntersection()) { + throw new LogicException('Intersection type in instance is not supported.'); + } + + foreach ($type->getTypes() as $singleType) { + if ($singleType->isBuiltin()) { + throw new LogicException(sprintf('Invalid type "%s" in instance.', $singleType->getSingleName())); + } + } + } + +} diff --git a/src/TypeAssert.php b/src/TypeAssert.php new file mode 100644 index 0000000..6cd92f7 --- /dev/null +++ b/src/TypeAssert.php @@ -0,0 +1,19 @@ + 'nested'], + 'foo' => ['bar' => 'bar'], + 'first' => [ + 'second' => [ + 'third' => [], + ], + ], +]; + +Assert::same('bar', ArrayTypeAssert::string($array, 'foo.bar')); + +Assert::exception(fn () => ArrayTypeAssert::string($array, 'fo'), \Utilitte\Asserts\Exceptions\OutOfBoundsException::class); + +Assert::same('bar', ArrayTypeAssert::string([ + 'foo' => ['bar' => 'bar'], +], 'foo.bar')); + +Assert::exception(fn () => ArrayTypeAssert::string($array, 'foo.ba'), \Utilitte\Asserts\Exceptions\OutOfBoundsException::class); +Assert::exception(fn () => ArrayTypeAssert::string($array, '0.nested.xxx'), \Utilitte\Asserts\Exceptions\OutOfBoundsException::class); + + +Assert::same('nested', ArrayTypeAssert::string($array, '0.nested')); diff --git a/tests/_bootstrap.php b/tests/_bootstrap.php new file mode 100644 index 0000000..558d6ec --- /dev/null +++ b/tests/_bootstrap.php @@ -0,0 +1,3 @@ +