Skip to content

Commit

Permalink
Check for PHP reserved keywords and LSB types
Browse files Browse the repository at this point in the history
  • Loading branch information
gmazzap committed May 17, 2024
1 parent e43d323 commit 26d0ce1
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 6 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/quality-assurance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@ jobs:
php-version: '8.3'
tools: infection
- uses: ramsey/composer-install@v3
- run: infection --min-covered-msi=95 --no-progress --log-verbosity=none --threads=max
- run: infection --min-covered-msi=96 --no-progress --log-verbosity=none --threads=max
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ Type::byReflectionType($refType)->satisfiedBy($thing);
```


### Note: Instantiation by string is "checked"

**`Type` instantiation by string is checked** to make sure the string is a valid type (or at least, looks like a valid type). Passing a string that does not contain valid PHP syntax for a type definition (including usage of reserved PHP keywords) will result in an error being thrown.

On the other hand, instantiation by `ReflectionType` is "unchecked" because if we obtain a `ReflectionType` instance, we know that's a valid PHP type. The only possible error building from `ReflectionType` can be caused by using "late state binding" types, more on this below.


## A deeper look

Expand Down Expand Up @@ -72,6 +78,13 @@ assert(Type::byString('ArrayObject')->isA(Type::byString('IteratorAggregate&Coun
`Type::isA()` behavior can be described as: _if a function's argument type is represented by the type passed as argument, would it be satisfied by a value whose type is represented by the instance calling the method_?


### Late Static Binding Types

PHP support "late state binding" (LSB) types `self`, `parent` and `static` in return-only type declaration. These types are called "late" as their actual type is calculated at _runtime_.

The main goal of this library is to _check_ types, and it is impossible to check LSB types without knowing the context where they were used, and such context is missing in a simple string such as `"self"` or in a "`ReflectionType` instance.

For that reason this library does *not support* them. Trying to create a `Type` instance from any type that reference those LSB types will result in an error being thrown.

### Type information

Expand Down
107 changes: 105 additions & 2 deletions src/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
*/
final class Type implements \Stringable, \JsonSerializable
{
private const TYPE_REGEX = '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*';
private const TYPE_REGEX = '[a-z_\x80-\xff][a-z0-9_\x80-\xff]*';

private const DEFAULT_TYPES = [
'mixed' => 'mixed',
Expand Down Expand Up @@ -94,6 +94,76 @@ final class Type implements \Stringable, \JsonSerializable
['iterable'],
];

private const RESERVED_WORDS = [
'__halt_compiler' => 0,
'abstract' => 0,
'and' => 0,
'as' => 0,
'break' => 0,
'case' => 0,
'catch' => 0,
'class' => 0,
'clone' => 0,
'const' => 0,
'continue' => 0,
'declare' => 0,
'default' => 0,
'die' => 0,
'do' => 0,
'echo' => 0,
'else' => 0,
'elseif' => 0,
'empty' => 0,
'enddeclare' => 0,
'endfor' => 0,
'endforeach' => 0,
'endif' => 0,
'endswitch' => 0,
'endwhile' => 0,
'eval' => 0,
'exit' => 0,
'extends' => 0,
'final' => 0,
'finally' => 0,
'fn' => 0,
'for' => 0,
'foreach' => 0,
'function' => 0,
'global' => 0,
'goto' => 0,
'if' => 0,
'implements' => 0,
'include' => 0,
'include_once' => 0,
'instanceof' => 0,
'insteadof' => 0,
'interface' => 0,
'isset' => 0,
'list' => 0,
'match' => 0,
'namespace' => 0,
'new' => 0,
'or' => 0,
'print' => 0,
'private' => 0,
'protected' => 0,
'public' => 0,
'readonly' => 0,
'require' => 0,
'require_once' => 0,
'return' => 0,
'switch' => 0,
'throw' => 0,
'trait' => 0,
'try' => 0,
'unset' => 0,
'use' => 0,
'var' => 0,
'while' => 0,
'xor' => 0,
'yield' => 0,
];

/** @var array<string, Type> */
private static array $factoryCache = [];

Expand Down Expand Up @@ -315,6 +385,7 @@ private static function createFromUnionType(\ReflectionUnionType $type): Type
private static function splitNull(\ReflectionNamedType $type): array
{
$name = $type->getName();
static::assertNotLateStaticBinding($name);
($name === 'resource') and $name = '\\resource';
$hasNull = $type->allowsNull() && ($name !== 'null') && ($name !== 'mixed');

Expand Down Expand Up @@ -362,9 +433,15 @@ private static function normalizeTypeNameString(string $type): array
*/
private static function assertValidTypeString(string $type, string $typeDef): void
{
if (!preg_match('~^' . self::TYPE_REGEX . '(?:\\\\' . self::TYPE_REGEX . ')*$~', $type)) {
$test = strtolower($type);
if (
isset(self::RESERVED_WORDS[$test])
|| !preg_match('~^' . self::TYPE_REGEX . '(?:\\\\' . self::TYPE_REGEX . ')*$~', $test)
) {
static::bailForInvalidDef($typeDef);
}

static::assertNotLateStaticBinding($test);
}

/**
Expand All @@ -383,6 +460,32 @@ private static function bailForInvalidDef(string $typeDef): never
);
}

/**
* @param string $type
* @return void
*/
private static function assertNotLateStaticBinding(string $type): void
{
$invalid = match ($type) {
'self' => 'self',
'static' => 'static',
'parent' => 'parent',
default => null
};

if ($invalid === null) {
return;
}

throw new \Error(
sprintf(
'%s does not support "late state binding" type "%s".',
__CLASS__,
$invalid
)
);
}

/**
* @param list<list<non-empty-string>> $types
*/
Expand Down
4 changes: 1 addition & 3 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
die('Please install via Composer before running tests.');
}

// At the moment we have too many deprecations due to dependencies so we hide them on PHP 8.1+
// to avoid tests failures.
error_reporting(E_ALL ^ E_DEPRECATED);
error_reporting(E_ALL);

if (!defined('PHPUNIT_COMPOSER_INSTALL')) {
define('PHPUNIT_COMPOSER_INSTALL', $autoload);
Expand Down
48 changes: 48 additions & 0 deletions tests/cases/ByReflectionTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,54 @@ public function testStandaloneType(): void
static::assertFalse($type->isDnf());
}

/**
* @test
* @dataProvider provideLateStaticBindingThrow
*/
public function testLateStaticBindingThrows(string $type): void
{
$before = match (random_int(1, 12)) {
1, 5, 9 => '',
2, 6, 10 => '?',
3, 7, 11 => 'Foo|',
4, 8, 12 => 'string|',
};

$after = '';
if ($before !== '?') {
$after = match (random_int(1, 9)) {
1, 4, 7 => '',
2, 5, 8 => '|null',
3, 6, 9 => '|callable',
};
}

// phpcs:disable VariableAnalysis, Squiz.PHP.Eval
eval(sprintf('$func = fn (): %s%s%s => 1;', $before, $type, $after));
/**
* @var \Closure $func
* @var \ReflectionType $ref
*/
$ref = (new \ReflectionFunction($func))->getReturnType();
// phpcs:enable VariableAnalysis, Squiz.PHP.Eval

$this->expectExceptionMessageMatches('/late/i');

Type::byReflectionType($ref);
}

/**
* @return \Generator
*/
public static function provideLateStaticBindingThrow(): \Generator
{
yield from [
['static'],
['self'],
['parent'],
];
}

/**
* @test
*/
Expand Down
59 changes: 59 additions & 0 deletions tests/cases/ByStringTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,65 @@ public function testMixedNullableThrows(): void
Type::byString('?mixed');
}

/**
* @test
*/
public function testReservedWordThrows(): void
{
$this->expectExceptionMessageMatches('/valid/i');

Type::byString('Meh|(Yield&Foo)');
}

/**
* @test
*/
public function testNullableReservedWordThrows(): void
{
$this->expectExceptionMessageMatches('/valid/i');

Type::byString('?goto');
}

/**
* @test
* @dataProvider provideLateStaticBindingThrow
*/
public function testLateStaticBindingThrows(string $type): void
{
$before = match (random_int(1, 12)) {
1, 5, 9 => '',
2, 6, 10 => '?',
3, 7, 11 => 'Foo|',
4, 8, 12 => 'string|',
};

$after = '';
if ($before !== '?') {
$after = match (random_int(1, 9)) {
1, 4, 7 => '',
2, 5, 8 => '|null',
3, 6, 9 => '|(ArrayAccess&Countable)',
};
}

$this->expectExceptionMessageMatches('/late/i');

Type::byString($before . $type . $after);
}

/**
* @return \Generator
*/
public static function provideLateStaticBindingThrow(): \Generator
{
yield from [
['static'],
['self'],
['parent'],
];
}

/**
* @test
* @dataProvider provideStandaloneInUnionThrows
Expand Down

0 comments on commit 26d0ce1

Please sign in to comment.