LICENSE000064400000002034147526136460005566 0ustar00Copyright (C) 2021 Composer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. README.md000064400000020771147526136460006050 0ustar00composer/pcre ============= PCRE wrapping library that offers type-safe `preg_*` replacements. This library gives you a way to ensure `preg_*` functions do not fail silently, returning unexpected `null`s that may not be handled. As of 3.0 this library enforces [`PREG_UNMATCHED_AS_NULL`](#preg_unmatched_as_null) usage for all matching and replaceCallback functions, [read more below](#preg_unmatched_as_null) to understand the implications. It thus makes it easier to work with static analysis tools like PHPStan or Psalm as it simplifies and reduces the possible return values from all the `preg_*` functions which are quite packed with edge cases. As of v2.2.0 / v3.2.0 the library also comes with a [PHPStan extension](#phpstan-extension) for parsing regular expressions and giving you even better output types. This library is a thin wrapper around `preg_*` functions with [some limitations](#restrictions--limitations). If you are looking for a richer API to handle regular expressions have a look at [rawr/t-regx](https://packagist.org/packages/rawr/t-regx) instead. [![Continuous Integration](https://github.com/composer/pcre/workflows/Continuous%20Integration/badge.svg?branch=main)](https://github.com/composer/pcre/actions) Installation ------------ Install the latest version with: ```bash $ composer require composer/pcre ``` Requirements ------------ * PHP 7.4.0 is required for 3.x versions * PHP 7.2.0 is required for 2.x versions * PHP 5.3.2 is required for 1.x versions Basic usage ----------- Instead of: ```php if (preg_match('{fo+}', $string, $matches)) { ... } if (preg_match('{fo+}', $string, $matches, PREG_OFFSET_CAPTURE)) { ... } if (preg_match_all('{fo+}', $string, $matches)) { ... } $newString = preg_replace('{fo+}', 'bar', $string); $newString = preg_replace_callback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string); $newString = preg_replace_callback_array(['{fo+}' => fn ($match) => strtoupper($match[0])], $string); $filtered = preg_grep('{[a-z]}', $elements); $array = preg_split('{[a-z]+}', $string); ``` You can now call these on the `Preg` class: ```php use Composer\Pcre\Preg; if (Preg::match('{fo+}', $string, $matches)) { ... } if (Preg::matchWithOffsets('{fo+}', $string, $matches)) { ... } if (Preg::matchAll('{fo+}', $string, $matches)) { ... } $newString = Preg::replace('{fo+}', 'bar', $string); $newString = Preg::replaceCallback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string); $newString = Preg::replaceCallbackArray(['{fo+}' => fn ($match) => strtoupper($match[0])], $string); $filtered = Preg::grep('{[a-z]}', $elements); $array = Preg::split('{[a-z]+}', $string); ``` The main difference is if anything fails to match/replace/.., it will throw a `Composer\Pcre\PcreException` instead of returning `null` (or false in some cases), so you can now use the return values safely relying on the fact that they can only be strings (for replace), ints (for match) or arrays (for grep/split). Additionally the `Preg` class provides match methods that return `bool` rather than `int`, for stricter type safety when the number of pattern matches is not useful: ```php use Composer\Pcre\Preg; 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)) ``` **Note:** This is generally safe to use as long as you do not have optional subpatterns (i.e. `(something)?` or `(something)*` or branches with a `|` that result in some groups not being matched at all). A subpattern that can match an empty string like `(.*)` is **not** optional, it will be present as an empty string in the matches. A non-matching subpattern, even if optional like `(?:foo)?` will anyway not be present in matches so it is also not a problem to use these with `*StrictGroups` methods. If you would prefer a slightly more verbose usage, replacing by-ref arguments by result objects, you can use the `Regex` class: ```php use Composer\Pcre\Regex; // this is useful when you are just interested in knowing if something matched // as it returns a bool instead of int(1/0) for match $bool = Regex::isMatch('{fo+}', $string); $result = Regex::match('{fo+}', $string); if ($result->matched) { something($result->matches); } $result = Regex::matchWithOffsets('{fo+}', $string); if ($result->matched) { something($result->matches); } $result = Regex::matchAll('{fo+}', $string); if ($result->matched && $result->count > 3) { something($result->matches); } $newString = Regex::replace('{fo+}', 'bar', $string)->result; $newString = Regex::replaceCallback('{fo+}', function ($match) { return strtoupper($match[0]); }, $string)->result; $newString = Regex::replaceCallbackArray(['{fo+}' => fn ($match) => strtoupper($match[0])], $string)->result; ``` Note that `preg_grep` and `preg_split` are only callable via the `Preg` class as they do not have complex return types warranting a specific result object. See the [MatchResult](src/MatchResult.php), [MatchWithOffsetsResult](src/MatchWithOffsetsResult.php), [MatchAllResult](src/MatchAllResult.php), [MatchAllWithOffsetsResult](src/MatchAllWithOffsetsResult.php), and [ReplaceResult](src/ReplaceResult.php) class sources for more details. Restrictions / Limitations -------------------------- Due to type safety requirements a few restrictions are in place. - matching using `PREG_OFFSET_CAPTURE` is made available via `matchWithOffsets` and `matchAllWithOffsets`. You cannot pass the flag to `match`/`matchAll`. - `Preg::split` will also reject `PREG_SPLIT_OFFSET_CAPTURE` and you should use `splitWithOffsets` instead. - `matchAll` rejects `PREG_SET_ORDER` as it also changes the shape of the returned matches. There is no alternative provided as you can fairly easily code around it. - `preg_filter` is not supported as it has a rather crazy API, most likely you should rather use `Preg::grep` in combination with some loop and `Preg::replace`. - `replace`, `replaceCallback` and `replaceCallbackArray` do not support an array `$subject`, only simple strings. - As of 2.0, the library always uses `PREG_UNMATCHED_AS_NULL` for matching, which offers [much saner/more predictable results](#preg_unmatched_as_null). As of 3.0 the flag is also set for `replaceCallback` and `replaceCallbackArray`. #### PREG_UNMATCHED_AS_NULL As of 2.0, this library always uses PREG_UNMATCHED_AS_NULL for all `match*` and `isMatch*` functions. As of 3.0 it is also done for `replaceCallback` and `replaceCallbackArray`. This means your matches will always contain all matching groups, either as null if unmatched or as string if it matched. The advantages in clarity and predictability are clearer if you compare the two outputs of running this with and without PREG_UNMATCHED_AS_NULL in $flags: ```php preg_match('/(a)(b)*(c)(d)*/', 'ac', $matches, $flags); ``` | no flag | PREG_UNMATCHED_AS_NULL | | --- | --- | | array (size=4) | array (size=5) | | 0 => string 'ac' (length=2) | 0 => string 'ac' (length=2) | | 1 => string 'a' (length=1) | 1 => string 'a' (length=1) | | 2 => string '' (length=0) | 2 => null | | 3 => string 'c' (length=1) | 3 => string 'c' (length=1) | | | 4 => null | | group 2 (any unmatched group preceding one that matched) is set to `''`. You cannot tell if it matched an empty string or did not match at all | group 2 is `null` when unmatched and a string if it matched, easy to check for | | group 4 (any optional group without a matching one following) is missing altogether. So you have to check with `isset()`, but really you want `isset($m[4]) && $m[4] !== ''` for safety unless you are very careful to check that a non-optional group follows it | group 4 is always set, and null in this case as there was no match, easy to check for with `$m[4] !== null` | PHPStan Extension ----------------- To use the PHPStan extension if you do not use `phpstan/extension-installer` you can include `vendor/composer/pcre/extension.neon` in your PHPStan config. The extension provides much better type information for $matches as well as regex validation where possible. License ------- composer/pcre is licensed under the MIT License, see the LICENSE file for details. composer.json000064400000002257147526136460007312 0ustar00{ "name": "composer/pcre", "description": "PCRE wrapping library that offers type-safe preg_* replacements.", "type": "library", "license": "MIT", "keywords": [ "pcre", "regex", "preg", "regular expression" ], "authors": [ { "name": "Jordi Boggiano", "email": "j.boggiano@seld.be", "homepage": "http://seld.be" } ], "require": { "php": "^7.4 || ^8.0" }, "require-dev": { "phpunit/phpunit": "^8 || ^9", "phpstan/phpstan": "^1.11.10", "phpstan/phpstan-strict-rules": "^1.1" }, "conflict": { "phpstan/phpstan": "<1.11.10" }, "autoload": { "psr-4": { "Composer\\Pcre\\": "src" } }, "autoload-dev": { "psr-4": { "Composer\\Pcre\\": "tests" } }, "extra": { "branch-alias": { "dev-main": "3.x-dev" }, "phpstan": { "includes": [ "extension.neon" ] } }, "scripts": { "test": "@php vendor/bin/phpunit", "phpstan": "@php phpstan analyse" } } extension.neon000064400000001370147526136460007460 0ustar00# composer/pcre PHPStan extensions # # These can be reused by third party packages by including 'vendor/composer/pcre/extension.neon' # in your phpstan config services: - class: Composer\Pcre\PHPStan\PregMatchParameterOutTypeExtension tags: - phpstan.staticMethodParameterOutTypeExtension - class: Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension tags: - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension - class: Composer\Pcre\PHPStan\PregReplaceCallbackClosureTypeExtension tags: - phpstan.staticMethodParameterClosureTypeExtension rules: - Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule - Composer\Pcre\PHPStan\InvalidRegexPatternRule src/MatchAllResult.php000064400000001605147526136460010750 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; final class MatchAllResult { /** * An array of match group => list of matched strings * * @readonly * @var array> */ public $matches; /** * @readonly * @var 0|positive-int */ public $count; /** * @readonly * @var bool */ public $matched; /** * @param 0|positive-int $count * @param array> $matches */ public function __construct(int $count, array $matches) { $this->matches = $matches; $this->matched = (bool) $count; $this->count = $count; } } src/MatchAllStrictGroupsResult.php000064400000001573147526136460013345 0ustar00 * * 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> */ public $matches; /** * @readonly * @var 0|positive-int */ public $count; /** * @readonly * @var bool */ public $matched; /** * @param 0|positive-int $count * @param array> $matches */ public function __construct(int $count, array $matches) { $this->matches = $matches; $this->matched = (bool) $count; $this->count = $count; } } src/MatchAllWithOffsetsResult.php000064400000002231147526136460013132 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; final class MatchAllWithOffsetsResult { /** * An array of match group => list of matches, every match being a pair of string matched + offset in bytes (or -1 if no match) * * @readonly * @var array> * @phpstan-var array}>> */ public $matches; /** * @readonly * @var 0|positive-int */ public $count; /** * @readonly * @var bool */ public $matched; /** * @param 0|positive-int $count * @param array> $matches * @phpstan-param array}>> $matches */ public function __construct(int $count, array $matches) { $this->matches = $matches; $this->matched = (bool) $count; $this->count = $count; } } src/MatchResult.php000064400000001362147526136460010317 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; final class MatchResult { /** * An array of match group => string matched * * @readonly * @var array */ public $matches; /** * @readonly * @var bool */ public $matched; /** * @param 0|positive-int $count * @param array $matches */ public function __construct(int $count, array $matches) { $this->matches = $matches; $this->matched = (bool) $count; } } src/MatchStrictGroupsResult.php000064400000001364147526136460012712 0ustar00 * * 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 */ public $matches; /** * @readonly * @var bool */ public $matched; /** * @param 0|positive-int $count * @param array $matches */ public function __construct(int $count, array $matches) { $this->matches = $matches; $this->matched = (bool) $count; } } src/MatchWithOffsetsResult.php000064400000001736147526136460012512 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; final class MatchWithOffsetsResult { /** * An array of match group => pair of string matched + offset in bytes (or -1 if no match) * * @readonly * @var array * @phpstan-var array}> */ public $matches; /** * @readonly * @var bool */ public $matched; /** * @param 0|positive-int $count * @param array $matches * @phpstan-param array}> $matches */ public function __construct(int $count, array $matches) { $this->matches = $matches; $this->matched = (bool) $count; } } src/PHPStan/InvalidRegexPatternRule.php000064400000010103147526136460014101 0ustar00 */ class InvalidRegexPatternRule implements Rule { public function getNodeType(): string { return StaticCall::class; } public function processNode(Node $node, Scope $scope): array { $patterns = $this->extractPatterns($node, $scope); $errors = []; foreach ($patterns as $pattern) { $errorMessage = $this->validatePattern($pattern); if ($errorMessage === null) { continue; } $errors[] = RuleErrorBuilder::message(sprintf('Regex pattern is invalid: %s', $errorMessage))->identifier('regexp.pattern')->build(); } return $errors; } /** * @return string[] */ private function extractPatterns(StaticCall $node, Scope $scope): array { if (!$node->class instanceof FullyQualified) { return []; } $isRegex = $node->class->toString() === Regex::class; $isPreg = $node->class->toString() === Preg::class; if (!$isRegex && !$isPreg) { return []; } if (!$node->name instanceof Node\Identifier || !Preg::isMatch('{^(match|isMatch|grep|replace|split)}', $node->name->name)) { return []; } $functionName = $node->name->name; if (!isset($node->getArgs()[0])) { return []; } $patternNode = $node->getArgs()[0]->value; $patternType = $scope->getType($patternNode); $patternStrings = []; foreach ($patternType->getConstantStrings() as $constantStringType) { if ($functionName === 'replaceCallbackArray') { continue; } $patternStrings[] = $constantStringType->getValue(); } foreach ($patternType->getConstantArrays() as $constantArrayType) { if ( in_array($functionName, [ 'replace', 'replaceCallback', ], true) ) { foreach ($constantArrayType->getValueTypes() as $arrayKeyType) { foreach ($arrayKeyType->getConstantStrings() as $constantString) { $patternStrings[] = $constantString->getValue(); } } } if ($functionName !== 'replaceCallbackArray') { continue; } foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) { foreach ($arrayKeyType->getConstantStrings() as $constantString) { $patternStrings[] = $constantString->getValue(); } } } return $patternStrings; } private function validatePattern(string $pattern): ?string { try { $msg = null; $prev = set_error_handler(function (int $severity, string $message, string $file) use (&$msg): bool { $msg = preg_replace("#^preg_match(_all)?\\(.*?\\): #", '', $message); return true; }); if ($pattern === '') { return 'Empty string is not a valid regular expression'; } Preg::match($pattern, ''); if ($msg !== null) { return $msg; } } catch (PcreException $e) { if ($e->getCode() === PREG_INTERNAL_ERROR && $msg !== null) { return $msg; } return preg_replace('{.*? failed executing ".*": }', '', $e->getMessage()); } finally { restore_error_handler(); } return null; } } src/PHPStan/PregMatchFlags.php000064400000002072147526136460012167 0ustar00getType($flagsArg->value); $constantScalars = $flagsType->getConstantScalarValues(); if ($constantScalars === []) { return null; } $internalFlagsTypes = []; foreach ($flagsType->getConstantScalarValues() as $constantScalarValue) { if (!is_int($constantScalarValue)) { return null; } $internalFlagsTypes[] = new ConstantIntegerType($constantScalarValue | PREG_UNMATCHED_AS_NULL); } return TypeCombinator::union(...$internalFlagsTypes); } } src/PHPStan/PregMatchParameterOutTypeExtension.php000064400000004215147526136460016303 0ustar00regexShapeMatcher = $regexShapeMatcher; } public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool { return $methodReflection->getDeclaringClass()->getName() === Preg::class && in_array($methodReflection->getName(), [ 'match', 'isMatch', 'matchStrictGroups', 'isMatchStrictGroups', 'matchAll', 'isMatchAll', 'matchAllStrictGroups', 'isMatchAllStrictGroups' ], true) && $parameter->getName() === 'matches'; } public function getParameterOutTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type { $args = $methodCall->getArgs(); $patternArg = $args[0] ?? null; $matchesArg = $args[2] ?? null; $flagsArg = $args[3] ?? null; if ( $patternArg === null || $matchesArg === null ) { return null; } $flagsType = PregMatchFlags::getType($flagsArg, $scope); if ($flagsType === null) { return null; } if (stripos($methodReflection->getName(), 'matchAll') !== false) { return $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); } return $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); } } src/PHPStan/PregMatchTypeSpecifyingExtension.php000064400000007277147526136460016006 0ustar00regexShapeMatcher = $regexShapeMatcher; } public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void { $this->typeSpecifier = $typeSpecifier; } public function getClass(): string { return Preg::class; } public function isStaticMethodSupported(MethodReflection $methodReflection, StaticCall $node, TypeSpecifierContext $context): bool { return in_array($methodReflection->getName(), [ 'match', 'isMatch', 'matchStrictGroups', 'isMatchStrictGroups', 'matchAll', 'isMatchAll', 'matchAllStrictGroups', 'isMatchAllStrictGroups' ], true) && !$context->null(); } public function specifyTypes(MethodReflection $methodReflection, StaticCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes { $args = $node->getArgs(); $patternArg = $args[0] ?? null; $matchesArg = $args[2] ?? null; $flagsArg = $args[3] ?? null; if ( $patternArg === null || $matchesArg === null ) { return new SpecifiedTypes(); } $flagsType = PregMatchFlags::getType($flagsArg, $scope); if ($flagsType === null) { return new SpecifiedTypes(); } if (stripos($methodReflection->getName(), 'matchAll') !== false) { $matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); } else { $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); } if ($matchedType === null) { return new SpecifiedTypes(); } if ( in_array($methodReflection->getName(), ['matchStrictGroups', 'isMatchStrictGroups'], true) && count($matchedType->getConstantArrays()) === 1 ) { $matchedType = $matchedType->getConstantArrays()[0]; $matchedType = new ConstantArrayType( $matchedType->getKeyTypes(), array_map(static function (Type $valueType): Type { return TypeCombinator::removeNull($valueType); }, $matchedType->getValueTypes()), $matchedType->getNextAutoIndexes(), [], $matchedType->isList() ); } $overwrite = false; if ($context->false()) { $overwrite = true; $context = $context->negate(); } return $this->typeSpecifier->create( $matchesArg->value, $matchedType, $context, $overwrite, $scope, $node ); } } src/PHPStan/PregReplaceCallbackClosureTypeExtension.php000064400000005552147526136460017250 0ustar00regexShapeMatcher = $regexShapeMatcher; } public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool { return in_array($methodReflection->getDeclaringClass()->getName(), [Preg::class, Regex::class], true) && in_array($methodReflection->getName(), ['replaceCallback', 'replaceCallbackStrictGroups'], true) && $parameter->getName() === 'replacement'; } public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type { $args = $methodCall->getArgs(); $patternArg = $args[0] ?? null; $flagsArg = $args[5] ?? null; if ( $patternArg === null ) { return null; } $flagsType = PregMatchFlags::getType($flagsArg, $scope); $matchesType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope); if ($matchesType === null) { return null; } if ($methodReflection->getName() === 'replaceCallbackStrictGroups' && count($matchesType->getConstantArrays()) === 1) { $matchesType = $matchesType->getConstantArrays()[0]; $matchesType = new ConstantArrayType( $matchesType->getKeyTypes(), array_map(static function (Type $valueType): Type { return TypeCombinator::removeNull($valueType); }, $matchesType->getValueTypes()), $matchesType->getNextAutoIndexes(), [], $matchesType->isList() ); } return new ClosureType( [ new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $matchesType, $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()), ], new StringType() ); } } src/PHPStan/UnsafeStrictGroupsCallRule.php000064400000007027147526136460014603 0ustar00 */ final class UnsafeStrictGroupsCallRule implements Rule { /** * @var RegexArrayShapeMatcher */ private $regexShapeMatcher; public function __construct(RegexArrayShapeMatcher $regexShapeMatcher) { $this->regexShapeMatcher = $regexShapeMatcher; } public function getNodeType(): string { return StaticCall::class; } public function processNode(Node $node, Scope $scope): array { if (!$node->class instanceof FullyQualified) { return []; } $isRegex = $node->class->toString() === Regex::class; $isPreg = $node->class->toString() === Preg::class; if (!$isRegex && !$isPreg) { return []; } if (!$node->name instanceof Node\Identifier || !in_array($node->name->name, ['matchStrictGroups', 'isMatchStrictGroups', 'matchAllStrictGroups', 'isMatchAllStrictGroups'], true)) { return []; } $args = $node->getArgs(); if (!isset($args[0])) { return []; } $patternArg = $args[0] ?? null; if ($isPreg) { if (!isset($args[2])) { // no matches set, skip as the matches won't be used anyway return []; } $flagsArg = $args[3] ?? null; } else { $flagsArg = $args[2] ?? null; } if ($patternArg === null) { return []; } $flagsType = PregMatchFlags::getType($flagsArg, $scope); if ($flagsType === null) { return []; } $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope); if ($matchedType === null) { return [ RuleErrorBuilder::message(sprintf('The %s call is potentially unsafe as $matches\' type could not be inferred.', $node->name->name)) ->identifier('composerPcre.maybeUnsafeStrictGroups') ->build(), ]; } if (count($matchedType->getConstantArrays()) === 1) { $matchedType = $matchedType->getConstantArrays()[0]; $nullableGroups = []; foreach ($matchedType->getValueTypes() as $index => $type) { if (TypeCombinator::containsNull($type)) { $nullableGroups[] = $matchedType->getKeyTypes()[$index]->getValue(); } } if (\count($nullableGroups) > 0) { return [ RuleErrorBuilder::message(sprintf( 'The %s call is unsafe as match group%s "%s" %s optional and may be null.', $node->name->name, \count($nullableGroups) > 1 ? 's' : '', implode('", "', $nullableGroups), \count($nullableGroups) > 1 ? 'are' : 'is' ))->identifier('composerPcre.unsafeStrictGroups')->build(), ]; } } return []; } } src/PcreException.php000064400000002754147526136460010642 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; class PcreException extends \RuntimeException { /** * @param string $function * @param string|string[] $pattern * @return self */ public static function fromFunction($function, $pattern) { $code = preg_last_error(); if (is_array($pattern)) { $pattern = implode(', ', $pattern); } return new PcreException($function.'(): failed executing "'.$pattern.'": '.self::pcreLastErrorMessage($code), $code); } /** * @param int $code * @return string */ private static function pcreLastErrorMessage($code) { if (function_exists('preg_last_error_msg')) { return preg_last_error_msg(); } // older php versions did not set the code properly in all cases if (PHP_VERSION_ID < 70201 && $code === 0) { return 'UNDEFINED_ERROR'; } $constants = get_defined_constants(true); if (!isset($constants['pcre'])) { return 'UNDEFINED_ERROR'; } foreach ($constants['pcre'] as $const => $val) { if ($val === $code && substr($const, -6) === '_ERROR') { return $const; } } return 'UNDEFINED_ERROR'; } } src/Preg.php000064400000042404147526136460006763 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; class Preg { /** @internal */ public const ARRAY_MSG = '$subject as an array is not supported. You can use \'foreach\' instead.'; /** @internal */ public const INVALID_TYPE_MSG = '$subject must be a string, %s given.'; /** * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * @return 0|1 * * @param-out array $matches */ public static function match(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int { self::checkOffsetCapture($flags, 'matchWithOffsets'); $result = preg_match($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL, $offset); if ($result === false) { throw PcreException::fromFunction('preg_match', $pattern); } return $result; } /** * Variant of `match()` which outputs non-null matches (or throws) * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * @return 0|1 * @throws UnexpectedNullMatchException * * @param-out array $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 * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_OFFSET_CAPTURE are always set, no other flags are supported * @return 0|1 * * @param-out array}> $matches */ public static function matchWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): int { $result = preg_match($pattern, $subject, $matches, $flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, $offset); if ($result === false) { throw PcreException::fromFunction('preg_match', $pattern); } return $result; } /** * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * @return 0|positive-int * * @param-out array> $matches */ public static function matchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): int { self::checkOffsetCapture($flags, 'matchAllWithOffsets'); 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 throw PcreException::fromFunction('preg_match_all', $pattern); } return $result; } /** * Variant of `match()` which outputs non-null matches (or throws) * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * @return 0|positive-int * @throws UnexpectedNullMatchException * * @param-out array> $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 * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported * @return 0|positive-int * * @param-out array}>> $matches */ 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); } return $result; } /** * @param string|string[] $pattern * @param string|string[] $replacement * @param string $subject * @param int $count Set by method * * @param-out int<0, max> $count */ public static function replace($pattern, $replacement, $subject, int $limit = -1, ?int &$count = null): string { if (!is_scalar($subject)) { if (is_array($subject)) { throw new \InvalidArgumentException(static::ARRAY_MSG); } throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject))); } $result = preg_replace($pattern, $replacement, $subject, $limit, $count); if ($result === null) { throw PcreException::fromFunction('preg_replace', $pattern); } return $result; } /** * @param string|string[] $pattern * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement * @param string $subject * @param int $count Set by method * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set * * @param-out int<0, max> $count */ public static function replaceCallback($pattern, callable $replacement, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string { if (!is_scalar($subject)) { if (is_array($subject)) { throw new \InvalidArgumentException(static::ARRAY_MSG); } throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject))); } $result = preg_replace_callback($pattern, $replacement, $subject, $limit, $count, $flags | PREG_UNMATCHED_AS_NULL); if ($result === null) { throw PcreException::fromFunction('preg_replace_callback', $pattern); } return $result; } /** * Variant of `replaceCallback()` which outputs non-null matches (or throws) * * @param string $pattern * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement * @param string $subject * @param int $count Set by method * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set * * @param-out int<0, max> $count */ public static function replaceCallbackStrictGroups(string $pattern, callable $replacement, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string { return self::replaceCallback($pattern, function (array $matches) use ($pattern, $replacement) { return $replacement(self::enforceNonNullMatches($pattern, $matches, 'replaceCallback')); }, $subject, $limit, $count, $flags); } /** * @param ($flags is PREG_OFFSET_CAPTURE ? (array}>): string>) : array): string>) $pattern * @param string $subject * @param int $count Set by method * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set * * @param-out int<0, max> $count */ public static function replaceCallbackArray(array $pattern, $subject, int $limit = -1, ?int &$count = null, int $flags = 0): string { if (!is_scalar($subject)) { if (is_array($subject)) { throw new \InvalidArgumentException(static::ARRAY_MSG); } throw new \TypeError(sprintf(static::INVALID_TYPE_MSG, gettype($subject))); } $result = preg_replace_callback_array($pattern, $subject, $limit, $count, $flags | PREG_UNMATCHED_AS_NULL); if ($result === null) { $pattern = array_keys($pattern); throw PcreException::fromFunction('preg_replace_callback_array', $pattern); } return $result; } /** * @param int-mask $flags PREG_SPLIT_NO_EMPTY or PREG_SPLIT_DELIM_CAPTURE * @return list */ public static function split(string $pattern, string $subject, int $limit = -1, int $flags = 0): array { if (($flags & PREG_SPLIT_OFFSET_CAPTURE) !== 0) { throw new \InvalidArgumentException('PREG_SPLIT_OFFSET_CAPTURE is not supported as it changes the type of $matches, use splitWithOffsets() instead'); } $result = preg_split($pattern, $subject, $limit, $flags); if ($result === false) { throw PcreException::fromFunction('preg_split', $pattern); } return $result; } /** * @param int-mask $flags PREG_SPLIT_NO_EMPTY or PREG_SPLIT_DELIM_CAPTURE, PREG_SPLIT_OFFSET_CAPTURE is always set * @return list * @phpstan-return list}> */ public static function splitWithOffsets(string $pattern, string $subject, int $limit = -1, int $flags = 0): array { $result = preg_split($pattern, $subject, $limit, $flags | PREG_SPLIT_OFFSET_CAPTURE); if ($result === false) { throw PcreException::fromFunction('preg_split', $pattern); } return $result; } /** * @template T of string|\Stringable * @param string $pattern * @param array $array * @param int-mask $flags PREG_GREP_INVERT * @return array */ public static function grep(string $pattern, array $array, int $flags = 0): array { $result = preg_grep($pattern, $array, $flags); if ($result === false) { throw PcreException::fromFunction('preg_grep', $pattern); } return $result; } /** * Variant of match() which returns a bool instead of int * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * * @param-out array $matches */ public static function isMatch(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool { return (bool) static::match($pattern, $subject, $matches, $flags, $offset); } /** * Variant of `isMatch()` which outputs non-null matches (or throws) * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * @throws UnexpectedNullMatchException * * @param-out array $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 $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * * @param-out array> $matches */ public static function isMatchAll(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0): bool { return (bool) static::matchAll($pattern, $subject, $matches, $flags, $offset); } /** * Variant of `isMatchAll()` which outputs non-null matches (or throws) * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * * @param-out array> $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 * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * * @param-out array}> $matches */ public static function isMatchWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): bool { return (bool) static::matchWithOffsets($pattern, $subject, $matches, $flags, $offset); } /** * Variant of matchAllWithOffsets() which returns a bool instead of int * * Runs preg_match_all with PREG_OFFSET_CAPTURE * * @param non-empty-string $pattern * @param array $matches Set by method * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * * @param-out array}>> $matches */ public static function isMatchAllWithOffsets(string $pattern, string $subject, ?array &$matches, int $flags = 0, int $offset = 0): bool { return (bool) static::matchAllWithOffsets($pattern, $subject, $matches, $flags, $offset); } private static function checkOffsetCapture(int $flags, string $useFunctionName): void { if (($flags & PREG_OFFSET_CAPTURE) !== 0) { 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 $matches * @return array * @throws UnexpectedNullMatchException */ private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod) { foreach ($matches as $group => $match) { if (is_string($match) || (is_array($match) && is_string($match[0]))) { continue; } throw new UnexpectedNullMatchException('Pattern "'.$pattern.'" had an unexpected unmatched group "'.$group.'", make sure the pattern always matches or use '.$variantMethod.'() instead.'); } /** @var array */ return $matches; } /** * @param array> $matches * @return array> * @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> */ return $matches; } } src/Regex.php000064400000016301147526136460007135 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; class Regex { /** * @param non-empty-string $pattern */ public static function isMatch(string $pattern, string $subject, int $offset = 0): bool { return (bool) Preg::match($pattern, $subject, $matches, 0, $offset); } /** * @param non-empty-string $pattern * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported */ public static function match(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchResult { self::checkOffsetCapture($flags, 'matchWithOffsets'); $count = Preg::match($pattern, $subject, $matches, $flags, $offset); return new MatchResult($count, $matches); } /** * Variant of `match()` which returns non-null matches (or throws) * * @param non-empty-string $pattern * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * @throws UnexpectedNullMatchException */ public static function matchStrictGroups(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchStrictGroupsResult { // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups $count = Preg::matchStrictGroups($pattern, $subject, $matches, $flags, $offset); return new MatchStrictGroupsResult($count, $matches); } /** * Runs preg_match with PREG_OFFSET_CAPTURE * * @param non-empty-string $pattern * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported */ public static function matchWithOffsets(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchWithOffsetsResult { $count = Preg::matchWithOffsets($pattern, $subject, $matches, $flags, $offset); return new MatchWithOffsetsResult($count, $matches); } /** * @param non-empty-string $pattern * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported */ public static function matchAll(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllResult { self::checkOffsetCapture($flags, 'matchAllWithOffsets'); self::checkSetOrder($flags); $count = Preg::matchAll($pattern, $subject, $matches, $flags, $offset); return new MatchAllResult($count, $matches); } /** * Variant of `matchAll()` which returns non-null matches (or throws) * * @param non-empty-string $pattern * @param int-mask $flags PREG_UNMATCHED_AS_NULL is always set, no other flags are supported * @throws UnexpectedNullMatchException */ public static function matchAllStrictGroups(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllStrictGroupsResult { self::checkOffsetCapture($flags, 'matchAllWithOffsets'); self::checkSetOrder($flags); // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups $count = Preg::matchAllStrictGroups($pattern, $subject, $matches, $flags, $offset); return new MatchAllStrictGroupsResult($count, $matches); } /** * Runs preg_match_all with PREG_OFFSET_CAPTURE * * @param non-empty-string $pattern * @param int-mask $flags PREG_UNMATCHED_AS_NULL and PREG_MATCH_OFFSET are always set, no other flags are supported */ public static function matchAllWithOffsets(string $pattern, string $subject, int $flags = 0, int $offset = 0): MatchAllWithOffsetsResult { self::checkSetOrder($flags); $count = Preg::matchAllWithOffsets($pattern, $subject, $matches, $flags, $offset); return new MatchAllWithOffsetsResult($count, $matches); } /** * @param string|string[] $pattern * @param string|string[] $replacement * @param string $subject */ public static function replace($pattern, $replacement, $subject, int $limit = -1): ReplaceResult { $result = Preg::replace($pattern, $replacement, $subject, $limit, $count); return new ReplaceResult($count, $result); } /** * @param string|string[] $pattern * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement * @param string $subject * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set */ public static function replaceCallback($pattern, callable $replacement, $subject, int $limit = -1, int $flags = 0): ReplaceResult { $result = Preg::replaceCallback($pattern, $replacement, $subject, $limit, $count, $flags); return new ReplaceResult($count, $result); } /** * Variant of `replaceCallback()` which outputs non-null matches (or throws) * * @param string $pattern * @param ($flags is PREG_OFFSET_CAPTURE ? (callable(array}>): string) : callable(array): string) $replacement * @param string $subject * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set */ public static function replaceCallbackStrictGroups($pattern, callable $replacement, $subject, int $limit = -1, int $flags = 0): ReplaceResult { $result = Preg::replaceCallbackStrictGroups($pattern, $replacement, $subject, $limit, $count, $flags); return new ReplaceResult($count, $result); } /** * @param ($flags is PREG_OFFSET_CAPTURE ? (array}>): string>) : array): string>) $pattern * @param string $subject * @param int-mask $flags PREG_OFFSET_CAPTURE is supported, PREG_UNMATCHED_AS_NULL is always set */ public static function replaceCallbackArray(array $pattern, $subject, int $limit = -1, int $flags = 0): ReplaceResult { $result = Preg::replaceCallbackArray($pattern, $subject, $limit, $count, $flags); return new ReplaceResult($count, $result); } private static function checkOffsetCapture(int $flags, string $useFunctionName): void { if (($flags & PREG_OFFSET_CAPTURE) !== 0) { throw new \InvalidArgumentException('PREG_OFFSET_CAPTURE is not supported as it changes the return type, 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 return type'); } } } src/ReplaceResult.php000064400000001346147526136460010640 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; final class ReplaceResult { /** * @readonly * @var string */ public $result; /** * @readonly * @var 0|positive-int */ public $count; /** * @readonly * @var bool */ public $matched; /** * @param 0|positive-int $count */ public function __construct(int $count, string $result) { $this->count = $count; $this->matched = (bool) $count; $this->result = $result; } } src/UnexpectedNullMatchException.php000064400000000777147526136460013670 0ustar00 * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace Composer\Pcre; class UnexpectedNullMatchException extends PcreException { public static function fromFunction($function, $pattern) { throw new \LogicException('fromFunction should not be called on '.self::class.', use '.PcreException::class); } }