Skip to content

Commit aedfac9

Browse files
fix(HeaderCollectionDynamicMethodReturnTypeExtension): Refactor dynamic return type inference for HeaderCollection::get() method in PHPStan analysis, and add tests suite for type inference. (#68)
1 parent 69f83ed commit aedfac9

File tree

5 files changed

+283
-32
lines changed

5 files changed

+283
-32
lines changed

.styleci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ enabled:
1818
- declare_strict_types
1919
- dir_constant
2020
- empty_loop_body_braces
21-
- fully_qualified_strict_types
22-
- function_to_constant
2321
- hash_to_slash_comment
2422
- integer_literal_case
2523
- is_null
@@ -83,5 +81,6 @@ enabled:
8381

8482
disabled:
8583
- function_declaration
84+
- new_with_parentheses
8685
- psr12_braces
8786
- psr12_class_definition

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,23 @@ $service = $container->get(MyService::class); // MyService
171171
$logger = $container->get('logger'); // LoggerInterface (if configured) or mixed
172172
```
173173

174+
#### Header collection
175+
176+
```php
177+
$headers = new HeaderCollection();
178+
179+
// ✅ Typed as string|null
180+
$host = $headers->get('Host');
181+
182+
// ✅ Typed as array<int, string>
183+
$forwardedFor = $headers->get('X-Forwarded-For', ['127.0.0.1'], false);
184+
185+
// ✅ Dynamic return type inference with mixed default
186+
$default = 'default-value';
187+
$requestId = $headers->get('X-Request-ID', $default, true); // string|null
188+
$allRequestIds = $headers->get('X-Request-ID', $default, false); // array<int, string>|null
189+
```
190+
174191
#### Service locator
175192

176193
```php

src/type/HeaderCollectionDynamicMethodReturnTypeExtension.php

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,34 @@
44

55
namespace yii2\extensions\phpstan\type;
66

7-
use PhpParser\Node\Arg;
8-
use PhpParser\Node\Expr\{ConstFetch, MethodCall};
7+
use PhpParser\Node\Expr\MethodCall;
98
use PHPStan\Analyser\Scope;
109
use PHPStan\Reflection\MethodReflection;
1110
use PHPStan\ShouldNotHappenException;
12-
use PHPStan\Type\{
13-
ArrayType,
14-
DynamicMethodReturnTypeExtension,
15-
IntegerType,
16-
StringType,
17-
Type,
18-
UnionType,
19-
};
11+
use PHPStan\Type\{ArrayType, DynamicMethodReturnTypeExtension, IntegerType, NullType, StringType, Type, UnionType};
2012
use yii\web\HeaderCollection;
2113

2214
use function count;
2315

2416
/**
2517
* Provides dynamic return type extension for Yii {@see HeaderCollection::get()} method in PHPStan analysis.
2618
*
27-
* Integrates Yii's {@see HeaderCollection} dynamic return types with PHPStan static analysis, enabling accurate type
19+
* Integrates Yii {@see HeaderCollection} dynamic return types with PHPStan static analysis, enabling accurate type
2820
* inference for the {@see HeaderCollection::get()} method based on the runtime context and method arguments.
2921
*
30-
* This extension allows PHPStan to infer the correct return type for the {@see HeaderCollection::get()}` method,
22+
* This extension allows PHPStan to infer the correct return type for the {@see HeaderCollection::get()} method,
3123
* supporting both string and array return types depending on the third argument, and handling the dynamic behavior of
3224
* header value retrieval in Yii HTTP handling.
3325
*
3426
* The implementation inspects the method arguments to determine the appropriate return type, ensuring that static
3527
* analysis and IDE autocompletion reflect the actual runtime behavior of {@see HeaderCollection::get()} method.
3628
*
3729
* Key features.
30+
* - Correctly handles nullable returns when default is null or not provided.
3831
* - Dynamic return type inference for the {@see HeaderCollection::get()} method based on the third argument.
3932
* - Ensures compatibility with PHPStan strict analysis and autocompletion.
4033
* - Handles runtime context and method argument inspection.
41-
* - Provides accurate type information for IDEs and static analysis tools.
34+
* - Provides accurate type information for IDE and static analysis tools.
4235
* - Supports both string and array result types for header retrieval.
4336
*
4437
* @see DynamicMethodReturnTypeExtension for PHPStan dynamic return type extension contract.
@@ -51,8 +44,8 @@ final class HeaderCollectionDynamicMethodReturnTypeExtension implements DynamicM
5144
/**
5245
* Returns the class name for which this dynamic return type extension applies.
5346
*
54-
* Specifies the fully qualified class name of the Yii {@see HeaderCollection} class that this extension targets
55-
* for dynamic return type inference in PHPStan analysis.
47+
* Specifies the fully qualified class name of the Yii {@see HeaderCollection} class that this extension targets for
48+
* dynamic return type inference in PHPStan analysis.
5649
*
5750
* This enables PHPStan to apply custom return type logic for the {@see HeaderCollection::get()} method, supporting
5851
* accurate type inference and IDE autocompletion for header value retrieval in Yii HTTP handling.
@@ -67,34 +60,59 @@ public function getClass(): string
6760
}
6861

6962
/**
70-
* @throws ShouldNotHappenException if the method is not supported or the arguments are invalid.
63+
* Infers the dynamic return type for {@see HeaderCollection::get()} based on method arguments.
64+
*
65+
* Determines the return type of the {@see HeaderCollection::get()} method by analyzing the provided arguments at
66+
* call site.
67+
*
68+
* The return type is inferred as `string`, `array<int, string>`, or `null` depending on the value of the third
69+
* argument ('$first') and the default value argument.
70+
* - If '$first' is `true`, the return type is `string`;
71+
* - If `false`, it is `array<int, string>`; if omitted or indeterminate, both types are included in a union.
72+
* - If the default value can be `null`, `null` is also included in the union type.
73+
*
74+
* This enables PHPStan to provide accurate type inference and autocompletion for header value retrieval in Yii HTTP
75+
* handling, reflecting the actual runtime behavior of {@see HeaderCollection::get()}.
76+
*
77+
* @param MethodReflection $methodReflection Reflection of the called method.
78+
* @param MethodCall $methodCall AST node representing the method call.
79+
* @param Scope $scope Current static analysis scope.
80+
*
81+
* @throws ShouldNotHappenException if the method is not supported or arguments are invalid.
82+
*
83+
* @return Type Inferred return type: `string`, `array<int, string>`, `null`, or a union of these types.
7184
*/
7285
public function getTypeFromMethodCall(
7386
MethodReflection $methodReflection,
7487
MethodCall $methodCall,
7588
Scope $scope,
7689
): Type {
7790
$args = $methodCall->getArgs();
91+
$types = [];
7892

79-
if (count($args) < 3) {
80-
return new StringType();
81-
}
93+
$canBeNull = true;
8294

83-
/** @var Arg $arg */
84-
$arg = $args[2] ?? null;
95+
if (isset($args[1])) {
96+
$defaultType = $scope->getType($args[1]->value);
97+
$canBeNull = $defaultType->accepts(new NullType(), true)->yes();
98+
}
8599

86-
if ($arg->value instanceof ConstFetch) {
87-
$value = $arg->value->name->getParts()[0];
88-
if ($value === 'true') {
89-
return new StringType();
90-
}
100+
if (isset($args[2]) === false) {
101+
$types[] = new StringType();
102+
} else {
103+
$firstType = $scope->getType($args[2]->value);
104+
$types = match (true) {
105+
$firstType->isTrue()->yes() => [new StringType()],
106+
$firstType->isFalse()->yes() => [new ArrayType(new IntegerType(), new StringType())],
107+
default => [new StringType(), new ArrayType(new IntegerType(), new StringType())],
108+
};
109+
}
91110

92-
if ($value === 'false') {
93-
return new ArrayType(new IntegerType(), new StringType());
94-
}
111+
if ($canBeNull) {
112+
$types[] = new NullType();
95113
}
96114

97-
return new UnionType([new ArrayType(new IntegerType(), new StringType()), new StringType()]);
115+
return count($types) === 1 ? $types[0] : new UnionType($types);
98116
}
99117

100118
/**
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace yii2\extensions\phpstan\tests\data\type;
6+
7+
use PHPUnit\Framework\Attributes\TestWith;
8+
use yii\web\HeaderCollection;
9+
10+
use function PHPStan\Testing\assertType;
11+
12+
/**
13+
* Test suite for dynamic return types of {@see HeaderCollection::get()} method in Yii HTTP header scenarios.
14+
*
15+
* Validates type inference and return types for the header collection {@see HeaderCollection::get()} method, covering
16+
* scenarios with different argument combinations, default values, first parameter variations, and various header
17+
* retrieval patterns.
18+
*
19+
* These tests ensure that PHPStan correctly infers the result types for HeaderCollection lookups, including string
20+
* returns, array returns, nullable returns, and union types based on method arguments.
21+
*
22+
* Key features:
23+
* - Array return type when first parameter is `false`.
24+
* - Dynamic return type inference based on the third argument (first parameter).
25+
* - Nullable return handling based on default value argument.
26+
* - String return type when first parameter is `true`.
27+
* - Union types when first parameter is indeterminate.
28+
*
29+
* @copyright Copyright (C) 2025 Terabytesoftw.
30+
* @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License.
31+
*/
32+
final class HeaderCollectionDynamicMethodReturnType
33+
{
34+
public function testReturnArrayWhenFirstIsFalse(): void
35+
{
36+
$headers = new HeaderCollection();
37+
38+
assertType('array<int, string>|null', $headers->get('Accept', null, false));
39+
assertType('array<int, string>', $headers->get('Accept', [], false));
40+
assertType('array<int, string>', $headers->get('Accept', 'default', false));
41+
}
42+
43+
public function testReturnStringWhenFirstIsTrue(): void
44+
{
45+
$headers = new HeaderCollection();
46+
47+
assertType('string|null', $headers->get('Content-Type'));
48+
assertType('string|null', $headers->get('Content-Type', null));
49+
assertType('string|null', $headers->get('Content-Type', null, true));
50+
assertType('string', $headers->get('Content-Type', 'default'));
51+
assertType('string', $headers->get('Content-Type', 'default', true));
52+
}
53+
54+
#[TestWith([false])]
55+
#[TestWith([true])]
56+
public function testReturnUnionWhenFirstIsIndeterminate(bool $first): void
57+
{
58+
$headers = new HeaderCollection();
59+
60+
assertType('array<int, string>|string|null', $headers->get('User-Agent', null, $first));
61+
assertType('array<int, string>|string', $headers->get('User-Agent', 'default', $first));
62+
}
63+
64+
public function testReturnWithArrayDefaultAndFirstTrue(): void
65+
{
66+
$headers = new HeaderCollection();
67+
68+
// when default is array but first is `true`, still returns `string`
69+
assertType('string', $headers->get('X-Forwarded-For', ['127.0.0.1'], true));
70+
assertType('array<int, string>', $headers->get('X-Forwarded-For', ['127.0.0.1'], false));
71+
}
72+
73+
public function testReturnWithBooleanFirstParameter(): void
74+
{
75+
$headers = new HeaderCollection();
76+
77+
// explicit `true`
78+
assertType('string|null', $headers->get('Content-Length', null, true));
79+
assertType('string', $headers->get('Content-Length', '0', true));
80+
81+
// explicit `false`
82+
assertType('array<int, string>|null', $headers->get('Accept-Encoding', null, false));
83+
assertType('array<int, string>', $headers->get('Accept-Encoding', [], false));
84+
}
85+
86+
public function testReturnWithComplexScenarios(): void
87+
{
88+
$headers = new HeaderCollection();
89+
90+
// no arguments beyond name - should default to first=`true`
91+
assertType('string|null', $headers->get('Host'));
92+
93+
// only name and default
94+
assertType('string', $headers->get('Referer', 'http://example.com'));
95+
assertType('string|null', $headers->get('Origin', null));
96+
97+
// all three arguments with various combinations
98+
assertType('string', $headers->get('Accept-Language', 'en-US', true));
99+
assertType('array<int, string>', $headers->get('Accept-Charset', ['utf-8'], false));
100+
}
101+
102+
#[TestWith([false])]
103+
#[TestWith([true])]
104+
public function testReturnWithDynamicBooleanFirstParameter(bool $first): void
105+
{
106+
$headers = new HeaderCollection();
107+
108+
assertType('array<int, string>|string|null', $headers->get('Connection', null, $first));
109+
assertType('array<int, string>|string', $headers->get('Connection', 'keep-alive', $first));
110+
}
111+
112+
public function testReturnWithExplicitNullDefault(): void
113+
{
114+
$headers = new HeaderCollection();
115+
116+
assertType('string|null', $headers->get('Expires', null));
117+
assertType('string|null', $headers->get('Expires', null, true));
118+
assertType('array<int, string>|null', $headers->get('Expires', null, false));
119+
}
120+
121+
#[TestWith(['string-default'])]
122+
#[TestWith([null])]
123+
public function testReturnWithMixedTypeDefault(string|null $default): void
124+
{
125+
$headers = new HeaderCollection();
126+
127+
assertType('string|null', $headers->get('X-Request-ID', $default, true));
128+
assertType('array<int, string>|null', $headers->get('X-Request-ID', $default, false));
129+
}
130+
131+
public function testReturnWithNonNullDefault(): void
132+
{
133+
$headers = new HeaderCollection();
134+
135+
assertType('string', $headers->get('Authorization', 'Bearer token'));
136+
assertType('string', $headers->get('Authorization', 'Bearer token', true));
137+
assertType('array<int, string>', $headers->get('Authorization', ['Bearer token'], false));
138+
}
139+
140+
#[TestWith(['fallback'])]
141+
#[TestWith([null])]
142+
public function testReturnWithNullableDefault(string|null $default): void
143+
{
144+
$headers = new HeaderCollection();
145+
146+
assertType('string|null', $headers->get('X-Custom-Header', $default));
147+
assertType('string|null', $headers->get('X-Custom-Header', $default, true));
148+
assertType('array<int, string>|null', $headers->get('X-Custom-Header', $default, false));
149+
}
150+
151+
public function testReturnWithVariableDefault(): void
152+
{
153+
$headers = new HeaderCollection();
154+
155+
$defaultValue = 'fallback-value';
156+
157+
assertType('string', $headers->get('Cache-Control', $defaultValue));
158+
assertType('string', $headers->get('Cache-Control', $defaultValue, true));
159+
assertType('array<int, string>', $headers->get('Cache-Control', $defaultValue, false));
160+
}
161+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace yii2\extensions\phpstan\tests\type;
6+
7+
use PHPStan\Testing\TypeInferenceTestCase;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use yii\web\HeaderCollection;
10+
11+
/**
12+
* Test suite for type inference of dynamic method return types in {@see HeaderCollection} for Yii component scenarios.
13+
*
14+
* Validates that PHPStan correctly infers types for dynamic HeaderCollection method calls and result sets in custom
15+
* {@see HeaderCollection} usage, using fixture-based assertions for header retrieval, value resolution, and
16+
* return type inference.
17+
*
18+
* The test class loads type assertions from a fixture file and delegates checks to the parent
19+
* {@see TypeInferenceTestCase}, ensuring that extension logic for {@see HeaderCollection} dynamic method return types is
20+
* robust and consistent with expected behavior.
21+
*
22+
* Key features:
23+
* - Ensures compatibility with PHPStan extension configuration.
24+
* - Loads and executes type assertions from a dedicated fixture file.
25+
* - Uses PHPUnit DataProvider for parameterized test execution.
26+
* - Validates type inference for header retrieval methods and result types.
27+
* - Tests different return type scenarios based on method arguments.
28+
*
29+
* @copyright Copyright (C) 2025 Terabytesoftw.
30+
* @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License.
31+
*/
32+
final class HeaderCollectionDynamicMethodReturnTypeExtensionTest extends TypeInferenceTestCase
33+
{
34+
/**
35+
* @return iterable<mixed>
36+
*/
37+
public static function dataFileAsserts(): iterable
38+
{
39+
$directory = dirname(__DIR__);
40+
41+
yield from self::gatherAssertTypes(
42+
"{$directory}/data/type/HeaderCollectionDynamicMethodReturnType.php",
43+
);
44+
}
45+
46+
public static function getAdditionalConfigFiles(): array
47+
{
48+
return [dirname(__DIR__) . '/extension-test.neon'];
49+
}
50+
51+
#[DataProvider('dataFileAsserts')]
52+
public function testFileAsserts(string $assertType, string $file, mixed ...$args): void
53+
{
54+
$this->assertFileAsserts($assertType, $file, ...$args);
55+
}
56+
}

0 commit comments

Comments
 (0)