Skip to content

Commit 4fa4568

Browse files
phpstan-botVincentLangletclaude
authored
Infer numeric-string for DateInterval::format('%a') when interval comes from diff() (#5674)
Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b22512d commit 4fa4568

6 files changed

Lines changed: 178 additions & 63 deletions

File tree

src/Type/Php/DateIntervalFormatDynamicReturnTypeExtension.php

Lines changed: 9 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,17 @@
77
use PHPStan\Analyser\Scope;
88
use PHPStan\DependencyInjection\AutowiredService;
99
use PHPStan\Reflection\MethodReflection;
10-
use PHPStan\Type\Accessory\AccessoryLowercaseStringType;
11-
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
12-
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
13-
use PHPStan\Type\Accessory\AccessoryNumericStringType;
14-
use PHPStan\Type\Accessory\AccessoryUppercaseStringType;
1510
use PHPStan\Type\DynamicMethodReturnTypeExtension;
16-
use PHPStan\Type\IntersectionType;
17-
use PHPStan\Type\StringType;
1811
use PHPStan\Type\Type;
19-
use PHPStan\Type\TypeCombinator;
20-
use function count;
21-
use function is_numeric;
22-
use function strtolower;
23-
use function strtoupper;
2412

2513
#[AutowiredService]
2614
final class DateIntervalFormatDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
2715
{
2816

17+
public function __construct(private DateIntervalFormatReturnTypeHelper $helper)
18+
{
19+
}
20+
2921
public function getClass(): string
3022
{
3123
return DateInterval::class;
@@ -44,51 +36,11 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method
4436
return null;
4537
}
4638

47-
$arg = $scope->getType($arguments[0]->value);
48-
49-
$constantStrings = $arg->getConstantStrings();
50-
if (count($constantStrings) === 0) {
51-
if ($arg->isNonEmptyString()->yes()) {
52-
return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]);
53-
}
54-
55-
return null;
56-
}
57-
58-
// The worst case scenario for the non-falsy-string check is that every number is 0.
59-
// `%a` format gives `(unknown)` and removes numeric and uppercase accessory but then
60-
// we'll have to manually check for the non-falsy one.
61-
$dateInterval = new DateInterval('P0D');
62-
63-
$possibleReturnTypes = [];
64-
foreach ($constantStrings as $string) {
65-
$formatString = $string->getValue();
66-
$value = $dateInterval->format($formatString);
67-
68-
$accessories = [];
69-
if (is_numeric($value)) {
70-
$accessories[] = new AccessoryNumericStringType();
71-
}
72-
if ($value !== '0' && $value !== '' && $formatString !== '%a') {
73-
$accessories[] = new AccessoryNonFalsyStringType();
74-
} elseif ($value !== '') {
75-
$accessories[] = new AccessoryNonEmptyStringType();
76-
}
77-
if (strtolower($value) === $value) {
78-
$accessories[] = new AccessoryLowercaseStringType();
79-
}
80-
if (strtoupper($value) === $value) {
81-
$accessories[] = new AccessoryUppercaseStringType();
82-
}
83-
84-
if (count($accessories) === 0) {
85-
return null;
86-
}
87-
88-
$possibleReturnTypes[] = new IntersectionType([new StringType(), ...$accessories]);
89-
}
90-
91-
return TypeCombinator::union(...$possibleReturnTypes);
39+
return $this->helper->getType(
40+
$scope->getType($arguments[0]->value),
41+
$scope->getType($methodCall->var),
42+
$scope,
43+
);
9244
}
9345

9446
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\AutowiredService;
8+
use PHPStan\Reflection\FunctionReflection;
9+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
10+
use PHPStan\Type\Type;
11+
use function count;
12+
13+
#[AutowiredService]
14+
final class DateIntervalFormatFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
15+
{
16+
17+
public function __construct(private DateIntervalFormatReturnTypeHelper $helper)
18+
{
19+
}
20+
21+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
22+
{
23+
return $functionReflection->getName() === 'date_interval_format';
24+
}
25+
26+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
27+
{
28+
$args = $functionCall->getArgs();
29+
if (count($args) < 2) {
30+
return null;
31+
}
32+
33+
return $this->helper->getType(
34+
$scope->getType($args[1]->value),
35+
$scope->getType($args[0]->value),
36+
$scope,
37+
);
38+
}
39+
40+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use DateInterval;
6+
use DateTime;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\DependencyInjection\AutowiredService;
9+
use PHPStan\Type\Accessory\AccessoryLowercaseStringType;
10+
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
11+
use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
12+
use PHPStan\Type\Accessory\AccessoryNumericStringType;
13+
use PHPStan\Type\Accessory\AccessoryUppercaseStringType;
14+
use PHPStan\Type\IntersectionType;
15+
use PHPStan\Type\StringType;
16+
use PHPStan\Type\Type;
17+
use PHPStan\Type\TypeCombinator;
18+
use function count;
19+
use function is_numeric;
20+
use function strtolower;
21+
use function strtoupper;
22+
23+
#[AutowiredService]
24+
final class DateIntervalFormatReturnTypeHelper
25+
{
26+
27+
public function getType(Type $formatType, Type $intervalType, Scope $scope): ?Type
28+
{
29+
$constantStrings = $formatType->getConstantStrings();
30+
if (count($constantStrings) === 0) {
31+
if ($formatType->isNonEmptyString()->yes()) {
32+
return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]);
33+
}
34+
35+
return null;
36+
}
37+
38+
$daysIsInt = $intervalType->hasInstanceProperty('days')->yes()
39+
&& $intervalType->getInstanceProperty('days', $scope)->getReadableType()->isInteger()->yes();
40+
41+
$dateInterval = $daysIsInt
42+
? (new DateTime('2000-01-01'))->diff(new DateTime('2000-01-01'))
43+
: new DateInterval('P0D');
44+
45+
$possibleReturnTypes = [];
46+
foreach ($constantStrings as $string) {
47+
$formatString = $string->getValue();
48+
$value = $dateInterval->format($formatString);
49+
50+
$accessories = [];
51+
if (is_numeric($value)) {
52+
$accessories[] = new AccessoryNumericStringType();
53+
}
54+
if ($value !== '0' && $value !== '' && ($formatString !== '%a' || $daysIsInt)) {
55+
$accessories[] = new AccessoryNonFalsyStringType();
56+
} elseif ($value !== '') {
57+
$accessories[] = new AccessoryNonEmptyStringType();
58+
}
59+
if (strtolower($value) === $value) {
60+
$accessories[] = new AccessoryLowercaseStringType();
61+
}
62+
if (strtoupper($value) === $value) {
63+
$accessories[] = new AccessoryUppercaseStringType();
64+
}
65+
66+
if (count($accessories) === 0) {
67+
return null;
68+
}
69+
70+
$possibleReturnTypes[] = new IntersectionType([new StringType(), ...$accessories]);
71+
}
72+
73+
return TypeCombinator::union(...$possibleReturnTypes);
74+
}
75+
76+
}
Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,44 @@
1-
<?php declare(strict_types = 1);
1+
<?php
22

33
namespace Bug1452;
44

5+
use DateInterval;
6+
use DateTime;
7+
use DateTimeImmutable;
8+
use DateTimeInterface;
59
use function PHPStan\Testing\assertType;
610

7-
$dateInterval = (new \DateTimeImmutable('now -60 minutes'))->diff(new \DateTimeImmutable('now'));
11+
function doFoo(): void {
12+
$dateInterval = (new DateTimeImmutable('now -60 minutes'))->diff(new DateTimeImmutable('now'));
13+
assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $dateInterval->format('%a'));
14+
assertType('float|int', $dateInterval->format('%a') * 60);
15+
}
816

9-
assertType(
10-
'lowercase-string&non-empty-string',
11-
$dateInterval->format('%a')
12-
);
17+
function doBar(DateTime $a, DateTime $b): void {
18+
$interval = $a->diff($b);
19+
assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $interval->format('%a'));
20+
assertType('lowercase-string&non-falsy-string&numeric-string&uppercase-string', $interval->format('%R%a'));
21+
}
22+
23+
function doBaz(DateTimeInterface $a, DateTimeInterface $b): void {
24+
$interval = $a->diff($b);
25+
assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $interval->format('%a'));
26+
}
27+
28+
function doDateDiff(DateTime $a, DateTime $b): void {
29+
$interval = date_diff($a, $b);
30+
assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', $interval->format('%a'));
31+
}
32+
33+
function doPlainInterval(DateInterval $interval): void {
34+
assertType('lowercase-string&non-empty-string', $interval->format('%a'));
35+
}
36+
37+
function doDateIntervalFormat(DateTime $a, DateTime $b): void {
38+
$interval = date_diff($a, $b);
39+
assertType('lowercase-string&non-empty-string&numeric-string&uppercase-string', date_interval_format($interval, '%a'));
40+
}
41+
42+
function doDateIntervalFormatPlain(DateInterval $interval): void {
43+
assertType('lowercase-string&non-empty-string', date_interval_format($interval, '%a'));
44+
}

tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,4 +853,9 @@ public function testBug10349(): void
853853
]);
854854
}
855855

856+
public function testBug1452(): void
857+
{
858+
$this->analyse([__DIR__ . '/data/bug-1452.php'], []);
859+
}
860+
856861
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace BugRuleTest1452;
4+
5+
use DateTimeImmutable;
6+
7+
function doFoo(): void {
8+
$dateInterval = (new DateTimeImmutable('now -60 minutes'))->diff(new DateTimeImmutable('now'));
9+
$minutes = $dateInterval->format('%a') * 60;
10+
}

0 commit comments

Comments
 (0)