diff --git a/docs/core-concepts/inline-variables.md b/docs/core-concepts/inline-variables.md index d22864a..34fd0fc 100644 --- a/docs/core-concepts/inline-variables.md +++ b/docs/core-concepts/inline-variables.md @@ -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. --- @@ -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) @@ -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 */ + return [10, 20, 30]; // Valid +} + +function fetchBadScores(): array +{ + /** @var list */ + 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**. @@ -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) 'objects' => true, // Class instance checks (@var User $user) ], -``` +``` \ No newline at end of file diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index 267a5f9..fd2e505 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -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. * @@ -226,4 +249,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam return null; } -} +} \ No newline at end of file diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index a98636e..9a1268f 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -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; } @@ -305,4 +309,4 @@ private static function shouldValidateType(TypeNode $node, array $config): bool return (bool) ($config['scalars'] ?? false); } -} +} \ No newline at end of file diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index f80cf8b..1598b44 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -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; @@ -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')) { @@ -214,4 +228,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ return $vars; } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Pipes/NativePipeRunner.php b/tests/Fixtures/Pipes/NativePipeRunner.php new file mode 100644 index 0000000..0bf7f6b --- /dev/null +++ b/tests/Fixtures/Pipes/NativePipeRunner.php @@ -0,0 +1,43 @@ + $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}"; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Pipes/PipePipelineService.php b/tests/Fixtures/Pipes/PipePipelineService.php new file mode 100644 index 0000000..f8f2234 --- /dev/null +++ b/tests/Fixtures/Pipes/PipePipelineService.php @@ -0,0 +1,38 @@ + */ + return [1, 2, 3]; +} + +/** + * Valid inline @var on return statement + */ +function testValidInlineVarOnReturn(): array +{ + /** @var list */ + 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'); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/PipeOperatorTest.php b/tests/TypeChecking/Boundaries/PipeOperatorTest.php new file mode 100644 index 0000000..20bc723 --- /dev/null +++ b/tests/TypeChecking/Boundaries/PipeOperatorTest.php @@ -0,0 +1,82 @@ +) 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' +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); + }); + }); +}); \ No newline at end of file