Skip to content

Commit 963cd62

Browse files
authored
Add matchStrictGroups and other strict group operations to avoid nullable matches (#14)
1 parent 4482b64 commit 963cd62

14 files changed

+334
-21
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ if (Preg::isMatch('{fo+}', $string, $matches)) // bool
8484
if (Preg::isMatchAll('{fo+}', $string, $matches)) // bool
8585
```
8686

87+
Finally the `Preg` class provides a few `*StrictGroups` method variants that ensure match groups
88+
are always present and thus non-nullable, making it easier to write type-safe code:
89+
90+
```php
91+
use Composer\Pcre\Preg;
92+
93+
// $matches is guaranteed to be an array of strings, if a subpattern does not match and produces a null it will throw
94+
if (Preg::matchStrictGroups('{fo+}', $string, $matches))
95+
if (Preg::matchAllStrictGroups('{fo+}', $string, $matches))
96+
```
97+
8798
If you would prefer a slightly more verbose usage, replacing by-ref arguments by result objects, you can use the `Regex` class:
8899

89100
```php

src/MatchAllResult.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ final class MatchAllResult
3535

3636
/**
3737
* @param 0|positive-int $count
38-
* @param array<array<string|null>> $matches
38+
* @param array<int|string, array<string|null>> $matches
3939
*/
40-
public function __construct($count, array $matches)
40+
public function __construct(int $count, array $matches)
4141
{
4242
$this->matches = $matches;
4343
$this->matched = (bool) $count;

src/MatchAllStrictGroupsResult.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of composer/pcre.
5+
*
6+
* (c) Composer <https://github.com/composer>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace Composer\Pcre;
13+
14+
final class MatchAllStrictGroupsResult
15+
{
16+
/**
17+
* An array of match group => list of matched strings
18+
*
19+
* @readonly
20+
* @var array<int|string, list<string>>
21+
*/
22+
public $matches;
23+
24+
/**
25+
* @readonly
26+
* @var 0|positive-int
27+
*/
28+
public $count;
29+
30+
/**
31+
* @readonly
32+
* @var bool
33+
*/
34+
public $matched;
35+
36+
/**
37+
* @param 0|positive-int $count
38+
* @param array<array<string>> $matches
39+
*/
40+
public function __construct(int $count, array $matches)
41+
{
42+
$this->matches = $matches;
43+
$this->matched = (bool) $count;
44+
$this->count = $count;
45+
}
46+
}

src/MatchAllWithOffsetsResult.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ final class MatchAllWithOffsetsResult
3939
* @param array<int|string, list<array{string|null, int}>> $matches
4040
* @phpstan-param array<int|string, list<array{string|null, int<-1, max>}>> $matches
4141
*/
42-
public function __construct($count, array $matches)
42+
public function __construct(int $count, array $matches)
4343
{
4444
$this->matches = $matches;
4545
$this->matched = (bool) $count;

src/MatchResult.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ final class MatchResult
3131
* @param 0|positive-int $count
3232
* @param array<string|null> $matches
3333
*/
34-
public function __construct($count, array $matches)
34+
public function __construct(int $count, array $matches)
3535
{
3636
$this->matches = $matches;
3737
$this->matched = (bool) $count;

src/MatchStrictGroupsResult.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of composer/pcre.
5+
*
6+
* (c) Composer <https://github.com/composer>
7+
*
8+
* For the full copyright and license information, please view
9+
* the LICENSE file that was distributed with this source code.
10+
*/
11+
12+
namespace Composer\Pcre;
13+
14+
final class MatchStrictGroupsResult
15+
{
16+
/**
17+
* An array of match group => string matched
18+
*
19+
* @readonly
20+
* @var array<int|string, string>
21+
*/
22+
public $matches;
23+
24+
/**
25+
* @readonly
26+
* @var bool
27+
*/
28+
public $matched;
29+
30+
/**
31+
* @param 0|positive-int $count
32+
* @param array<string> $matches
33+
*/
34+
public function __construct(int $count, array $matches)
35+
{
36+
$this->matches = $matches;
37+
$this->matched = (bool) $count;
38+
}
39+
}

src/MatchWithOffsetsResult.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ final class MatchWithOffsetsResult
3333
* @param array<array{string|null, int}> $matches
3434
* @phpstan-param array<int|string, array{string|null, int<-1, max>}> $matches
3535
*/
36-
public function __construct($count, array $matches)
36+
public function __construct(int $count, array $matches)
3737
{
3838
$this->matches = $matches;
3939
$this->matched = (bool) $count;

src/Preg.php

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ public static function match(string $pattern, string $subject, ?array &$matches
3838
return $result;
3939
}
4040

41+
/**
42+
* Variant of `match()` which outputs non-null matches (or throws)
43+
*
44+
* @param non-empty-string $pattern
45+
* @param array<string> $matches Set by method
46+
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
47+
* @return 0|1
48+
* @throws UnexpectedNullMatchException
49+
*
50+
* @param-out array<int|string, string> $matches
51+
*/
52+
public static function matchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
53+
{
54+
$result = self::match($pattern, $subject, $matchesInternal, $flags, $offset);
55+
$matches = self::enforceNonNullMatches($pattern, $matchesInternal, 'match');
56+
57+
return $result;
58+
}
59+
4160
/**
4261
* Runs preg_match with PREG_OFFSET_CAPTURE
4362
*
@@ -61,18 +80,15 @@ public static function matchWithOffsets(string $pattern, string $subject, ?array
6180
/**
6281
* @param non-empty-string $pattern
6382
* @param array<int|string, list<string|null>> $matches Set by method
64-
* @param int-mask<PREG_UNMATCHED_AS_NULL|PREG_SET_ORDER> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
83+
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
6584
* @return 0|positive-int
6685
*
6786
* @param-out array<int|string, list<string|null>> $matches
6887
*/
6988
public static function matchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
7089
{
7190
self::checkOffsetCapture($flags, 'matchAllWithOffsets');
72-
73-
if (($flags & PREG_SET_ORDER) !== 0) {
74-
throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the type of $matches');
75-
}
91+
self::checkSetOrder($flags);
7692

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

101+
/**
102+
* Variant of `match()` which outputs non-null matches (or throws)
103+
*
104+
* @param non-empty-string $pattern
105+
* @param array<int|string, list<string|null>> $matches Set by method
106+
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
107+
* @return 0|positive-int
108+
* @throws UnexpectedNullMatchException
109+
*
110+
* @param-out array<int|string, list<string>> $matches
111+
*/
112+
public static function matchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int
113+
{
114+
$result = self::matchAll($pattern, $subject, $matchesInternal, $flags, $offset);
115+
$matches = self::enforceNonNullMatchAll($pattern, $matchesInternal, 'matchAll');
116+
117+
return $result;
118+
}
119+
85120
/**
86121
* Runs preg_match_all with PREG_OFFSET_CAPTURE
87122
*
@@ -94,6 +129,8 @@ public static function matchAll(string $pattern, string $subject, ?array &$match
94129
*/
95130
public static function matchAllWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): int
96131
{
132+
self::checkSetOrder($flags);
133+
97134
$result = preg_match_all($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, $offset);
98135
if (!is_int($result)) { // PHP < 8 may return null, 8+ returns int|false
99136
throw PcreException::fromFunction('preg_match_all', $pattern);
@@ -233,6 +270,8 @@ public static function grep(string $pattern, array $array, int $flags = 0): arra
233270
}
234271

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

247286
/**
287+
* Variant of `isMatch()` which outputs non-null matches (or throws)
288+
*
289+
* @param non-empty-string $pattern
290+
* @param array<string> $matches Set by method
291+
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
292+
* @throws UnexpectedNullMatchException
293+
*
294+
* @param-out array<int|string, string> $matches
295+
*/
296+
public static function isMatchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
297+
{
298+
return (bool) self::matchStrictGroups($pattern, $subject, $matches, $flags, $offset);
299+
}
300+
301+
/**
302+
* Variant of matchAll() which returns a bool instead of int
303+
*
248304
* @param non-empty-string $pattern
249305
* @param array<int|string, list<string|null>> $matches Set by method
250306
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
@@ -257,6 +313,22 @@ public static function isMatchAll(string $pattern, string $subject, ?array &$mat
257313
}
258314

259315
/**
316+
* Variant of `isMatchAll()` which outputs non-null matches (or throws)
317+
*
318+
* @param non-empty-string $pattern
319+
* @param array<int|string, list<string>> $matches Set by method
320+
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
321+
*
322+
* @param-out array<int|string, list<string>> $matches
323+
*/
324+
public static function isMatchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
325+
{
326+
return (bool) self::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset);
327+
}
328+
329+
/**
330+
* Variant of matchWithOffsets() which returns a bool instead of int
331+
*
260332
* Runs preg_match with PREG_OFFSET_CAPTURE
261333
*
262334
* @param non-empty-string $pattern
@@ -271,6 +343,8 @@ public static function isMatchWithOffsets(string $pattern, string $subject, ?arr
271343
}
272344

273345
/**
346+
* Variant of matchAllWithOffsets() which returns a bool instead of int
347+
*
274348
* Runs preg_match_all with PREG_OFFSET_CAPTURE
275349
*
276350
* @param non-empty-string $pattern
@@ -290,4 +364,47 @@ private static function checkOffsetCapture(int $flags, string $useFunctionName):
290364
throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the type of $matches, use ' . $useFunctionName . '() instead');
291365
}
292366
}
367+
368+
private static function checkSetOrder(int $flags): void
369+
{
370+
if (($flags & PREG_SET_ORDER) !== 0) {
371+
throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the type of $matches');
372+
}
373+
}
374+
375+
/**
376+
* @param array<int|string, string|null> $matches
377+
* @return array<int|string, string>
378+
* @throws UnexpectedNullMatchException
379+
*/
380+
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod)
381+
{
382+
foreach ($matches as $group => $match) {
383+
if (null === $match) {
384+
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
385+
}
386+
}
387+
388+
/** @var array<string> */
389+
return $matches;
390+
}
391+
392+
/**
393+
* @param array<int|string, list<string|null>> $matches
394+
* @return array<int|string, list<string>>
395+
* @throws UnexpectedNullMatchException
396+
*/
397+
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod)
398+
{
399+
foreach ($matches as $group => $groupMatches) {
400+
foreach ($groupMatches as $match) {
401+
if (null === $match) {
402+
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
403+
}
404+
}
405+
}
406+
407+
/** @var array<int|string, list<string>> */
408+
return $matches;
409+
}
293410
}

0 commit comments

Comments
 (0)