Skip to content

Commit

Permalink
Add matchStrictGroups and other strict group operations to avoid null…
Browse files Browse the repository at this point in the history
…able matches (#14)
  • Loading branch information
Seldaek authored Nov 16, 2022
1 parent 4482b64 commit 963cd62
Show file tree
Hide file tree
Showing 14 changed files with 334 additions and 21 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ if (Preg::isMatch('{fo+}', $string, $matches)) // bool
if (Preg::isMatchAll('{fo+}', $string, $matches)) // bool
```

Finally the `Preg` class provides a few `*StrictGroups` method variants that ensure match groups
are always present and thus non-nullable, making it easier to write type-safe code:

```php
use Composer\Pcre\Preg;

// $matches is guaranteed to be an array of strings, if a subpattern does not match and produces a null it will throw
if (Preg::matchStrictGroups('{fo+}', $string, $matches))
if (Preg::matchAllStrictGroups('{fo+}', $string, $matches))
```

If you would prefer a slightly more verbose usage, replacing by-ref arguments by result objects, you can use the `Regex` class:

```php
Expand Down
4 changes: 2 additions & 2 deletions src/MatchAllResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ final class MatchAllResult

/**
* @param 0|positive-int $count
* @param array<array<string|null>> $matches
* @param array<int|string, array<string|null>> $matches
*/
public function __construct($count, array $matches)
public function __construct(int $count, array $matches)
{
$this->matches = $matches;
$this->matched = (bool) $count;
Expand Down
46 changes: 46 additions & 0 deletions src/MatchAllStrictGroupsResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of composer/pcre.
*
* (c) Composer <https://github.com/composer>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Composer\Pcre;

final class MatchAllStrictGroupsResult
{
/**
* An array of match group => list of matched strings
*
* @readonly
* @var array<int|string, list<string>>
*/
public $matches;

/**
* @readonly
* @var 0|positive-int
*/
public $count;

/**
* @readonly
* @var bool
*/
public $matched;

/**
* @param 0|positive-int $count
* @param array<array<string>> $matches
*/
public function __construct(int $count, array $matches)
{
$this->matches = $matches;
$this->matched = (bool) $count;
$this->count = $count;
}
}
2 changes: 1 addition & 1 deletion src/MatchAllWithOffsetsResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class MatchAllWithOffsetsResult
* @param array<int|string, list<array{string|null, int}>> $matches
* @phpstan-param array<int|string, list<array{string|null, int<-1, max>}>> $matches
*/
public function __construct($count, array $matches)
public function __construct(int $count, array $matches)
{
$this->matches = $matches;
$this->matched = (bool) $count;
Expand Down
2 changes: 1 addition & 1 deletion src/MatchResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ final class MatchResult
* @param 0|positive-int $count
* @param array<string|null> $matches
*/
public function __construct($count, array $matches)
public function __construct(int $count, array $matches)
{
$this->matches = $matches;
$this->matched = (bool) $count;
Expand Down
39 changes: 39 additions & 0 deletions src/MatchStrictGroupsResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/*
* This file is part of composer/pcre.
*
* (c) Composer <https://github.com/composer>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Composer\Pcre;

final class MatchStrictGroupsResult
{
/**
* An array of match group => string matched
*
* @readonly
* @var array<int|string, string>
*/
public $matches;

/**
* @readonly
* @var bool
*/
public $matched;

/**
* @param 0|positive-int $count
* @param array<string> $matches
*/
public function __construct(int $count, array $matches)
{
$this->matches = $matches;
$this->matched = (bool) $count;
}
}
2 changes: 1 addition & 1 deletion src/MatchWithOffsetsResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ final class MatchWithOffsetsResult
* @param array<array{string|null, int}> $matches
* @phpstan-param array<int|string, array{string|null, int<-1, max>}> $matches
*/
public function __construct($count, array $matches)
public function __construct(int $count, array $matches)
{
$this->matches = $matches;
$this->matched = (bool) $count;
Expand Down
127 changes: 122 additions & 5 deletions src/Preg.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ public static function match(string $pattern, string $subject, ?array &$matches
return $result;
}

/**
* Variant of `match()` which outputs non-null matches (or throws)
*
* @param non-empty-string $pattern
* @param array<string> $matches Set by method
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
* @return 0|1
* @throws UnexpectedNullMatchException
*
* @param-out array<int|string, string> $matches
*/
public static function matchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
{
$result = self::match($pattern, $subject, $matchesInternal, $flags, $offset);
$matches = self::enforceNonNullMatches($pattern, $matchesInternal, 'match');

return $result;
}

/**
* Runs preg_match with PREG_OFFSET_CAPTURE
*
Expand All @@ -61,18 +80,15 @@ public static function matchWithOffsets(string $pattern, string $subject, ?array
/**
* @param non-empty-string $pattern
* @param array<int|string, list<string|null>> $matches Set by method
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_SET_ORDER> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
* @return 0|positive-int
*
* @param-out array<int|string, list<string|null>> $matches
*/
public static function matchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
{
self::checkOffsetCapture($flags, 'matchAllWithOffsets');

if (($flags & PREG_SET_ORDER) !== 0) {
throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the type of $matches');
}
self::checkSetOrder($flags);

$result = preg_match_all($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset);
if (!is_int($result)) { // PHP < 8 may return null, 8+ returns int|false
Expand All @@ -82,6 +98,25 @@ public static function matchAll(string $pattern, string $subject, ?array &$match
return $result;
}

/**
* Variant of `match()` which outputs non-null matches (or throws)
*
* @param non-empty-string $pattern
* @param array<int|string, list<string|null>> $matches Set by method
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
* @return 0|positive-int
* @throws UnexpectedNullMatchException
*
* @param-out array<int|string, list<string>> $matches
*/
public static function matchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
{
$result = self::matchAll($pattern, $subject, $matchesInternal, $flags, $offset);
$matches = self::enforceNonNullMatchAll($pattern, $matchesInternal, 'matchAll');

return $result;
}

/**
* Runs preg_match_all with PREG_OFFSET_CAPTURE
*
Expand All @@ -94,6 +129,8 @@ public static function matchAll(string $pattern, string $subject, ?array &$match
*/
public static function matchAllWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): int
{
self::checkSetOrder($flags);

$result = preg_match_all($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, $offset);
if (!is_int($result)) { // PHP < 8 may return null, 8+ returns int|false
throw PcreException::fromFunction('preg_match_all', $pattern);
Expand Down Expand Up @@ -233,6 +270,8 @@ public static function grep(string $pattern, array $array, int $flags = 0): arra
}

/**
* Variant of match() which returns a bool instead of int
*
* @param non-empty-string $pattern
* @param array<string|null> $matches Set by method
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
Expand All @@ -245,6 +284,23 @@ public static function isMatch(string $pattern, string $subject, ?array &$matche
}

/**
* Variant of `isMatch()` which outputs non-null matches (or throws)
*
* @param non-empty-string $pattern
* @param array<string> $matches Set by method
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
* @throws UnexpectedNullMatchException
*
* @param-out array<int|string, string> $matches
*/
public static function isMatchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
{
return (bool) self::matchStrictGroups($pattern, $subject, $matches, $flags, $offset);
}

/**
* Variant of matchAll() which returns a bool instead of int
*
* @param non-empty-string $pattern
* @param array<int|string, list<string|null>> $matches Set by method
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
Expand All @@ -257,6 +313,22 @@ public static function isMatchAll(string $pattern, string $subject, ?array &$mat
}

/**
* Variant of `isMatchAll()` which outputs non-null matches (or throws)
*
* @param non-empty-string $pattern
* @param array<int|string, list<string>> $matches Set by method
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
*
* @param-out array<int|string, list<string>> $matches
*/
public static function isMatchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
{
return (bool) self::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset);
}

/**
* Variant of matchWithOffsets() which returns a bool instead of int
*
* Runs preg_match with PREG_OFFSET_CAPTURE
*
* @param non-empty-string $pattern
Expand All @@ -271,6 +343,8 @@ public static function isMatchWithOffsets(string $pattern, string $subject, ?arr
}

/**
* Variant of matchAllWithOffsets() which returns a bool instead of int
*
* Runs preg_match_all with PREG_OFFSET_CAPTURE
*
* @param non-empty-string $pattern
Expand All @@ -290,4 +364,47 @@ private static function checkOffsetCapture(int $flags, string $useFunctionName):
throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the type of $matches, use ' . $useFunctionName . '() instead');
}
}

private static function checkSetOrder(int $flags): void
{
if (($flags & PREG_SET_ORDER) !== 0) {
throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the type of $matches');
}
}

/**
* @param array<int|string, string|null> $matches
* @return array<int|string, string>
* @throws UnexpectedNullMatchException
*/
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod)
{
foreach ($matches as $group => $match) {
if (null === $match) {
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
}
}

/** @var array<string> */
return $matches;
}

/**
* @param array<int|string, list<string|null>> $matches
* @return array<int|string, list<string>>
* @throws UnexpectedNullMatchException
*/
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod)
{
foreach ($matches as $group => $groupMatches) {
foreach ($groupMatches as $match) {
if (null === $match) {
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
}
}
}

/** @var array<int|string, list<string>> */
return $matches;
}
}
Loading

0 comments on commit 963cd62

Please sign in to comment.