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
38 changes: 24 additions & 14 deletions src/Validator/ArrayShapeValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@

namespace TypePHP\Validator;

use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode;
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use TypePHP\Internal\ErrorFactory;
use TypePHP\Internal\ErrorMessage;
use TypePHP\Internal\TypeFormatter;

/**
* @internal Class for validating array shapes like array<1:string,2:int>.
* @internal Class for validating array shapes and tuple shapes like array{0: string, 1: int} or array{string, int}.
*/
final class ArrayShapeValidator implements TypeValidatorInterface
{
Expand All @@ -24,30 +26,38 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali

/** @var ArrayShapeNode $node */
$knownKeys = [];
$nextAutoIndex = 0;

foreach ($node->items as $item) {
$key = null;

if ($item->keyName instanceof ConstExprStringNode) {
$key = $item->keyName->value;
} elseif ($item->keyName instanceof ConstExprIntegerNode) {
$key = (int) $item->keyName->value;
$nextAutoIndex = max($nextAutoIndex, $key + 1);
} elseif ($item->keyName instanceof IdentifierTypeNode) {
$key = $item->keyName->name;
} elseif ($item->keyName !== null) {
$key = (string) $item->keyName;
} else {
$key = $nextAutoIndex;
$nextAutoIndex++;
}

if ($key !== null) {
$knownKeys[$key] = true;

if (! \array_key_exists($key, $value)) {
if (! $item->optional) {
return ErrorFactory::createError($context . " is missing required key '$key'");
}
$knownKeys[$key] = true;

continue;
if (! \array_key_exists($key, $value)) {
if (! $item->optional) {
return ErrorFactory::createError($context . " is missing required key '$key'");
}

$err = $registry->validate($value[$key], $item->valueType, $context . "['" . $key . "']");
if ($err !== null) {
return $err;
}
continue;
}

$err = $registry->validate($value[$key], $item->valueType, $context . "['" . $key . "']");
if ($err !== null) {
return $err;
}
}

Expand Down Expand Up @@ -81,4 +91,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali

return null;
}
}
}
3 changes: 2 additions & 1 deletion tests/Fixtures/Types/GlobalTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

/**
* @phpstan-type SharedShape array{id: positive-int, name: non-empty-string}
* @phpstan-type SharedTupleShape array{list<positive-int>, non-empty-string}
*/
class GlobalTypes
{
}
}
83 changes: 83 additions & 0 deletions tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,54 @@ function testComplexNestedShapeParam(array $data): bool
return true;
}

/**
* Test function for Issue #20: Implicit keyless tuple array shapes
*
* @param array{list<positive-int>, list<non-empty-string>} $tuple
*/
function testKeylessImplicitTupleShape(array $tuple): bool
{
return true;
}

/**
* Helpers for Issue #20 Edge Cases
*
* @phpstan-type LocalTupleAlias array{list<positive-int>, list<non-empty-string>}
* @phpstan-type MixedTupleShape array{non-empty-string, code: positive-int, list<int>}
* @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple
*
* @param LocalTupleAlias $payload
* @param MixedTupleShape $mixedPayload
*/
function testLocalTupleAliasParam(array $payload, array $mixedPayload): bool
{
return true;
}

/**
* @phpstan-import-type SharedTupleShape from \TypePHP\Tests\Fixtures\Types\GlobalTypes as ImportedTuple
*
* @param ImportedTuple $tuple
*/
function testImportedTupleAliasParam(array $tuple): bool
{
return true;
}

/**
* @return array{list<positive-int>, non-empty-string}
*/
function testReturnKeylessTuple(bool $valid): array
{
if (! $valid) {
return [[10, -5], 'bundle'];
}

return [[10, 20], 'bundle'];
}


describe('Class Object Arrays (Dog[])', function () {
test('accepts array of matching class instances', function () {
expect(testDogArrayParam([new Dog(), new Dog()]))->toBe(2);
Expand Down Expand Up @@ -297,3 +345,38 @@ function testComplexNestedShapeParam(array $data): bool
;
});
});

describe('Issue #20 Edge Cases: Keyless Tuples in Type Aliases, Returns, and Mixed Keys', function () {
test('resolves keyless tuple shapes defined inside local @phpstan-type aliases', function () {
expect(testLocalTupleAliasParam(
[[10, 20], ['a', 'b']],
['status_ok', 'code' => 200, [1, 2, 3]]
))->toBeTrue();

expect(fn () => testLocalTupleAliasParam(
[[10, -5], ['a', 'b']],
['status_ok', 'code' => 200, [1, 2, 3]]
))->toThrow(TypeError::class, "Argument \$payload['0'][1] must be of type positive-int");

expect(fn () => testLocalTupleAliasParam(
[[10, 20], ['a', 'b']],
['status_ok', 'code' => -100, [1, 2, 3]]
))->toThrow(TypeError::class, "Argument \$mixedPayload['code'] must be of type positive-int");
});

test('resolves keyless tuple shapes imported via @phpstan-import-type', function () {
expect(testImportedTupleAliasParam([[100, 200], 'valid_string']))->toBeTrue();

expect(fn () => testImportedTupleAliasParam([[100, 200], '']))
->toThrow(TypeError::class, "Argument \$tuple['1'] must be of type non-empty-string")
;
});

test('validates keyless tuple shapes returned from functions', function () {
expect(testReturnKeylessTuple(true))->toBe([[10, 20], 'bundle']);

expect(fn () => testReturnKeylessTuple(false))
->toThrow(TypeError::class, "Return value['0'][1] must be of type positive-int")
;
});
});
Loading