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
56 changes: 47 additions & 9 deletions src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,13 @@ private static function parseFunction(\ReflectionFunction $ref): array
$type = new ArrayTypeNode($type);
}
$substitutedType = self::substituteAliases($type, $aliases);
$types[$paramName] = SpecialTypeResolver::resolve($substitutedType, $ref);
$resolvedType = SpecialTypeResolver::resolve($substitutedType, $ref);

if ($resolvedType instanceof IdentifierTypeNode && strtolower($resolvedType->name) === 'mixed') {
continue;
}

$types[$paramName] = $resolvedType;
}

$returnTag = DocblockExtractor::getReturnTag($phpDocNode);
Expand Down Expand Up @@ -696,7 +702,13 @@ private static function parseMethodHierarchyDocs(
$type = new ArrayTypeNode($type);
}
$substitutedType = self::substituteAliases($type, $aliases);
$types[$targetParamName] = SpecialTypeResolver::resolve($substitutedType, $hierRef);
$resolvedType = SpecialTypeResolver::resolve($substitutedType, $hierRef);

if ($resolvedType instanceof IdentifierTypeNode && strtolower($resolvedType->name) === 'mixed') {
continue;
}

$types[$targetParamName] = $resolvedType;
}
}

Expand Down Expand Up @@ -790,15 +802,19 @@ private static function applyConstructorPromotionFallback(\ReflectionMethod $ref
) {
$propType = new ArrayTypeNode($propType);
}
$types[$paramName] = self::substituteAliases($propType, []);
$substitutedProp = self::substituteAliases($propType, []);
if ($substitutedProp instanceof IdentifierTypeNode && strtolower($substitutedProp->name) === 'mixed') {
continue;
}
$types[$paramName] = $substitutedProp;
}
}
}
}
}

/**
* Recursively substitutes all type aliases inside a TypeNode AST.
* Recursively substitutes all type aliases inside a TypeNode AST and simplifies unions/intersections containing `mixed`.
*
* @param array<string, TypeNode> $aliases
*/
Expand Down Expand Up @@ -864,17 +880,39 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
}

if ($node instanceof UnionTypeNode) {
return new UnionTypeNode(array_map(
$types = array_map(
fn ($t) => self::substituteAliases($t, $aliases),
$node->types
));
);

foreach ($types as $t) {
if ($t instanceof IdentifierTypeNode && strtolower($t->name) === 'mixed') {
return new IdentifierTypeNode('mixed');
}
}

return new UnionTypeNode($types);
}

if ($node instanceof IntersectionTypeNode) {
return new IntersectionTypeNode(array_map(
$types = array_map(
fn ($t) => self::substituteAliases($t, $aliases),
$node->types
));
);

$filtered = array_values(array_filter($types, function ($t) {
return ! ($t instanceof IdentifierTypeNode && strtolower($t->name) === 'mixed');
}));

if (\count($filtered) === 0) {
return new IdentifierTypeNode('mixed');
}

if (\count($filtered) === 1) {
return $filtered[0];
}

return new IntersectionTypeNode($filtered);
}

if ($node instanceof ArrayShapeNode) {
Expand All @@ -901,4 +939,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo

return $node;
}
}
}
1 change: 1 addition & 0 deletions src/Internal/ContractVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public function enterNode(Node $node): array|int|null
$effectiveVarName = ($varName !== '') ? $varName : 'return';
$checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $effectiveVarName);
$node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->getStartLine());
$node->setAttribute('typephp_var_wrapped', true);
}
}
}
Expand Down
71 changes: 68 additions & 3 deletions src/Internal/Visitor/FunctionContractInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node):
$methodName = $isClassMethod ? strtolower($node->name->toString()) : '';
$isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true);

$hasParam = $isClassMethod || str_contains($docText, '@param') || str_contains($docText, '@phpstan-param') || str_contains($docText, '@psalm-param');
$hasReturn = ! $isMagicLifecycle && ($isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return'));
$hasParam = self::hasParamContracts($docText, $isClassMethod);
$hasReturn = ! $isMagicLifecycle && self::hasReturnContracts($docText, $isClassMethod);

if (! $hasParam && ! $hasReturn) {
return;
Expand All @@ -61,6 +61,67 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node):
$node->stmts = [...$injectedStmts, ...$node->stmts];
}

private static function hasParamContracts(string $docText, bool $isClassMethod): bool
{
if ($isClassMethod) {
return true;
}

if (! str_contains($docText, '@param') && ! str_contains($docText, '@phpstan-param') && ! str_contains($docText, '@psalm-param') && ! str_contains($docText, '@template')) {
return false;
}

if (str_contains($docText, '@template') || str_contains($docText, '@phpstan-param') || str_contains($docText, '@psalm-param')) {
return true;
}

if ((int) preg_match_all('/@param\s+([^\s$]+)/', $docText, $matches) > 0) {
foreach ($matches[1] as $typeStr) {
$unionParts = explode('|', $typeStr);
$hasMixed = false;
foreach ($unionParts as $part) {
if (strtolower(trim($part)) === 'mixed') {
$hasMixed = true;
break;
}
}

if (! $hasMixed) {
return true;
}
}

return false;
}

return false;
}

private static function hasReturnContracts(string $docText, bool $isClassMethod): bool
{
if ($isClassMethod) {
return true;
}

if (str_contains($docText, '@template') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')) {
return true;
}

if (preg_match('/@return\s+([^\s$]+)/', $docText, $matches) === 1) {
$returnTypeStr = $matches[1];
$unionParts = explode('|', $returnTypeStr);
foreach ($unionParts as $part) {
if (strtolower(trim($part)) === 'mixed') {
return false; // Collapses to mixed
}
}

return true;
}

return false;
}

private static function shouldSkipInjection(string $docText): bool
{
$shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true);
Expand Down Expand Up @@ -556,6 +617,10 @@ public function enterNode(Node $n): int|array|null
}

if ($n instanceof Node\Stmt\Return_) {
if ($n->getAttribute('typephp_var_wrapped') === true) {
return null;
}

$exprToWrap = $n->expr ?? new Node\Expr\ConstFetch(new Node\Name('null'));
$checkCall = FunctionContractInjector::buildReturnCheckCall($exprToWrap, $this->thisArg, $this->needsReturnVars);

Expand Down Expand Up @@ -588,4 +653,4 @@ public function enterNode(Node $n): int|array|null

return $newStmts;
}
}
}
20 changes: 20 additions & 0 deletions tests/TypeChecking/Boundaries/InlineReturnValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

declare(strict_types=1);

use TypePHP\Internal\StreamWrapper;

/**
* Function with broad return type, but specific inline @var on return statement
*/
Expand Down Expand Up @@ -64,4 +66,22 @@ function testInlineVarOnReturnInClosure(): array
->toThrow(TypeError::class, 'positive-int')
;
});

test('inline @var on return statement is not double wrapped with checkReturn in AST', function () {
$source = <<<'PHP'
<?php

function sampleSingleWrapReturn(): string
{
/** @var non-empty-string */
return 'hello_world';
}
PHP;

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

expect($transformed)->toContain('RuntimeTypeChecker::checkVariable')
->and($transformed)->not()->toContain('checkReturn(__METHOD__, ($__typephpVal = \TypePHP\Internal\RuntimeTypeChecker::checkVariable')
;
});
});
25 changes: 24 additions & 1 deletion tests/TypeChecking/Boundaries/ParamContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use TypePHP\Contract\ContractParser;
use TypePHP\Tests\Fixtures\Domain\Car;
use TypePHP\Tests\Fixtures\Domain\Dog;
use TypePHP\Tests\Fixtures\Services\VariadicPropertyService;
Expand Down Expand Up @@ -59,6 +60,17 @@ function testProcessIntKeyGenerator(iterable $items): array
return $out;
}

/**
* Function with purely mixed parameters
*
* @param mixed $data
* @param mixed $meta
*/
function testPureMixedParamFunction(mixed $data, mixed $meta): bool
{
return true;
}

describe('Function & Method Parameter Contracts', function () {
test('inherits variadic constructor parameter contracts from property @var array docblocks without double-wrapping', function () {
$service = new VariadicPropertyService(['tag1', 'tag2'], new Dog(), new Dog());
Expand Down Expand Up @@ -100,6 +112,16 @@ function testProcessIntKeyGenerator(iterable $items): array
->toThrow(TypeError::class, 'Argument $strings[3] must be of type string')
;
});

test('filters out pure mixed parameters so hasParamContract is false', function () {
$contract = ContractParser::parse('testPureMixedParamFunction');

expect($contract['types'])->toBeEmpty()
->and($contract['hasParamContract'])->toBeFalse()
;

expect(testPureMixedParamFunction('anything', 12345))->toBeTrue();
});
});

describe('Lazy Wrapped Callable Parameter Contracts', function () {
Expand Down Expand Up @@ -155,6 +177,7 @@ function testProcessIntKeyGenerator(iterable $items): array
};

expect(fn () => testProcessIntKeyGenerator($badKeyGenerator()))
->toThrow(TypeError::class, 'Iterator $items key');
->toThrow(TypeError::class, 'Iterator $items key')
;
});
});
15 changes: 13 additions & 2 deletions typephp.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
*/
'respect_ignore_tags' => true,

/*
/*
|--------------------------------------------------------------------------
| Enable Caching & Cache Directory
|--------------------------------------------------------------------------
Expand All @@ -67,7 +67,7 @@
// \Acme\Domain\TypePHPExtension::class,
],

/*
/*
|--------------------------------------------------------------------------
| Array Validation Strategy
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -106,6 +106,17 @@
'objects' => true,
],

/*
|--------------------------------------------------------------------------
| Stub Files (DocBlock Overrides for Third-Party & Vendor Packages)
|--------------------------------------------------------------------------
| Path globs or specific file paths containing stub files (.stub, .stub.php, .php)
| that override inaccurate or missing DocBlocks in third-party vendor packages.
*/
'stubs' => [
// 'stubs/**',
],

/*
|--------------------------------------------------------------------------
| Included Paths & Whitelisting
Expand Down
Loading