Skip to content

Commit 26d0ce1

Browse files
committed
Check for PHP reserved keywords and LSB types
1 parent e43d323 commit 26d0ce1

6 files changed

Lines changed: 227 additions & 6 deletions

File tree

.github/workflows/quality-assurance.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,4 @@ jobs:
8282
php-version: '8.3'
8383
tools: infection
8484
- uses: ramsey/composer-install@v3
85-
- run: infection --min-covered-msi=95 --no-progress --log-verbosity=none --threads=max
85+
- run: infection --min-covered-msi=96 --no-progress --log-verbosity=none --threads=max

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ Type::byReflectionType($refType)->satisfiedBy($thing);
4040
```
4141

4242

43+
### Note: Instantiation by string is "checked"
44+
45+
**`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.
46+
47+
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.
48+
4349

4450
## A deeper look
4551

@@ -72,6 +78,13 @@ assert(Type::byString('ArrayObject')->isA(Type::byString('IteratorAggregate&Coun
7278
`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_?
7379

7480

81+
### Late Static Binding Types
82+
83+
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_.
84+
85+
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.
86+
87+
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.
7588

7689
### Type information
7790

src/Type.php

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
*/
2424
final class Type implements \Stringable, \JsonSerializable
2525
{
26-
private const TYPE_REGEX = '[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*';
26+
private const TYPE_REGEX = '[a-z_\x80-\xff][a-z0-9_\x80-\xff]*';
2727

2828
private const DEFAULT_TYPES = [
2929
'mixed' => 'mixed',
@@ -94,6 +94,76 @@ final class Type implements \Stringable, \JsonSerializable
9494
['iterable'],
9595
];
9696

97+
private const RESERVED_WORDS = [
98+
'__halt_compiler' => 0,
99+
'abstract' => 0,
100+
'and' => 0,
101+
'as' => 0,
102+
'break' => 0,
103+
'case' => 0,
104+
'catch' => 0,
105+
'class' => 0,
106+
'clone' => 0,
107+
'const' => 0,
108+
'continue' => 0,
109+
'declare' => 0,
110+
'default' => 0,
111+
'die' => 0,
112+
'do' => 0,
113+
'echo' => 0,
114+
'else' => 0,
115+
'elseif' => 0,
116+
'empty' => 0,
117+
'enddeclare' => 0,
118+
'endfor' => 0,
119+
'endforeach' => 0,
120+
'endif' => 0,
121+
'endswitch' => 0,
122+
'endwhile' => 0,
123+
'eval' => 0,
124+
'exit' => 0,
125+
'extends' => 0,
126+
'final' => 0,
127+
'finally' => 0,
128+
'fn' => 0,
129+
'for' => 0,
130+
'foreach' => 0,
131+
'function' => 0,
132+
'global' => 0,
133+
'goto' => 0,
134+
'if' => 0,
135+
'implements' => 0,
136+
'include' => 0,
137+
'include_once' => 0,
138+
'instanceof' => 0,
139+
'insteadof' => 0,
140+
'interface' => 0,
141+
'isset' => 0,
142+
'list' => 0,
143+
'match' => 0,
144+
'namespace' => 0,
145+
'new' => 0,
146+
'or' => 0,
147+
'print' => 0,
148+
'private' => 0,
149+
'protected' => 0,
150+
'public' => 0,
151+
'readonly' => 0,
152+
'require' => 0,
153+
'require_once' => 0,
154+
'return' => 0,
155+
'switch' => 0,
156+
'throw' => 0,
157+
'trait' => 0,
158+
'try' => 0,
159+
'unset' => 0,
160+
'use' => 0,
161+
'var' => 0,
162+
'while' => 0,
163+
'xor' => 0,
164+
'yield' => 0,
165+
];
166+
97167
/** @var array<string, Type> */
98168
private static array $factoryCache = [];
99169

@@ -315,6 +385,7 @@ private static function createFromUnionType(\ReflectionUnionType $type): Type
315385
private static function splitNull(\ReflectionNamedType $type): array
316386
{
317387
$name = $type->getName();
388+
static::assertNotLateStaticBinding($name);
318389
($name === 'resource') and $name = '\\resource';
319390
$hasNull = $type->allowsNull() && ($name !== 'null') && ($name !== 'mixed');
320391

@@ -362,9 +433,15 @@ private static function normalizeTypeNameString(string $type): array
362433
*/
363434
private static function assertValidTypeString(string $type, string $typeDef): void
364435
{
365-
if (!preg_match('~^' . self::TYPE_REGEX . '(?:\\\\' . self::TYPE_REGEX . ')*$~', $type)) {
436+
$test = strtolower($type);
437+
if (
438+
isset(self::RESERVED_WORDS[$test])
439+
|| !preg_match('~^' . self::TYPE_REGEX . '(?:\\\\' . self::TYPE_REGEX . ')*$~', $test)
440+
) {
366441
static::bailForInvalidDef($typeDef);
367442
}
443+
444+
static::assertNotLateStaticBinding($test);
368445
}
369446

370447
/**
@@ -383,6 +460,32 @@ private static function bailForInvalidDef(string $typeDef): never
383460
);
384461
}
385462

463+
/**
464+
* @param string $type
465+
* @return void
466+
*/
467+
private static function assertNotLateStaticBinding(string $type): void
468+
{
469+
$invalid = match ($type) {
470+
'self' => 'self',
471+
'static' => 'static',
472+
'parent' => 'parent',
473+
default => null
474+
};
475+
476+
if ($invalid === null) {
477+
return;
478+
}
479+
480+
throw new \Error(
481+
sprintf(
482+
'%s does not support "late state binding" type "%s".',
483+
__CLASS__,
484+
$invalid
485+
)
486+
);
487+
}
488+
386489
/**
387490
* @param list<list<non-empty-string>> $types
388491
*/

tests/bootstrap.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
die('Please install via Composer before running tests.');
1414
}
1515

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

2018
if (!defined('PHPUNIT_COMPOSER_INSTALL')) {
2119
define('PHPUNIT_COMPOSER_INSTALL', $autoload);

tests/cases/ByReflectionTypeTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,54 @@ public function testStandaloneType(): void
2929
static::assertFalse($type->isDnf());
3030
}
3131

32+
/**
33+
* @test
34+
* @dataProvider provideLateStaticBindingThrow
35+
*/
36+
public function testLateStaticBindingThrows(string $type): void
37+
{
38+
$before = match (random_int(1, 12)) {
39+
1, 5, 9 => '',
40+
2, 6, 10 => '?',
41+
3, 7, 11 => 'Foo|',
42+
4, 8, 12 => 'string|',
43+
};
44+
45+
$after = '';
46+
if ($before !== '?') {
47+
$after = match (random_int(1, 9)) {
48+
1, 4, 7 => '',
49+
2, 5, 8 => '|null',
50+
3, 6, 9 => '|callable',
51+
};
52+
}
53+
54+
// phpcs:disable VariableAnalysis, Squiz.PHP.Eval
55+
eval(sprintf('$func = fn (): %s%s%s => 1;', $before, $type, $after));
56+
/**
57+
* @var \Closure $func
58+
* @var \ReflectionType $ref
59+
*/
60+
$ref = (new \ReflectionFunction($func))->getReturnType();
61+
// phpcs:enable VariableAnalysis, Squiz.PHP.Eval
62+
63+
$this->expectExceptionMessageMatches('/late/i');
64+
65+
Type::byReflectionType($ref);
66+
}
67+
68+
/**
69+
* @return \Generator
70+
*/
71+
public static function provideLateStaticBindingThrow(): \Generator
72+
{
73+
yield from [
74+
['static'],
75+
['self'],
76+
['parent'],
77+
];
78+
}
79+
3280
/**
3381
* @test
3482
*/

tests/cases/ByStringTest.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,65 @@ public function testMixedNullableThrows(): void
7878
Type::byString('?mixed');
7979
}
8080

81+
/**
82+
* @test
83+
*/
84+
public function testReservedWordThrows(): void
85+
{
86+
$this->expectExceptionMessageMatches('/valid/i');
87+
88+
Type::byString('Meh|(Yield&Foo)');
89+
}
90+
91+
/**
92+
* @test
93+
*/
94+
public function testNullableReservedWordThrows(): void
95+
{
96+
$this->expectExceptionMessageMatches('/valid/i');
97+
98+
Type::byString('?goto');
99+
}
100+
101+
/**
102+
* @test
103+
* @dataProvider provideLateStaticBindingThrow
104+
*/
105+
public function testLateStaticBindingThrows(string $type): void
106+
{
107+
$before = match (random_int(1, 12)) {
108+
1, 5, 9 => '',
109+
2, 6, 10 => '?',
110+
3, 7, 11 => 'Foo|',
111+
4, 8, 12 => 'string|',
112+
};
113+
114+
$after = '';
115+
if ($before !== '?') {
116+
$after = match (random_int(1, 9)) {
117+
1, 4, 7 => '',
118+
2, 5, 8 => '|null',
119+
3, 6, 9 => '|(ArrayAccess&Countable)',
120+
};
121+
}
122+
123+
$this->expectExceptionMessageMatches('/late/i');
124+
125+
Type::byString($before . $type . $after);
126+
}
127+
128+
/**
129+
* @return \Generator
130+
*/
131+
public static function provideLateStaticBindingThrow(): \Generator
132+
{
133+
yield from [
134+
['static'],
135+
['self'],
136+
['parent'],
137+
];
138+
}
139+
81140
/**
82141
* @test
83142
* @dataProvider provideStandaloneInUnionThrows

0 commit comments

Comments
 (0)