Skip to content

Commit b24b167

Browse files
authored
Internal improvements 7 (#30)
* Add NativePipeRunner and PipePipelineService with multi-step pipeline functionality and tests * Implement inline @var validation for return statements and add corresponding tests * Enhance inline `@var` documentation and add support for direct return statement type assertions
1 parent 1b2e428 commit b24b167

8 files changed

Lines changed: 304 additions & 9 deletions

File tree

docs/core-concepts/inline-variables.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Inline Variables (`@var`)
22

3-
While parameter and return contracts protect function boundaries, inline `@var` annotations enforce type safety on local variable assignments and reassignments inside function bodies or php file execution lines.
3+
While parameter and return contracts protect function boundaries, inline `@var` annotations enforce type safety on local variable assignments, reassignments, and direct return statements inside function bodies or PHP scripts.
44

55
---
66

@@ -43,7 +43,7 @@ TypePHP supports both single-variable single-line docblocks and multi-variable d
4343
$id = 100;
4444

4545
/** @var non-empty-string $name -> single-line docblock */
46-
$name = 'Reymart'
46+
$name = 'Reymart';
4747
```
4848

4949
### Multi-Variable Annotation (Array Destructuring)
@@ -79,6 +79,33 @@ Both `/** @var positive-int $count */` and `/** @var positive-int */` behave ide
7979

8080
---
8181

82+
## Inline `@var` on Direct Return Statements
83+
84+
You can place `/** @var Type */` directly above a `return` statement to perform surgical, expression-level type assertion narrowing inside function bodies, closures, or specific conditional branches:
85+
86+
```php
87+
function fetchUserScores(): array
88+
{
89+
/** @var list<positive-int> */
90+
return [10, 20, 30]; // Valid
91+
}
92+
93+
function fetchBadScores(): array
94+
{
95+
/** @var list<positive-int> */
96+
return [10, -5, 30]; // Throws TypeError on -5!
97+
}
98+
99+
$getUser = function () use ($repo) {
100+
/** @var array{id: positive-int, username: non-empty-string} */
101+
return $repo->fetchRawUser();
102+
};
103+
```
104+
105+
> **PHPStan & Psalm Parity:** Static analysis tools treat `/** @var Type */ return $expr;` as an inline type cast assertion. TypePHP physically enforces this assertion at runtime, ensuring that live dynamic returns strictly satisfy the annotated type.
106+
107+
---
108+
82109
## Block-Level Scope Isolation & Shadowing
83110

84111
TypePHP tracks variable type contracts using **Lexical Block Scope Frames**.
@@ -146,4 +173,4 @@ You can enable or disable specific categories of inline variable validation in `
146173
'arrays' => true, // Array shapes and lists (array{id: int}, list<T>)
147174
'objects' => true, // Class instance checks (@var User $user)
148175
],
149-
```
176+
```

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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Pipes;
6+
7+
class NativePipeRunner
8+
{
9+
public function runPipeline(int $initialId, PipePipelineService $service): string
10+
{
11+
return $initialId
12+
|> $service->doubleId(...)
13+
|> $service->stringify(...)
14+
|> $service->prefixTag(...);
15+
}
16+
17+
public function runStandalonePipeline(int $id): string
18+
{
19+
return $id
20+
|> $this->stepOne(...)
21+
|> $this->stepTwo(...);
22+
}
23+
24+
/**
25+
* @param positive-int $id
26+
*
27+
* @return positive-int
28+
*/
29+
public function stepOne(int $id): int
30+
{
31+
return $id + 10;
32+
}
33+
34+
/**
35+
* @param positive-int $id
36+
*
37+
* @return non-empty-string
38+
*/
39+
public function stepTwo(int $id): string
40+
{
41+
return "piped_user_{$id}";
42+
}
43+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypePHP\Tests\Fixtures\Pipes;
6+
7+
class PipePipelineService
8+
{
9+
/**
10+
* @param positive-int $id
11+
*
12+
* @return positive-int
13+
*/
14+
public function doubleId(int $id): int
15+
{
16+
return $id * 2;
17+
}
18+
19+
/**
20+
* @param positive-int $id
21+
*
22+
* @return non-empty-string
23+
*/
24+
public function stringify(int $id): string
25+
{
26+
return "RECORD_{$id}";
27+
}
28+
29+
/**
30+
* @param non-empty-string $tag
31+
*
32+
* @return non-empty-string
33+
*/
34+
public function prefixTag(string $tag): string
35+
{
36+
return "[TAG] {$tag}";
37+
}
38+
}
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+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if (PHP_VERSION_ID < 80500) {
6+
return;
7+
}
8+
9+
use TypePHP\Internal\StreamWrapper;
10+
use TypePHP\Tests\Fixtures\Pipes\NativePipeRunner;
11+
use TypePHP\Tests\Fixtures\Pipes\PipePipelineService;
12+
13+
describe('PHP 8.5+ Pipe Operator (|>) and Multi-Step Pipelines', function () {
14+
describe('Native Pipe Execution (PHP 8.5+)', function () {
15+
test('executes multi-step piped transformation through first-class callables', function () {
16+
$service = new PipePipelineService();
17+
$runner = new NativePipeRunner();
18+
19+
$result = $runner->runPipeline(10, $service);
20+
21+
expect($result)->toBe('[TAG] RECORD_20');
22+
});
23+
24+
test('throws TypeError at the exact pipe step where parameter contract is violated', function () {
25+
$service = new PipePipelineService();
26+
$runner = new NativePipeRunner();
27+
28+
expect(fn () => $runner->runPipeline(-5, $service))
29+
->toThrow(TypeError::class, 'positive-int');
30+
});
31+
32+
test('executes standalone method pipeline with native pipe operator', function () {
33+
$runner = new NativePipeRunner();
34+
35+
$result = $runner->runStandalonePipeline(5);
36+
37+
expect($result)->toBe('piped_user_15');
38+
});
39+
40+
test('throws TypeError when standalone pipe step receives invalid integer', function () {
41+
$runner = new NativePipeRunner();
42+
43+
expect(fn () => $runner->runStandalonePipeline(-50))
44+
->toThrow(TypeError::class, 'positive-int');
45+
});
46+
});
47+
48+
describe('AST Transformation & Zero Line-Drift with Pipe Syntax', function () {
49+
test('transforms code containing multi-line pipe operator chains with zero line-drift', function () {
50+
$source = <<<'PHP'
51+
<?php
52+
53+
declare(strict_types=1);
54+
55+
/**
56+
* @param positive-int $id
57+
* @return non-empty-string
58+
*/
59+
function formatId(int $id): string
60+
{
61+
return "ID_{$id}";
62+
}
63+
64+
$service = new \TypePHP\Tests\Fixtures\Pipes\PipePipelineService();
65+
66+
$targetLine = true;
67+
PHP;
68+
69+
$transformed = StreamWrapper::transformSource($source, 'test_pipe_drift.php');
70+
71+
$origLines = explode("\n", str_replace("\r\n", "\n", $source));
72+
$transLines = explode("\n", str_replace("\r\n", "\n", $transformed));
73+
74+
expect(count($transLines))->toBe(count($origLines));
75+
76+
$origTarget = array_search('$targetLine = true;', array_map('trim', $origLines), true);
77+
$transTarget = array_search('$targetLine = true;', array_map('trim', $transLines), true);
78+
79+
expect($transTarget)->toBe($origTarget);
80+
});
81+
});
82+
});

0 commit comments

Comments
 (0)