Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions docs/core-concepts/inline-variables.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Inline Variables (`@var`)

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.
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.

---

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

/** @var non-empty-string $name -> single-line docblock */
$name = 'Reymart'
$name = 'Reymart';
```

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

---

## Inline `@var` on Direct Return Statements

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:

```php
function fetchUserScores(): array
{
/** @var list<positive-int> */
return [10, 20, 30]; // Valid
}

function fetchBadScores(): array
{
/** @var list<positive-int> */
return [10, -5, 30]; // Throws TypeError on -5!
}

$getUser = function () use ($repo) {
/** @var array{id: positive-int, username: non-empty-string} */
return $repo->fetchRawUser();
};
```

> **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.

---

## Block-Level Scope Isolation & Shadowing

TypePHP tracks variable type contracts using **Lexical Block Scope Frames**.
Expand Down Expand Up @@ -146,4 +173,4 @@ You can enable or disable specific categories of inline variable validation in `
'arrays' => true, // Array shapes and lists (array{id: int}, list<T>)
'objects' => true, // Class instance checks (@var User $user)
],
```
```
25 changes: 24 additions & 1 deletion src/Contract/DocblockExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,29 @@ public static function extractTypeFromPropertyDoc(string $doc, string $propName)
return null;
}

/**
* Extracts the first @var tag's type string and variable name from a docblock string.
*
* @return array{0: string, 1: string}|null
*/
public static function extractVarTagFromDoc(string $doc): ?array
{
try {
$phpDocNode = self::parseDocString($doc);
$varTags = $phpDocNode->getVarTagValues();
if (\count($varTags) > 0) {
$typeString = (string) $varTags[0]->type;
$varName = ltrim($varTags[0]->variableName, '$');

return [$typeString, $varName];
}
} catch (\Throwable $e) {
// Silently ignore malformed docblocks
}

return null;
}

/**
* Extracts local and imported type aliases (@phpstan-type and @phpstan-import-type) from a PHPDoc node.
*
Expand Down Expand Up @@ -226,4 +249,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam

return null;
}
}
}
12 changes: 8 additions & 4 deletions src/Internal/Checker/InlineChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,22 @@ public static function checkVariable(mixed $value, string $typeString, string $v
return $value;
}

$context = ($varName === 'return') ? 'Return value' : "Variable \$$varName";

if ($typeNode instanceof CallableTypeNode || ($typeNode instanceof IdentifierTypeNode && strtolower($typeNode->name) === 'callable')) {
return CallableWrapper::wrapTypeNode($typeNode, $value, "Variable \$$varName: Callback", $registry);
$cbPrefix = ($varName === 'return') ? 'Return value: Callback' : "Variable \$$varName: Callback";

return CallableWrapper::wrapTypeNode($typeNode, $value, $cbPrefix, $registry);
}

if ($typeNode instanceof GenericTypeNode && $checkGenerics && \is_object($value)) {
$err = TemplateManager::bindInstanceFromNode($value, $typeNode, "Variable \$$varName", true);
$err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context, true);
if ($err !== null) {
return $err;
}
}

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

return (bool) ($config['scalars'] ?? false);
}
}
}
16 changes: 15 additions & 1 deletion src/Internal/ContractVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use TypePHP\Contract\DocblockExtractor;
use TypePHP\Internal\Visitor\FunctionContractInjector;
use TypePHP\Internal\Visitor\NodeBuilder;
use TypePHP\Internal\Visitor\PropertyHookInjector;
Expand Down Expand Up @@ -68,6 +69,19 @@ public function enterNode(Node $node): array|int|null
return null;
}

if ($node instanceof Node\Stmt\Return_ && $node->expr !== null) {
$doc = $node->getDocComment();
if ($doc !== null && str_contains($doc->getText(), '@var')) {
$extracted = DocblockExtractor::extractVarTagFromDoc($doc->getText());
if ($extracted !== null) {
[$typeString, $varName] = $extracted;
$effectiveVarName = ($varName !== '') ? $varName : 'return';
$checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $effectiveVarName);
$node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->getStartLine());
}
}
}

if ($node instanceof Node\Stmt\Expression) {
$doc = $node->getDocComment();
if ($doc !== null && str_contains($doc->getText(), '@var')) {
Expand Down Expand Up @@ -214,4 +228,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_

return $vars;
}
}
}
43 changes: 43 additions & 0 deletions tests/Fixtures/Pipes/NativePipeRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Pipes;

class NativePipeRunner
{
public function runPipeline(int $initialId, PipePipelineService $service): string
{
return $initialId
|> $service->doubleId(...)
|> $service->stringify(...)
|> $service->prefixTag(...);
}

public function runStandalonePipeline(int $id): string
{
return $id
|> $this->stepOne(...)
|> $this->stepTwo(...);
}

/**
* @param positive-int $id
*
* @return positive-int
*/
public function stepOne(int $id): int
{
return $id + 10;
}

/**
* @param positive-int $id
*
* @return non-empty-string
*/
public function stepTwo(int $id): string
{
return "piped_user_{$id}";
}
}
38 changes: 38 additions & 0 deletions tests/Fixtures/Pipes/PipePipelineService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\Fixtures\Pipes;

class PipePipelineService
{
/**
* @param positive-int $id
*
* @return positive-int
*/
public function doubleId(int $id): int
{
return $id * 2;
}

/**
* @param positive-int $id
*
* @return non-empty-string
*/
public function stringify(int $id): string
{
return "RECORD_{$id}";
}

/**
* @param non-empty-string $tag
*
* @return non-empty-string
*/
public function prefixTag(string $tag): string
{
return "[TAG] {$tag}";
}
}
64 changes: 64 additions & 0 deletions tests/TypeChecking/Boundaries/InlineReturnValidationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

/**
* Function with broad return type, but specific inline @var on return statement
*/
function testInlineVarOnReturnArray(): array
{
/** @var list<string> */
return [1, 2, 3];
}

/**
* Valid inline @var on return statement
*/
function testValidInlineVarOnReturn(): array
{
/** @var list<string> */
return ['apple', 'banana', 'cherry'];
}

/**
* Named @var tag on return statement
*/
function testNamedInlineVarOnReturn(int $id): int
{
/** @var positive-int $id */
return $id;
}

/**
* Inline @var array shape on return inside a closure
*/
function testInlineVarOnReturnInClosure(): array
{
$closure = function (): array {
/** @var array{id: positive-int, name: non-empty-string} */
return ['id' => -10, 'name' => 'Alice'];
};

return $closure();
}

describe('Inline @var Validation on Direct Return Statements', function () {
test('validates and accepts valid return expression with inline @var', function () {
expect(testValidInlineVarOnReturn())->toBe(['apple', 'banana', 'cherry']);
});

test('throws TypeError when direct return expression violates unnamed inline @var contract', function () {
expect(fn () => testInlineVarOnReturnArray())
->toThrow(TypeError::class, 'must be of type string');
});

test('throws TypeError when direct return expression violates named inline @var contract', function () {
expect(fn () => testNamedInlineVarOnReturn(-5))
->toThrow(TypeError::class, 'positive-int');
});

test('throws TypeError when closure return expression violates inline @var array shape', function () {
expect(fn () => testInlineVarOnReturnInClosure())
->toThrow(TypeError::class, 'positive-int');
});
});
82 changes: 82 additions & 0 deletions tests/TypeChecking/Boundaries/PipeOperatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

if (PHP_VERSION_ID < 80500) {
return;
}

use TypePHP\Internal\StreamWrapper;
use TypePHP\Tests\Fixtures\Pipes\NativePipeRunner;
use TypePHP\Tests\Fixtures\Pipes\PipePipelineService;

describe('PHP 8.5+ Pipe Operator (|>) and Multi-Step Pipelines', function () {
describe('Native Pipe Execution (PHP 8.5+)', function () {
test('executes multi-step piped transformation through first-class callables', function () {
$service = new PipePipelineService();
$runner = new NativePipeRunner();

$result = $runner->runPipeline(10, $service);

expect($result)->toBe('[TAG] RECORD_20');
});

test('throws TypeError at the exact pipe step where parameter contract is violated', function () {
$service = new PipePipelineService();
$runner = new NativePipeRunner();

expect(fn () => $runner->runPipeline(-5, $service))
->toThrow(TypeError::class, 'positive-int');
});

test('executes standalone method pipeline with native pipe operator', function () {
$runner = new NativePipeRunner();

$result = $runner->runStandalonePipeline(5);

expect($result)->toBe('piped_user_15');
});

test('throws TypeError when standalone pipe step receives invalid integer', function () {
$runner = new NativePipeRunner();

expect(fn () => $runner->runStandalonePipeline(-50))
->toThrow(TypeError::class, 'positive-int');
});
});

describe('AST Transformation & Zero Line-Drift with Pipe Syntax', function () {
test('transforms code containing multi-line pipe operator chains with zero line-drift', function () {
$source = <<<'PHP'
<?php

declare(strict_types=1);

/**
* @param positive-int $id
* @return non-empty-string
*/
function formatId(int $id): string
{
return "ID_{$id}";
}

$service = new \TypePHP\Tests\Fixtures\Pipes\PipePipelineService();

$targetLine = true;
PHP;

$transformed = StreamWrapper::transformSource($source, 'test_pipe_drift.php');

$origLines = explode("\n", str_replace("\r\n", "\n", $source));
$transLines = explode("\n", str_replace("\r\n", "\n", $transformed));

expect(count($transLines))->toBe(count($origLines));

$origTarget = array_search('$targetLine = true;', array_map('trim', $origLines), true);
$transTarget = array_search('$targetLine = true;', array_map('trim', $transLines), true);

expect($transTarget)->toBe($origTarget);
});
});
});
Loading