Skip to content

Commit

Permalink
[Regex] Introduce matching() function
Browse files Browse the repository at this point in the history
  • Loading branch information
veewee committed Mar 8, 2021
1 parent 3621bf2 commit ce89775
Show file tree
Hide file tree
Showing 8 changed files with 282 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Psl\Integration\Psalm\EventHandler;

use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent;
use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface;
use Psalm\Type;

final class RegexCaptureGroupsFunctionReturnTypeProvider implements FunctionReturnTypeProviderInterface
{
/**
* @return array<lowercase-string>
*/
public static function getFunctionIds(): array
{
return [
'psl\regex\capture_groups'
];
}

public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Type\Union
{
$statements_source = $event->getStatementsSource();
$call_args = $event->getCallArgs();

$argument = $call_args[0] ?? null;
if (null === $argument) {
return self::fallbackType();
}

$type = null;
$argument_value = $argument->value;
$type = $statements_source->getNodeTypeProvider()->getType($argument_value);
if (null === $type) {
return self::fallbackType();
}

$atomic = $type->getAtomicTypes();
$capture_groups = $atomic['array'] ?? null;
if (!$capture_groups instanceof Type\Atomic\TKeyedArray) {
return self::fallbackType();
}

$string = static fn (): Type\Union => new Type\Union([new Type\Atomic\TString()]);
$properties = [
0 => $string()
];
foreach ($capture_groups->properties as $index => $value) {
$type = array_values($value->getAtomicTypes())[0] ?? null;
if (!$type instanceof Type\Atomic\TLiteralInt && !$type instanceof Type\Atomic\TLiteralString) {
return self::fallbackType();
}

$name = $type->value;

$properties[$name] = $string();
}

return new Type\Union([new Type\Atomic\TGenericObject('Psl\Type\TypeInterface', [
new Type\Union([
new Type\Atomic\TKeyedArray($properties)
])
])]);
}

private static function fallbackType(): Type\Union
{
return new Type\Union([new Type\Atomic\TGenericObject('Psl\Type\TypeInterface', [
new Type\Union([
new Type\Atomic\TArray([
new Type\Union([new Type\Atomic\TArrayKey()]),
new Type\Union([new Type\Atomic\TString()])
])
])
])]);
}
}
2 changes: 2 additions & 0 deletions integration/Psalm/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ final class Plugin implements PluginEntryPointInterface
{
public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement $config = null): void
{
require_once __DIR__ . '/EventHandler/RegexCaptureGroupsFunctionReturnTypeProvider.php';
require_once __DIR__ . '/EventHandler/ShapeFunctionReturnTypeProvider.php';

$registration->registerHooksFromClass(EventHandler\RegexCaptureGroupsFunctionReturnTypeProvider::class);
$registration->registerHooksFromClass(EventHandler\ShapeFunctionReturnTypeProvider::class);
}
}
2 changes: 2 additions & 0 deletions src/Psl/Internal/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ final class Loader
'Psl\Math\tan',
'Psl\Math\to_base',
'Psl\Result\wrap',
'Psl\Regex\capture_groups',
'Psl\Regex\first_match',
'Psl\Regex\split',
'Psl\Regex\matches',
'Psl\Regex\replace',
Expand Down
19 changes: 5 additions & 14 deletions src/Psl/Json/typed.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,24 @@

namespace Psl\Json;

use Psl\Type\Exception\AssertException;
use Psl\Type\Exception\CoercionException;
use Psl\Type\TypeInterface;
use Psl\Type;

/**
* Decode a json encoded string into a dynamic variable.
*
* @psalm-template T
*
* @psalm-param TypeInterface<T> $type
* @psalm-param Type\TypeInterface<T> $type
*
* @psalm-return T
*
* @throws Exception\DecodeException If an error occurred.
*/
function typed(string $json, TypeInterface $type)
function typed(string $json, Type\TypeInterface $type)
{
$value = decode($json);

try {
return $type->assert($value);
} catch (AssertException $e) {
}

try {
return $type->coerce($value);
} catch (CoercionException $e) {
return $type->coerce(decode($json));
} catch (Type\Exception\CoercionException $e) {
throw new Exception\DecodeException($e->getMessage(), (int)$e->getCode(), $e);
}
}
28 changes: 28 additions & 0 deletions src/Psl/Regex/capture_groups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Psl\Regex;

use Psl\Dict;
use Psl\Type;

/**
* @param list<array-key> $groups
*
* @return Type\TypeInterface<array<array-key, string>>
*
* @psalm-suppress MixedReturnTypeCoercion - Psalm loses track of the keys. No worries, another psalm plugin fixes this!
*/
function capture_groups(array $groups): Type\TypeInterface
{
return Type\shape(
Dict\from_keys(
Dict\unique([0, ...$groups]),
/**
* @return Type\TypeInterface<string>
*/
static fn(): Type\TypeInterface => Type\string()
)
);
}
45 changes: 45 additions & 0 deletions src/Psl/Regex/first_match.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Psl\Regex;

use Psl\Type;

use function preg_match;

/**
* Determine if $subject matches the given $pattern and return the matches.
*
* @template T of array
*
* @param non-empty-string $pattern The pattern to match against.
* @param Type\TypeInterface<T> $capture_groups What shape does the matching items have?
*
* @return T|null
*
* @throws Exception\RuntimeException If an internal error accord.
* @throws Exception\InvalidPatternException If $pattern is invalid.
*/
function first_match(string $subject, string $pattern, Type\TypeInterface $capture_groups, int $offset = 0): ?array
{
$matching = Internal\call_preg(
'preg_match',
static function () use ($subject, $pattern, $offset): ?array {
$matching = [];
$matches = preg_match($pattern, $subject, $matching, 0, $offset);

return $matches === 0 ? null : $matching;
}
);

if ($matching === null) {
return null;
}

try {
return $capture_groups->coerce($matching);
} catch (Type\Exception\CoercionException $e) {
throw new Exception\RuntimeException('Invalid capture groups', 0, $e);
}
}
21 changes: 21 additions & 0 deletions tests/Psl/Regex/CaptureGroupsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Regex;

use PHPUnit\Framework\TestCase;

use function Psl\Regex\capture_groups;

final class CaptureGroupsTest extends TestCase
{
public function testItAlwaysAddsZeroCaptureResult(): void
{
$data = [0 => 'Hello', 1 => 'World'];
$shape = capture_groups([1]);
$actual = $shape->coerce($data);

static::assertSame($actual, $data);
}
}
100 changes: 100 additions & 0 deletions tests/Psl/Regex/FirstMatchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Regex;

use PHPUnit\Framework\TestCase;
use Psl\Regex;
use Psl\Type\TypeInterface;

use function Psl\Regex\capture_groups;

final class FirstMatchTest extends TestCase
{
/**
* @dataProvider provideMatchingData
*/
public function testMatching(
array $expected,
string $subject,
string $pattern,
TypeInterface $shape,
int $offset = 0
): void {
static::assertSame($expected, Regex\first_match($subject, $pattern, $shape, $offset));
}

/**
* @dataProvider provideNonMatchingData
*/
public function testNotMatching(string $subject, string $pattern, int $offset = 0)
{
static::assertNull(Regex\first_match($subject, $pattern, capture_groups([]), $offset));
}

public function testMatchingWithInvalidPattern(): void
{
$this->expectException(Regex\Exception\InvalidPatternException::class);
$this->expectExceptionMessage("No ending delimiter '/' found");

Regex\first_match('hello', '/hello', capture_groups([]));
}

public function provideMatchingData(): iterable
{
yield [
[
0 => 'PHP',
1 => 'PHP',
],
'PHP is the web scripting language of choice.',
'/(php)/i',
capture_groups([0, 1])
];
yield [
[
0 => 'Hello world',
1 => 'Hello',
],
'Hello world is the web scripting language of choice.',
'/(hello) world/i',
capture_groups([0, 1])
];
yield [
[
0 => 'web',
1 => 'web',
],
'PHP is the web scripting language of choice.',
'/(\bweb\b)/i',
capture_groups([0, 1])
];
yield [
[
0 => 'PHP',
'language' => 'PHP',
],
'PHP is the web scripting language of choice.',
'/(?P<language>PHP)/',
capture_groups([0, 'language'])
];
yield [
[
0 => 'http://www.php.net',
1 => 'www.php.net'
],
'http://www.php.net/index.html',
'@^(?:http://)?([^/]+)@i',
capture_groups([1])
];
}

public function provideNonMatchingData(): iterable
{
yield ['PHP is the web scripting language of choice.', '/php/'];
yield ['PHP is the website scripting language of choice.', '/\bweb\b/i'];
yield ['php is the web scripting language of choice.', '/PHP/'];
yield ['hello', '/[^.]+\.[^.]+$/'];
}
}

0 comments on commit ce89775

Please sign in to comment.