Skip to content

Commit e92ff6e

Browse files
committed
Add matchStrictGroups and other strict group operations to avoid nullable matches (#14)
1 parent 36d3086 commit e92ff6e

14 files changed

+334
-21
lines changed

README.md

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

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

8596
```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);
@@ -241,6 +278,8 @@ public static function grep(string $pattern, array $array, int $flags = 0): arra
241278
}
242279

243280
/**
281+
* Variant of match() which returns a bool instead of int
282+
*
244283
* @param non-empty-string $pattern
245284
* @param array<string|null> $matches Set by method
246285
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
@@ -253,6 +292,23 @@ public static function isMatch(string $pattern, string $subject, ?array &$matche
253292
}
254293

255294
/**
295+
* Variant of `isMatch()` which outputs non-null matches (or throws)
296+
*
297+
* @param non-empty-string $pattern
298+
* @param array<string> $matches Set by method
299+
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
300+
* @throws UnexpectedNullMatchException
301+
*
302+
* @param-out array<int|string, string> $matches
303+
*/
304+
public static function isMatchStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
305+
{
306+
return (bool) self::matchStrictGroups($pattern, $subject, $matches, $flags, $offset);
307+
}
308+
309+
/**
310+
* Variant of matchAll() which returns a bool instead of int
311+
*
256312
* @param non-empty-string $pattern
257313
* @param array<int|string, list<string|null>> $matches Set by method
258314
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
@@ -265,6 +321,22 @@ public static function isMatchAll(string $pattern, string $subject, ?array &$mat
265321
}
266322

267323
/**
324+
* Variant of `isMatchAll()` which outputs non-null matches (or throws)
325+
*
326+
* @param non-empty-string $pattern
327+
* @param array<int|string, list<string>> $matches Set by method
328+
* @param int-mask<PREG_UNMATCHED_AS_NULL> $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported
329+
*
330+
* @param-out array<int|string, list<string>> $matches
331+
*/
332+
public static function isMatchAllStrictGroups(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool
333+
{
334+
return (bool) self::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset);
335+
}
336+
337+
/**
338+
* Variant of matchWithOffsets() which returns a bool instead of int
339+
*
268340
* Runs preg_match with PREG_OFFSET_CAPTURE
269341
*
270342
* @param non-empty-string $pattern
@@ -279,6 +351,8 @@ public static function isMatchWithOffsets(string $pattern, string $subject, ?arr
279351
}
280352

281353
/**
354+
* Variant of matchAllWithOffsets() which returns a bool instead of int
355+
*
282356
* Runs preg_match_all with PREG_OFFSET_CAPTURE
283357
*
284358
* @param non-empty-string $pattern
@@ -298,4 +372,47 @@ private static function checkOffsetCapture(int $flags, string $useFunctionName):
298372
throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the type of $matches, use ' . $useFunctionName . '() instead');
299373
}
300374
}
375+
376+
private static function checkSetOrder(int $flags): void
377+
{
378+
if (($flags & PREG_SET_ORDER) !== 0) {
379+
throw new \InvalidArgumentException('PREG_SET_ORDER is not supported as it changes the type of $matches');
380+
}
381+
}
382+
383+
/**
384+
* @param array<int|string, string|null> $matches
385+
* @return array<int|string, string>
386+
* @throws UnexpectedNullMatchException
387+
*/
388+
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod)
389+
{
390+
foreach ($matches as $group => $match) {
391+
if (null === $match) {
392+
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
393+
}
394+
}
395+
396+
/** @var array<string> */
397+
return $matches;
398+
}
399+
400+
/**
401+
* @param array<int|string, list<string|null>> $matches
402+
* @return array<int|string, list<string>>
403+
* @throws UnexpectedNullMatchException
404+
*/
405+
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod)
406+
{
407+
foreach ($matches as $group => $groupMatches) {
408+
foreach ($groupMatches as $match) {
409+
if (null === $match) {
410+
throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.');
411+
}
412+
}
413+
}
414+
415+
/** @var array<int|string, list<string>> */
416+
return $matches;
417+
}
301418
}

0 commit comments

Comments
 (0)