Skip to content

Commit a767c51

Browse files
authored
Improve date() return types
1 parent 5473b67 commit a767c51

8 files changed

+170
-114
lines changed

conf/config.neon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,9 @@ services:
12721272
tags:
12731273
- phpstan.broker.dynamicFunctionReturnTypeExtension
12741274

1275+
-
1276+
class: PHPStan\Type\Php\DateFunctionReturnTypeHelper
1277+
12751278
-
12761279
class: PHPStan\Type\Php\DateFormatFunctionReturnTypeExtension
12771280
tags:

src/Type/Php/DateFormatFunctionReturnTypeExtension.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace PHPStan\Type\Php;
44

55
use PhpParser\Node\Expr\FuncCall;
6-
use PhpParser\Node\Name\FullyQualified;
76
use PHPStan\Analyser\Scope;
87
use PHPStan\Reflection\FunctionReflection;
98
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
@@ -14,6 +13,10 @@
1413
class DateFormatFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
1514
{
1615

16+
public function __construct(private DateFunctionReturnTypeHelper $dateFunctionReturnTypeHelper)
17+
{
18+
}
19+
1720
public function isFunctionSupported(FunctionReflection $functionReflection): bool
1821
{
1922
return $functionReflection->getName() === 'date_format';
@@ -23,16 +26,15 @@ public function getTypeFromFunctionCall(
2326
FunctionReflection $functionReflection,
2427
FuncCall $functionCall,
2528
Scope $scope,
26-
): Type
29+
): ?Type
2730
{
2831
if (count($functionCall->getArgs()) < 2) {
2932
return new StringType();
3033
}
3134

32-
return $scope->getType(
33-
new FuncCall(new FullyQualified('date'), [
34-
$functionCall->getArgs()[1],
35-
]),
35+
return $this->dateFunctionReturnTypeHelper->getTypeFromFormatType(
36+
$scope->getType($functionCall->getArgs()[1]->value),
37+
true,
3638
);
3739
}
3840

src/Type/Php/DateFormatMethodReturnTypeExtension.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
namespace PHPStan\Type\Php;
44

55
use DateTimeInterface;
6-
use PhpParser\Node\Expr\FuncCall;
76
use PhpParser\Node\Expr\MethodCall;
8-
use PhpParser\Node\Name\FullyQualified;
97
use PHPStan\Analyser\Scope;
108
use PHPStan\Reflection\MethodReflection;
119
use PHPStan\Type\DynamicMethodReturnTypeExtension;
@@ -16,6 +14,10 @@
1614
class DateFormatMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension
1715
{
1816

17+
public function __construct(private DateFunctionReturnTypeHelper $dateFunctionReturnTypeHelper)
18+
{
19+
}
20+
1921
public function getClass(): string
2022
{
2123
return DateTimeInterface::class;
@@ -26,16 +28,15 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
2628
return $methodReflection->getName() === 'format';
2729
}
2830

29-
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
31+
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
3032
{
3133
if (count($methodCall->getArgs()) === 0) {
3234
return new StringType();
3335
}
3436

35-
return $scope->getType(
36-
new FuncCall(new FullyQualified('date'), [
37-
$methodCall->getArgs()[0],
38-
]),
37+
return $this->dateFunctionReturnTypeHelper->getTypeFromFormatType(
38+
$scope->getType($methodCall->getArgs()[0]->value),
39+
true,
3940
);
4041
}
4142

src/Type/Php/DateFunctionReturnTypeExtension.php

Lines changed: 10 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,17 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\Reflection\FunctionReflection;
8-
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
9-
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
10-
use PHPStan\Type\Accessory\AccessoryNumericStringType;
11-
use PHPStan\Type\Constant\ConstantStringType;
128
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
13-
use PHPStan\Type\IntersectionType;
14-
use PHPStan\Type\StringType;
159
use PHPStan\Type\Type;
16-
use PHPStan\Type\TypeCombinator;
17-
use PHPStan\Type\UnionType;
1810
use function count;
19-
use function date;
20-
use function sprintf;
2111

2212
class DateFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
2313
{
2414

15+
public function __construct(private DateFunctionReturnTypeHelper $dateFunctionReturnTypeHelper)
16+
{
17+
}
18+
2519
public function isFunctionSupported(FunctionReflection $functionReflection): bool
2620
{
2721
return $functionReflection->getName() === 'date';
@@ -31,101 +25,16 @@ public function getTypeFromFunctionCall(
3125
FunctionReflection $functionReflection,
3226
FuncCall $functionCall,
3327
Scope $scope,
34-
): Type
28+
): ?Type
3529
{
3630
if (count($functionCall->getArgs()) === 0) {
37-
return new StringType();
38-
}
39-
$argType = $scope->getType($functionCall->getArgs()[0]->value);
40-
$constantStrings = $argType->getConstantStrings();
41-
42-
if (count($constantStrings) === 0) {
43-
return new StringType();
44-
}
45-
46-
if (count($constantStrings) === 1) {
47-
$constantString = $constantStrings[0]->getValue();
48-
49-
// see see https://www.php.net/manual/en/datetime.format.php
50-
switch ($constantString) {
51-
case 'd':
52-
return $this->buildNumericRangeType(1, 31, true);
53-
case 'j':
54-
return $this->buildNumericRangeType(1, 31, false);
55-
case 'N':
56-
return $this->buildNumericRangeType(1, 7, false);
57-
case 'w':
58-
return $this->buildNumericRangeType(0, 6, false);
59-
case 'm':
60-
return $this->buildNumericRangeType(1, 12, true);
61-
case 'n':
62-
return $this->buildNumericRangeType(1, 12, false);
63-
case 't':
64-
return $this->buildNumericRangeType(28, 31, false);
65-
case 'L':
66-
return $this->buildNumericRangeType(0, 1, false);
67-
case 'g':
68-
return $this->buildNumericRangeType(1, 12, false);
69-
case 'G':
70-
return $this->buildNumericRangeType(0, 23, false);
71-
case 'h':
72-
return $this->buildNumericRangeType(1, 12, true);
73-
case 'H':
74-
return $this->buildNumericRangeType(0, 23, true);
75-
case 'I':
76-
return $this->buildNumericRangeType(0, 1, false);
77-
}
78-
}
79-
80-
$types = [];
81-
foreach ($constantStrings as $constantString) {
82-
$types[] = new ConstantStringType(date($constantString->getValue()));
83-
}
84-
85-
$type = TypeCombinator::union(...$types);
86-
if ($type->isNumericString()->yes()) {
87-
return new IntersectionType([
88-
new StringType(),
89-
new AccessoryNumericStringType(),
90-
]);
91-
}
92-
93-
if ($type->isNonFalsyString()->yes()) {
94-
return new IntersectionType([
95-
new StringType(),
96-
new AccessoryNonFalsyStringType(),
97-
]);
98-
}
99-
100-
if ($type->isNonEmptyString()->yes()) {
101-
return new IntersectionType([
102-
new StringType(),
103-
new AccessoryNonEmptyStringType(),
104-
]);
105-
}
106-
107-
if ($type->isNonEmptyString()->no()) {
108-
return new ConstantStringType('');
109-
}
110-
111-
return new StringType();
112-
}
113-
114-
private function buildNumericRangeType(int $min, int $max, bool $zeroPad): Type
115-
{
116-
$types = [];
117-
118-
for ($i = $min; $i <= $max; $i++) {
119-
$string = (string) $i;
120-
121-
if ($zeroPad) {
122-
$string = sprintf('%02s', $string);
123-
}
124-
125-
$types[] = new ConstantStringType($string);
31+
return null;
12632
}
12733

128-
return new UnionType($types);
34+
return $this->dateFunctionReturnTypeHelper->getTypeFromFormatType(
35+
$scope->getType($functionCall->getArgs()[0]->value),
36+
false,
37+
);
12938
}
13039

13140
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
6+
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
7+
use PHPStan\Type\Accessory\AccessoryNumericStringType;
8+
use PHPStan\Type\Constant\ConstantStringType;
9+
use PHPStan\Type\IntersectionType;
10+
use PHPStan\Type\StringType;
11+
use PHPStan\Type\Type;
12+
use PHPStan\Type\TypeCombinator;
13+
use PHPStan\Type\UnionType;
14+
use function count;
15+
use function date;
16+
use function is_numeric;
17+
use function str_pad;
18+
use const STR_PAD_LEFT;
19+
20+
class DateFunctionReturnTypeHelper
21+
{
22+
23+
public function getTypeFromFormatType(Type $formatType, bool $useMicrosec): ?Type
24+
{
25+
$types = [];
26+
foreach ($formatType->getConstantStrings() as $formatString) {
27+
$types[] = $this->buildReturnTypeFromFormat($formatString->getValue(), $useMicrosec);
28+
}
29+
30+
if (count($types) === 0) {
31+
$types[] = $formatType->isNonEmptyString()->yes()
32+
? new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()])
33+
: new StringType();
34+
}
35+
36+
$type = TypeCombinator::union(...$types);
37+
38+
if ($type->isNumericString()->no() && $formatType->isNonEmptyString()->yes()) {
39+
$type = TypeCombinator::union($type, new IntersectionType([
40+
new StringType(), new AccessoryNonEmptyStringType(),
41+
]));
42+
}
43+
44+
return $type;
45+
}
46+
47+
public function buildReturnTypeFromFormat(string $formatString, bool $useMicrosec): Type
48+
{
49+
// see see https://www.php.net/manual/en/datetime.format.php
50+
switch ($formatString) {
51+
case 'd':
52+
return $this->buildNumericRangeType(1, 31, true);
53+
case 'j':
54+
return $this->buildNumericRangeType(1, 31, false);
55+
case 'N':
56+
return $this->buildNumericRangeType(1, 7, false);
57+
case 'w':
58+
return $this->buildNumericRangeType(0, 6, false);
59+
case 'm':
60+
return $this->buildNumericRangeType(1, 12, true);
61+
case 'n':
62+
return $this->buildNumericRangeType(1, 12, false);
63+
case 't':
64+
return $this->buildNumericRangeType(28, 31, false);
65+
case 'L':
66+
return $this->buildNumericRangeType(0, 1, false);
67+
case 'g':
68+
return $this->buildNumericRangeType(1, 12, false);
69+
case 'G':
70+
return $this->buildNumericRangeType(0, 23, false);
71+
case 'h':
72+
return $this->buildNumericRangeType(1, 12, true);
73+
case 'H':
74+
return $this->buildNumericRangeType(0, 23, true);
75+
case 'I':
76+
return $this->buildNumericRangeType(0, 1, false);
77+
case 'u':
78+
return $useMicrosec
79+
? new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()])
80+
: new ConstantStringType('000000');
81+
}
82+
83+
$date = date($formatString);
84+
85+
// If parameter string is not included, returned as ConstantStringType
86+
if ($date === $formatString) {
87+
return new ConstantStringType($date);
88+
}
89+
90+
if (is_numeric($date)) {
91+
return new IntersectionType([new StringType(), new AccessoryNumericStringType()]);
92+
}
93+
94+
return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]);
95+
}
96+
97+
private function buildNumericRangeType(int $min, int $max, bool $zeroPad): Type
98+
{
99+
$types = [];
100+
101+
for ($i = $min; $i <= $max; $i++) {
102+
$string = (string) $i;
103+
104+
if ($zeroPad) {
105+
$string = str_pad($string, 2, '0', STR_PAD_LEFT);
106+
}
107+
108+
$types[] = new ConstantStringType($string);
109+
}
110+
111+
return new UnionType($types);
112+
}
113+
114+
}

tests/PHPStan/Analyser/NodeScopeResolverTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,8 @@ public function dataFileAsserts(): iterable
14501450
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10037.php');
14511451
yield from $this->gatherAssertTypes(__DIR__ . '/../Rules/PhpDoc/data/bug-10594.php');
14521452
yield from $this->gatherAssertTypes(__DIR__ . '/data/set-type-type-specifying.php');
1453+
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-10468.php');
1454+
yield from $this->gatherAssertTypes(__DIR__ . '/data/bug-6613.php');
14531455
}
14541456

14551457
/**
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Bug6613;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @param non-empty-string $non_empty_format
9+
*/
10+
function foo(string $format, string $non_empty_format): void
11+
{
12+
assertType("string", date($format));
13+
assertType("non-empty-string", date($non_empty_format));
14+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Bug6613;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function (\DateTime $dt) {
8+
assertType("'000000'", date('u'));
9+
assertType('non-falsy-string', date_format($dt, 'u'));
10+
assertType('non-falsy-string', $dt->format('u'));
11+
};

0 commit comments

Comments
 (0)