Skip to content

Commit c563b1a

Browse files
committed
added Forms\Container::getComponent() and $form['…'] return type narrowing based on addXxx() calls
1 parent a05f297 commit c563b1a

6 files changed

Lines changed: 331 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ vendor/bin/tester tests/SomeTest.phpt -s # Run a single test
2020
- **`src/Tester/TypeAssert.php`** — Reusable type inference testing helper for Nette Tester (used by other Nette packages)
2121
- **`extension.neon`** — Entry point, includes `extension-php.neon` and `extension-nette.neon`, auto-included by `phpstan/extension-installer`
2222
- **`extension-php.neon`** — Generic PHP-level extensions (RemoveFailingReturnType, ClosureTypeCheckIgnore)
23-
- **`extension-nette.neon`** — All Nette package extensions (component-model, schema, tester, utils), separated by comments
23+
- **`extension-nette.neon`** — All Nette package extensions (component-model, forms, schema, tester, utils), separated by comments
2424
- **`phpstan.neon`** — Self-analysis config (level 8, analyses `src/` and `tests/`)
2525

2626
### How extensions are registered
@@ -73,6 +73,10 @@ Extensions for specific Nette packages use dedicated namespaces: `Nette\PHPStan\
7373

7474
`GetComponentReturnTypeExtension` (`DynamicMethodReturnTypeExtension`) narrows return types of `Container::getComponent()` and `Container::offsetGet()` (i.e. `$this['xxx']`). When the component name is a constant string, it looks for a `createComponent<Name>()` factory method on the caller type and returns its return type — e.g. `$this->getComponent('poll')` returns `PollControl` if `createComponentPoll(): PollControl` exists. Falls back to the declared return type when no factory method is found. Config: `extension-nette.neon`.
7575

76+
### FormContainerReturnTypeExtension
77+
78+
`FormContainerReturnTypeExtension` (`DynamicMethodReturnTypeExtension`) narrows return types of `Forms\Container::getComponent()` and `::offsetGet()` (i.e. `$form['xxx']`) based on `addXxx()` calls in the same function body. When the component name is a constant string, it parses the current file, finds the enclosing function/method, and walks the AST looking for `$form->addText('name')`, `$form->addSelect('name')`, etc. on the same variable. Returns the `addXxx` method's declared return type — e.g. `$form['name']` returns `TextInput` after `$form->addText('name', ...)`. Falls back to `createComponent*()` factory lookup. Only matches simple variable names (not complex expressions). Config: `extension-nette.neon`.
79+
7680
### AssertTypeNarrowingExtension
7781

7882
`AssertTypeNarrowingExtension` (`StaticMethodTypeSpecifyingExtension` + `TypeSpecifierAwareExtension`) narrows variable types after `Tester\Assert` assertion calls. Each assertion method is mapped to an equivalent PHP expression that PHPStan already understands, then delegated to `TypeSpecifier::specifyTypesInCondition()`. Supported methods: `null`, `notNull`, `true`, `false`, `truthy`, `falsey`, `same`, `notSame`, and `type` (with built-in type strings like `'string'`, `'int'`, etc. and class/interface names). Config: `extension-nette.neon`.

extension-nette.neon

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ services:
1313
class: Nette\PHPStan\ComponentModel\GetComponentReturnTypeExtension
1414
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
1515

16+
# nette/forms
17+
-
18+
class: Nette\PHPStan\Forms\FormContainerReturnTypeExtension
19+
arguments:
20+
parser: @defaultAnalysisParser
21+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
22+
1623
# nette/schema
1724
-
1825
class: Nette\PHPStan\Schema\ExpectArrayReturnTypeExtension

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ includes:
4141

4242
<!---->
4343

44-
**Precise return types** — narrows return types of `Strings::match()`, `matchAll()`, `split()`, `Helpers::falseToNull()`, `Expect::array()`, `Arrays::invoke()`, and `Arrays::invokeMethod()` based on the arguments you pass. Also narrows `Container::getComponent()` and `$container['...']` to match the corresponding `createComponent*()` factory return type.
44+
**Precise return types** — narrows return types of `Strings::match()`, `matchAll()`, `split()`, `Helpers::falseToNull()`, `Expect::array()`, `Arrays::invoke()`, and `Arrays::invokeMethod()` based on the arguments you pass. Also narrows `Container::getComponent()` and `$container['...']` to match the corresponding `createComponent*()` factory return type. For forms, `$form['name']` returns the specific control type (e.g. `TextInput`, `SelectBox`) based on the `addText()`, `addSelect()`, etc. call in the same function.
4545

4646
**Removes `|false` and `|null` from PHP functions** — many native functions like `getcwd`, `json_encode`, `preg_split`, `preg_replace`, and [many more](extension-php.neon) include `false` or `null` in their return type even though these error values are unrealistic on modern systems.
4747

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Forms;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr\MethodCall;
7+
use PhpParser\Node\Expr\Variable;
8+
use PhpParser\Node\Identifier;
9+
use PhpParser\Node\Scalar\String_;
10+
use PhpParser\Node\Stmt;
11+
use PHPStan\Analyser\Scope;
12+
use PHPStan\Parser\Parser;
13+
use PHPStan\Reflection\MethodReflection;
14+
use PHPStan\Reflection\ReflectionProvider;
15+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
16+
use PHPStan\Type\ObjectType;
17+
use PHPStan\Type\Type;
18+
use PHPStan\Type\TypeCombinator;
19+
use function count, in_array, is_array, is_string, str_starts_with, ucfirst;
20+
21+
22+
/**
23+
* Narrows return types of Forms\Container::getComponent() and ::offsetGet()
24+
* by finding the corresponding addXxx() call in the same function body.
25+
*/
26+
class FormContainerReturnTypeExtension implements DynamicMethodReturnTypeExtension
27+
{
28+
public function __construct(
29+
private Parser $parser,
30+
private ReflectionProvider $reflectionProvider,
31+
) {
32+
}
33+
34+
35+
public function getClass(): string
36+
{
37+
return 'Nette\Forms\Container';
38+
}
39+
40+
41+
public function isMethodSupported(MethodReflection $methodReflection): bool
42+
{
43+
return in_array($methodReflection->getName(), ['getComponent', 'offsetGet'], true);
44+
}
45+
46+
47+
public function getTypeFromMethodCall(
48+
MethodReflection $methodReflection,
49+
MethodCall $methodCall,
50+
Scope $scope,
51+
): ?Type
52+
{
53+
$args = $methodCall->getArgs();
54+
if ($args === []) {
55+
return null;
56+
}
57+
58+
$nameType = $scope->getType($args[0]->value);
59+
$constantStrings = $nameType->getConstantStrings();
60+
if (count($constantStrings) !== 1) {
61+
return null;
62+
}
63+
64+
$componentName = $constantStrings[0]->getValue();
65+
$type = $this->resolveComponentType($methodCall, $scope, $componentName);
66+
67+
// Respect $throw parameter for getComponent()
68+
if ($methodReflection->getName() === 'getComponent' && count($args) >= 2) {
69+
$throwType = $scope->getType($args[1]->value);
70+
if (!$throwType->isTrue()->yes()) {
71+
$type = TypeCombinator::addNull($type);
72+
}
73+
}
74+
75+
return $type;
76+
}
77+
78+
79+
private function resolveComponentType(MethodCall $methodCall, Scope $scope, string $componentName): Type
80+
{
81+
// Try to find addXxx() call in the same function body
82+
if ($methodCall->var instanceof Variable && is_string($methodCall->var->name)) {
83+
$type = $this->resolveFromAddCall($scope, $methodCall->var->name, $componentName);
84+
if ($type !== null) {
85+
return $type;
86+
}
87+
}
88+
89+
// Fallback: try createComponent*() factory method
90+
$factoryMethodName = 'createComponent' . ucfirst($componentName);
91+
$callerType = $scope->getType($methodCall->var);
92+
if ($callerType->hasMethod($factoryMethodName)->yes()) {
93+
$factoryMethod = $callerType->getMethod($factoryMethodName, $scope);
94+
return $factoryMethod->getVariants()[0]->getReturnType();
95+
}
96+
97+
// Fallback: when we can't determine the specific control type,
98+
// return BaseControl (most $form['field'] accesses are controls, not containers)
99+
return new ObjectType('Nette\Forms\Controls\BaseControl');
100+
}
101+
102+
103+
private function resolveFromAddCall(Scope $scope, string $variableName, string $componentName): ?Type
104+
{
105+
$stmts = $this->parser->parseFile($scope->getFile());
106+
$body = $this->findEnclosingBody($stmts, $scope);
107+
if ($body === null) {
108+
return null;
109+
}
110+
111+
$addMethodName = $this->findAddCall($body, $variableName, $componentName);
112+
if ($addMethodName === null) {
113+
return null;
114+
}
115+
116+
$containerClass = $this->reflectionProvider->getClass('Nette\Forms\Container');
117+
if (!$containerClass->hasMethod($addMethodName)) {
118+
return null;
119+
}
120+
121+
return $containerClass->getNativeMethod($addMethodName)->getVariants()[0]->getReturnType();
122+
}
123+
124+
125+
/**
126+
* @param Stmt[] $stmts
127+
* @return Stmt[]|null
128+
*/
129+
private function findEnclosingBody(array $stmts, Scope $scope): ?array
130+
{
131+
$function = $scope->getFunction();
132+
if ($function === null) {
133+
return $stmts;
134+
}
135+
136+
$functionName = $function->getName();
137+
$className = $scope->isInClass() ? $scope->getClassReflection()->getName() : null;
138+
return $this->searchBody($stmts, $functionName, $className);
139+
}
140+
141+
142+
/**
143+
* @param Stmt[] $stmts
144+
* @return Stmt[]|null
145+
*/
146+
private function searchBody(array $stmts, string $functionName, ?string $className): ?array
147+
{
148+
foreach ($stmts as $stmt) {
149+
if ($stmt instanceof Stmt\Namespace_) {
150+
$result = $this->searchBody($stmt->stmts, $functionName, $className);
151+
if ($result !== null) {
152+
return $result;
153+
}
154+
155+
} elseif (
156+
$className !== null
157+
&& ($stmt instanceof Stmt\Class_ || $stmt instanceof Stmt\Trait_)
158+
&& $stmt->namespacedName !== null
159+
&& $stmt->namespacedName->toString() === $className
160+
) {
161+
foreach ($stmt->stmts as $member) {
162+
if ($member instanceof Stmt\ClassMethod && $member->name->toString() === $functionName) {
163+
return $member->stmts ?? [];
164+
}
165+
}
166+
} elseif (
167+
$className === null
168+
&& $stmt instanceof Stmt\Function_
169+
&& $stmt->namespacedName !== null
170+
&& $stmt->namespacedName->toString() === $functionName
171+
) {
172+
return $stmt->stmts ?? [];
173+
}
174+
}
175+
176+
return null;
177+
}
178+
179+
180+
/**
181+
* Walks AST to find $variable->addXxx('componentName', ...) call.
182+
* @param Stmt[] $stmts
183+
*/
184+
private function findAddCall(array $stmts, string $variableName, string $componentName): ?string
185+
{
186+
foreach ($stmts as $stmt) {
187+
$result = $this->walkNode($stmt, $variableName, $componentName);
188+
if ($result !== null) {
189+
return $result;
190+
}
191+
}
192+
193+
return null;
194+
}
195+
196+
197+
private function walkNode(Node $node, string $variableName, string $componentName): ?string
198+
{
199+
if (
200+
$node instanceof MethodCall
201+
&& $node->var instanceof Variable
202+
&& $node->var->name === $variableName
203+
&& $node->name instanceof Identifier
204+
&& str_starts_with($node->name->toString(), 'add')
205+
&& $node->getArgs() !== []
206+
) {
207+
$firstArg = $node->getArgs()[0]->value;
208+
if ($firstArg instanceof String_ && $firstArg->value === $componentName) {
209+
return $node->name->toString();
210+
}
211+
}
212+
213+
foreach ($node->getSubNodeNames() as $name) {
214+
$subNode = $node->$name;
215+
if ($subNode instanceof Node) {
216+
$result = $this->walkNode($subNode, $variableName, $componentName);
217+
if ($result !== null) {
218+
return $result;
219+
}
220+
} elseif (is_array($subNode)) {
221+
foreach ($subNode as $item) {
222+
if ($item instanceof Node) {
223+
$result = $this->walkNode($item, $variableName, $componentName);
224+
if ($result !== null) {
225+
return $result;
226+
}
227+
}
228+
}
229+
}
230+
}
231+
232+
return null;
233+
}
234+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\Forms\Form;
4+
use function PHPStan\Testing\assertType;
5+
6+
7+
function testAddText(): void
8+
{
9+
$form = new Form;
10+
$form->addText('name', 'Name:');
11+
$form->addPassword('password', 'Password:');
12+
assertType('Nette\Forms\Controls\TextInput', $form['name']);
13+
assertType('Nette\Forms\Controls\TextInput', $form->getComponent('name'));
14+
assertType('Nette\Forms\Controls\TextInput', $form['password']);
15+
16+
// $throw = false → nullable
17+
assertType('Nette\Forms\Controls\TextInput|null', $form->getComponent('name', false));
18+
}
19+
20+
21+
function testAddSelect(): void
22+
{
23+
$form = new Form;
24+
$form->addSelect('country', 'Country:', ['CZ' => 'Czech Republic']);
25+
$form->addMultiSelect('tags', 'Tags:', ['a' => 'A', 'b' => 'B']);
26+
assertType('Nette\Forms\Controls\SelectBox', $form['country']);
27+
assertType('Nette\Forms\Controls\MultiSelectBox', $form['tags']);
28+
}
29+
30+
31+
function testAddCheckbox(): void
32+
{
33+
$form = new Form;
34+
$form->addCheckbox('agree', 'I agree');
35+
$form->addCheckboxList('colors', 'Colors:', ['r' => 'Red']);
36+
$form->addRadioList('gender', 'Gender:', ['m' => 'Male']);
37+
assertType('Nette\Forms\Controls\Checkbox', $form['agree']);
38+
assertType('Nette\Forms\Controls\CheckboxList', $form['colors']);
39+
assertType('Nette\Forms\Controls\RadioList', $form['gender']);
40+
}
41+
42+
43+
function testAddOther(): void
44+
{
45+
$form = new Form;
46+
$form->addTextArea('bio', 'Bio:');
47+
$form->addEmail('email', 'Email:');
48+
$form->addInteger('age', 'Age:');
49+
$form->addHidden('token');
50+
$form->addSubmit('send', 'Send');
51+
$form->addButton('cancel', 'Cancel');
52+
$form->addUpload('avatar', 'Avatar:');
53+
assertType('Nette\Forms\Controls\TextArea', $form['bio']);
54+
assertType('Nette\Forms\Controls\TextInput', $form['email']);
55+
assertType('Nette\Forms\Controls\TextInput', $form['age']);
56+
assertType('Nette\Forms\Controls\HiddenField', $form['token']);
57+
assertType('Nette\Forms\Controls\SubmitButton', $form['send']);
58+
assertType('Nette\Forms\Controls\Button', $form['cancel']);
59+
assertType('Nette\Forms\Controls\UploadControl', $form['avatar']);
60+
}
61+
62+
63+
function testAddContainer(): void
64+
{
65+
$form = new Form;
66+
$form->addContainer('address');
67+
assertType('Nette\Forms\Container', $form['address']);
68+
}
69+
70+
71+
function testUnknownComponent(): void
72+
{
73+
$form = new Form;
74+
assertType('Nette\Forms\Controls\BaseControl', $form['unknown']);
75+
}
76+
77+
78+
function testFallbackFromOtherMethod(Form $form): void
79+
{
80+
assertType('Nette\Forms\Controls\BaseControl', $form['unknown']);
81+
}

tests/extensions.phpt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ TypeAssert::assertNoErrors(__DIR__ . '/Php/closure-type-check.php');
1212
// ComponentModel
1313
TypeAssert::assertTypes(__DIR__ . '/ComponentModel/get-component-return-type.php');
1414

15+
// Forms
16+
TypeAssert::assertTypes(__DIR__ . '/Forms/form-component-return-type.php');
17+
1518
// Schema
1619
TypeAssert::assertTypes(__DIR__ . '/Schema/expect-array-return-type.php');
1720

0 commit comments

Comments
 (0)