Skip to content

Commit

Permalink
feat: introduce unsealed shaped array syntax
Browse files Browse the repository at this point in the history
This syntax enables an extension of the shaped array type by allowing
additional values that must respect a certain type.

```php
$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper();

// Default syntax can be used like this:
$mapper->map(
    'array{foo: string, ...array<string>}',
    [
        'foo' => 'foo',
        'bar' => 'bar', // ✅ valid additional value
    ]
);

$mapper->map(
    'array{foo: string, ...array<string>}',
    [
        'foo' => 'foo',
        'bar' => 1337, // ❌ invalid value 1337
    ]
);

// Key type can be added as well:
$mapper->map(
    'array{foo: string, ...array<int, string>}',
    [
        'foo' => 'foo',
        42 => 'bar', // ✅ valid additional key
    ]
);

$mapper->map(
    'array{foo: string, ...array<int, string>}',
    [
        'foo' => 'foo',
        'bar' => 'bar' // ❌ invalid key
    ]
);

// Advanced types can be used:
$mapper->map(
    "array{
        'en_US': non-empty-string,
        ...array<non-empty-string, non-empty-string>
    }",
    [
        'en_US' => 'Hello',
        'fr_FR' => 'Salut', // ✅ valid additional value
    ]
);

$mapper->map(
    "array{
        'en_US': non-empty-string,
        ...array<non-empty-string, non-empty-string>
    }",
    [
        'en_US' => 'Hello',
        'fr_FR' => '', // ❌ invalid value
    ]
);

// If the permissive type is enabled, the following will work:
(new \CuyZ\Valinor\MapperBuilder())
    ->allowPermissiveTypes()
    ->mapper()
    ->map(
        'array{foo: string, ...}',
        ['foo' => 'foo', 'bar' => 'bar', 42 => 1337]
    ); // ✅
```
  • Loading branch information
romm committed Mar 28, 2024
1 parent a8fe2ae commit a6a3cf6
Show file tree
Hide file tree
Showing 19 changed files with 560 additions and 56 deletions.
9 changes: 9 additions & 0 deletions docs/pages/usage/type-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ final class SomeClass

/** @var array{string, bar: int} */
private array $shapedArrayWithUndefinedKey,

/** @var array{foo: string, ...} */
private array $unsealedShapedArray,

/** @var array{foo: string, ...array<string>} */
private array $unsealedShapedArrayWithExplicitType,

/** @var array{foo: string, ...array<int, string>} */
private array $unsealedShapedArrayWithExplicitKeyAndType,
) {}
}
```
Expand Down
15 changes: 11 additions & 4 deletions src/Definition/Repository/Cache/Compiler/TypeCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,20 @@ public function compile(Type $type): string
default => "$class::default()",
};
case $type instanceof ShapedArrayType:
$shapes = array_map(
$elements = implode(', ', array_map(
fn (ShapedArrayElement $element) => $this->compileArrayShapeElement($element),
$type->elements()
);
$shapes = implode(', ', $shapes);
));

if ($type->hasUnsealedType()) {
$unsealedType = $this->compile($type->unsealedType());

return "$class::unsealed($unsealedType, $elements)";
} elseif ($type->isUnsealed()) {
return "$class::unsealedWithoutType($elements)";
}

return "new $class(...[$shapes])";
return "new $class($elements)";
case $type instanceof ArrayType:
case $type instanceof NonEmptyArrayType:
if ($type->toString() === 'array' || $type->toString() === 'non-empty-array') {
Expand Down
9 changes: 9 additions & 0 deletions src/Mapper/Tree/Builder/ShapedArrayNodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ private function children(ShapedArrayType $type, Shell $shell, RootNodeBuilder $
unset($value[$key]);
}

if ($type->isUnsealed()) {
$unsealedShell = $shell->withType($type->unsealedType())->withValue($value);
$unsealedChildren = $rootBuilder->build($unsealedShell)->children();

foreach ($unsealedChildren as $unsealedChild) {
$children[$unsealedChild->name()] = $unsealedChild;
}
}

return $children;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ShapedArrayElement;
use RuntimeException;

Expand All @@ -16,10 +17,16 @@ final class ShapedArrayClosingBracketMissing extends RuntimeException implements
/**
* @param ShapedArrayElement[] $elements
*/
public function __construct(array $elements)
public function __construct(array $elements, Type|null|false $unsealedType = null)
{
$signature = 'array{' . implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $elements));

if ($unsealedType === false) {
$signature .= ', ...';
} elseif ($unsealedType instanceof Type) {
$signature .= ', ...' . $unsealedType->toString();
}

parent::__construct(
"Missing closing curly bracket in shaped array signature `$signature`.",
1631283658
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ShapedArrayElement;
use RuntimeException;

use function implode;

/** @internal */
final class ShapedArrayInvalidUnsealedType extends RuntimeException implements InvalidType
{
/**
* @param ShapedArrayElement[] $elements
*/
public function __construct(array $elements, Type $unsealedType)
{
$signature = 'array{';
$signature .= implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $elements));
$signature .= ', ...' . $unsealedType->toString();
$signature .= '}';

parent::__construct(
"Invalid unsealed type `{$unsealedType->toString()}` in shaped array signature `$signature`, it should be a valid array.",
1711618899,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Parser\Lexer\Token\Token;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ShapedArrayElement;
use RuntimeException;

use function implode;

/** @internal */
final class ShapedArrayUnexpectedTokenAfterSealedType extends RuntimeException implements InvalidType
{
/**
* @param array<ShapedArrayElement> $elements
* @param list<Token> $unexpectedTokens
*/
public function __construct(array $elements, Type $unsealedType, array $unexpectedTokens)
{
$unexpected = implode('', array_map(fn (Token $token) => $token->symbol(), $unexpectedTokens));

$signature = 'array{';
$signature .= implode(', ', array_map(fn (ShapedArrayElement $element) => $element->toString(), $elements));
$signature .= ', ...' . $unsealedType->toString();
$signature .= $unexpected;

parent::__construct(
"Unexpected `$unexpected` after sealed type in shaped array signature `$signature`, expected a `}`.",
1711618958,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Exception\Iterable;

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Type;
use RuntimeException;

/** @internal */
final class ShapedArrayWithoutElementsWithSealedType extends RuntimeException implements InvalidType
{
public function __construct(Type $unsealedType)
{
$signature = "array{...{$unsealedType->toString()}}";

parent::__construct(
"Missing elements in shaped array signature `$signature`.",
1711629845,
);
}
}
2 changes: 2 additions & 0 deletions src/Type/Parser/Lexer/NativeLexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningSquareBracketToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\QuoteToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\Token;
use CuyZ\Valinor\Type\Parser\Lexer\Token\TripleDotsToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\UnionToken;

use function filter_var;
Expand Down Expand Up @@ -56,6 +57,7 @@ public function tokenize(string $symbol): Token
':' => ColonToken::get(),
'?' => NullableToken::get(),
',' => CommaToken::get(),
'...' => TripleDotsToken::get(),
'"', "'" => new QuoteToken($symbol),
'int', 'integer' => IntegerToken::get(),
'array' => ArrayToken::array(),
Expand Down
51 changes: 50 additions & 1 deletion src/Type/Parser/Lexer/Token/ArrayToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayCommaMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayElementTypeMissing;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayEmptyElements;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayInvalidUnsealedType;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayUnexpectedTokenAfterSealedType;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayWithoutElementsWithSealedType;
use CuyZ\Valinor\Type\Parser\Lexer\TokenStream;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ArrayKeyType;
Expand Down Expand Up @@ -99,6 +102,8 @@ private function shapedArrayType(TokenStream $stream): ShapedArrayType

$elements = [];
$index = 0;
$isUnsealed = false;
$unsealedType = null;

while (! $stream->done()) {
if ($stream->next() instanceof ClosingCurlyBracketToken) {
Expand All @@ -121,12 +126,50 @@ private function shapedArrayType(TokenStream $stream): ShapedArrayType

$optional = false;

if ($stream->next() instanceof TripleDotsToken) {
$isUnsealed = true;
$stream->forward();
}

if ($stream->done()) {
throw new ShapedArrayClosingBracketMissing($elements, unsealedType: false);
}

if ($stream->next() instanceof UnknownSymbolToken) {
$type = new StringValueType($stream->forward()->symbol());
} elseif ($isUnsealed && $stream->next() instanceof ClosingCurlyBracketToken) {
$stream->forward();
break;
} else {
$type = $stream->read();
}

if ($isUnsealed) {
$unsealedType = $type;

if ($elements === []) {
throw new ShapedArrayWithoutElementsWithSealedType($unsealedType);
}

if (! $unsealedType instanceof ArrayType) {
throw new ShapedArrayInvalidUnsealedType($elements, $unsealedType);
}

if ($stream->done()) {
throw new ShapedArrayClosingBracketMissing($elements, $unsealedType);
} elseif (! $stream->next() instanceof ClosingCurlyBracketToken) {
$unexpected = [];

while (! $stream->done() && ! $stream->next() instanceof ClosingCurlyBracketToken) {
$unexpected[] = $stream->forward();
}

throw new ShapedArrayUnexpectedTokenAfterSealedType($elements, $unsealedType, $unexpected);
}

continue;
}

if ($stream->done()) {
$elements[] = new ShapedArrayElement(new IntegerValueType($index), $type);

Expand Down Expand Up @@ -178,10 +221,16 @@ private function shapedArrayType(TokenStream $stream): ShapedArrayType
}
}

if (empty($elements)) {
if ($elements === []) {
throw new ShapedArrayEmptyElements();
}

if ($unsealedType) {
return ShapedArrayType::unsealed($unsealedType, ...$elements);
} elseif ($isUnsealed) {
return ShapedArrayType::unsealedWithoutType(...$elements);
}

return new ShapedArrayType(...$elements);
}
}
18 changes: 18 additions & 0 deletions src/Type/Parser/Lexer/Token/TripleDotsToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Lexer\Token;

use CuyZ\Valinor\Utility\IsSingleton;

/** @internal */
final class TripleDotsToken implements Token
{
use IsSingleton;

public function symbol(): string
{
return '...';
}
}
2 changes: 1 addition & 1 deletion src/Type/Types/ArrayKeyType.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public static function string(): self
return self::$string ??= new self(NativeStringType::get());
}

public static function from(Type $type): ?self
public static function from(Type $type): self
{
return match (true) {
$type instanceof self => $type,
Expand Down
Loading

0 comments on commit a6a3cf6

Please sign in to comment.