Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce unsealed shaped array syntax #510

Merged
merged 3 commits into from
Apr 2, 2024

Conversation

romm
Copy link
Member

@romm romm commented Mar 28, 2024

This syntax enables an extension of the shaped array type by allowing additional values that must respect a certain type.

$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]
    ); // ✅

Fixes #438

@romm
Copy link
Member Author

romm commented Mar 28, 2024

Hello @Ocramius, if you have some time I'd really appreciate if you could play with this and try to make it crash. 😛

@@ -134,6 +134,15 @@ final class SomeClass

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I definitely need to rework this entire documentation page. 😬

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still, the additions are useful/understandable.

Copy link
Contributor

@Ocramius Ocramius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romm I lack the low level understanding of your library to provide an implementation-level review, but based off your comments on expected behavior, this is very good!

qa/PHPStan/Extension/TreeMapperPHPStanExtension.php Outdated Show resolved Hide resolved

use function implode;

/** @internal */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, the fact that you meticulously add @internal everywhere is :chefkiss:

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be sure I don't forget to add it, I'm covered by this PHPStan rule 😁 : https://github.com/CuyZ/Valinor/blob/master/qa/PHPStan/Extension/ApiAndInternalAnnotationCheck.php

@@ -121,12 +126,50 @@ private function shapedArrayType(TokenStream $stream): ShapedArrayType

$optional = false;

if ($stream->next() instanceof TripleDotsToken) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lexer is going to grow over time: I wonder if there's a way to declare a grammar somewhere 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no clue about that; for now I find the current approach readable and understandable, but indeed that may change in the future.

Comment on lines +1294 to +1301
public function test_unsealed_shaped_array_with_missing_closing_bracket_throws_exception(): void
{
$this->expectException(ShapedArrayClosingBracketMissing::class);
$this->expectExceptionCode(1631283658);
$this->expectExceptionMessage('Missing closing curly bracket in shaped array signature `array{0: int, ...`.');

$this->parser->parse('array{int, ...');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would check '...' (quotes around the triple dots) in a separate test: it's just a detail, but I'm wondering if your lexer works well

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like array{'...': '...', ...} to see what happens :D

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well that does not really need a test because that kind of cases is already covered. But because you're curious about it, here are the results 😁

(new \CuyZ\Valinor\MapperBuilder())
    ->allowPermissiveTypes()
    ->mapper()
    ->map('array{"...": "...", ...array<string>}', [
        '...' => '...',
        'foo' => 'bar'
    ]); // ✅

(new \CuyZ\Valinor\MapperBuilder())
    ->allowPermissiveTypes()
    ->mapper()
    ->map('array{"...": "...", ...array<string>}', [
        'foo' => 'bar'
    ]); // ❌ An error occurred at path ...: Cannot be empty and must be filled with a value matching type "...".

Comment on lines +57 to +59
} catch (MappingError $error) {
$this->mappingFail($error);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've seen this in many mapper integration tests: I think this try-catch flow should be abstracted away in the test suite

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MyTestUtility::useMapper($this->mapper) would be sufficient, for example.

Alternatively:

final class TestMapper implements TreeMapper
{
    private TreeMapper $realMapper;
    // add try-catch logic here
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I already thought about something like this, at some point I'll just do it… I hope 😁

@romm
Copy link
Member Author

romm commented Apr 1, 2024

@Ocramius I was actually just asking for real tests on your end, but thanks for the review it's really much appreciated. If you are done we can merge it. 😉

@Ocramius
Copy link
Contributor

Ocramius commented Apr 1, 2024

Nothing immediate in userland projects: I think it used to crash in a project last year, due to parsing failing

romm added 3 commits April 2, 2024 18:10
This prevents PHPStan from crashing when giving a type it does not
understand to `\CuyZ\Valinor\Mapper\TreeMapper::map()`.
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]
    ); // ✅
```
@romm romm force-pushed the feat/unsealed-array-shape branch from b0ddcf1 to 4b6faa2 Compare April 2, 2024 16:10
@romm
Copy link
Member Author

romm commented Apr 2, 2024

Let's go for it then! 💪

@romm romm enabled auto-merge (rebase) April 2, 2024 16:11
@romm romm merged commit c8174bf into CuyZ:master Apr 2, 2024
11 checks passed
@romm romm deleted the feat/unsealed-array-shape branch April 2, 2024 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Unsealed array shapes not recognized
2 participants