Skip to content

Commit 20d0ee5

Browse files
committed
Implement inline @var validation for return statements and add corresponding tests
1 parent 941639b commit 20d0ee5

4 files changed

Lines changed: 111 additions & 6 deletions

File tree

src/Contract/DocblockExtractor.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,29 @@ public static function extractTypeFromPropertyDoc(string $doc, string $propName)
103103
return null;
104104
}
105105

106+
/**
107+
* Extracts the first @var tag's type string and variable name from a docblock string.
108+
*
109+
* @return array{0: string, 1: string}|null
110+
*/
111+
public static function extractVarTagFromDoc(string $doc): ?array
112+
{
113+
try {
114+
$phpDocNode = self::parseDocString($doc);
115+
$varTags = $phpDocNode->getVarTagValues();
116+
if (\count($varTags) > 0) {
117+
$typeString = (string) $varTags[0]->type;
118+
$varName = ltrim($varTags[0]->variableName, '$');
119+
120+
return [$typeString, $varName];
121+
}
122+
} catch (\Throwable $e) {
123+
// Silently ignore malformed docblocks
124+
}
125+
126+
return null;
127+
}
128+
106129
/**
107130
* Extracts local and imported type aliases (@phpstan-type and @phpstan-import-type) from a PHPDoc node.
108131
*
@@ -226,4 +249,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam
226249

227250
return null;
228251
}
229-
}
252+
}

src/Internal/Checker/InlineChecker.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,22 @@ public static function checkVariable(mixed $value, string $typeString, string $v
9898
return $value;
9999
}
100100

101+
$context = ($varName === 'return') ? 'Return value' : "Variable \$$varName";
102+
101103
if ($typeNode instanceof CallableTypeNode || ($typeNode instanceof IdentifierTypeNode && strtolower($typeNode->name) === 'callable')) {
102-
return CallableWrapper::wrapTypeNode($typeNode, $value, "Variable \$$varName: Callback", $registry);
104+
$cbPrefix = ($varName === 'return') ? 'Return value: Callback' : "Variable \$$varName: Callback";
105+
106+
return CallableWrapper::wrapTypeNode($typeNode, $value, $cbPrefix, $registry);
103107
}
104108

105109
if ($typeNode instanceof GenericTypeNode && $checkGenerics && \is_object($value)) {
106-
$err = TemplateManager::bindInstanceFromNode($value, $typeNode, "Variable \$$varName", true);
110+
$err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context, true);
107111
if ($err !== null) {
108112
return $err;
109113
}
110114
}
111115

112-
$err = $registry->validate($value, $typeNode, "Variable \$$varName");
116+
$err = $registry->validate($value, $typeNode, $context);
113117
if ($err !== null) {
114118
return $err;
115119
}
@@ -305,4 +309,4 @@ private static function shouldValidateType(TypeNode $node, array $config): bool
305309

306310
return (bool) ($config['scalars'] ?? false);
307311
}
308-
}
312+
}

src/Internal/ContractVisitor.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PhpParser\Node;
88
use PhpParser\NodeTraverser;
99
use PhpParser\NodeVisitorAbstract;
10+
use TypePHP\Contract\DocblockExtractor;
1011
use TypePHP\Internal\Visitor\FunctionContractInjector;
1112
use TypePHP\Internal\Visitor\NodeBuilder;
1213
use TypePHP\Internal\Visitor\PropertyHookInjector;
@@ -68,6 +69,19 @@ public function enterNode(Node $node): array|int|null
6869
return null;
6970
}
7071

72+
if ($node instanceof Node\Stmt\Return_ && $node->expr !== null) {
73+
$doc = $node->getDocComment();
74+
if ($doc !== null && str_contains($doc->getText(), '@var')) {
75+
$extracted = DocblockExtractor::extractVarTagFromDoc($doc->getText());
76+
if ($extracted !== null) {
77+
[$typeString, $varName] = $extracted;
78+
$effectiveVarName = ($varName !== '') ? $varName : 'return';
79+
$checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $effectiveVarName);
80+
$node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->getStartLine());
81+
}
82+
}
83+
}
84+
7185
if ($node instanceof Node\Stmt\Expression) {
7286
$doc = $node->getDocComment();
7387
if ($doc !== null && str_contains($doc->getText(), '@var')) {
@@ -214,4 +228,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_
214228

215229
return $vars;
216230
}
217-
}
231+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Function with broad return type, but specific inline @var on return statement
7+
*/
8+
function testInlineVarOnReturnArray(): array
9+
{
10+
/** @var list<string> */
11+
return [1, 2, 3];
12+
}
13+
14+
/**
15+
* Valid inline @var on return statement
16+
*/
17+
function testValidInlineVarOnReturn(): array
18+
{
19+
/** @var list<string> */
20+
return ['apple', 'banana', 'cherry'];
21+
}
22+
23+
/**
24+
* Named @var tag on return statement
25+
*/
26+
function testNamedInlineVarOnReturn(int $id): int
27+
{
28+
/** @var positive-int $id */
29+
return $id;
30+
}
31+
32+
/**
33+
* Inline @var array shape on return inside a closure
34+
*/
35+
function testInlineVarOnReturnInClosure(): array
36+
{
37+
$closure = function (): array {
38+
/** @var array{id: positive-int, name: non-empty-string} */
39+
return ['id' => -10, 'name' => 'Alice'];
40+
};
41+
42+
return $closure();
43+
}
44+
45+
describe('Inline @var Validation on Direct Return Statements', function () {
46+
test('validates and accepts valid return expression with inline @var', function () {
47+
expect(testValidInlineVarOnReturn())->toBe(['apple', 'banana', 'cherry']);
48+
});
49+
50+
test('throws TypeError when direct return expression violates unnamed inline @var contract', function () {
51+
expect(fn () => testInlineVarOnReturnArray())
52+
->toThrow(TypeError::class, 'must be of type string');
53+
});
54+
55+
test('throws TypeError when direct return expression violates named inline @var contract', function () {
56+
expect(fn () => testNamedInlineVarOnReturn(-5))
57+
->toThrow(TypeError::class, 'positive-int');
58+
});
59+
60+
test('throws TypeError when closure return expression violates inline @var array shape', function () {
61+
expect(fn () => testInlineVarOnReturnInClosure())
62+
->toThrow(TypeError::class, 'positive-int');
63+
});
64+
});

0 commit comments

Comments
 (0)