Skip to content

Commit 37ef71e

Browse files
Seldaekstaabm
andauthored
Implement array shapes for Preg::match $matches by-ref parameter (#25)
* Implement array shapes for `Preg::match` $matches by-ref parameter * declare conflict with phpstan < 1.11.6 * Fork off phpstan CI in another job * Get rid of phpunit bridge Co-authored-by: Markus Staab <[email protected]>
1 parent b13ea67 commit 37ef71e

13 files changed

+333
-43
lines changed

.github/workflows/continuous-integration.yml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ on:
55
- pull_request
66

77
env:
8-
COMPOSER_FLAGS: "--ansi --no-interaction --no-progress --prefer-dist"
98
SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT: "1"
109

1110
jobs:
1211
tests:
1312
name: "CI"
1413

1514
runs-on: ubuntu-latest
15+
continue-on-error: ${{ matrix.experimental }}
1616

1717
strategy:
1818
matrix:
@@ -24,7 +24,10 @@ jobs:
2424
- "8.1"
2525
- "8.2"
2626
- "8.3"
27-
- "8.4"
27+
experimental: [false]
28+
include:
29+
- php-version: "8.4"
30+
experimental: true
2831

2932
steps:
3033
- name: "Checkout"
@@ -49,9 +52,9 @@ jobs:
4952

5053
- name: "Install latest dependencies"
5154
run: |
52-
# Remove PHPStan as it requires a newer PHP
53-
composer remove phpstan/phpstan phpstan/phpstan-strict-rules --dev --no-update
5455
composer update ${{ env.COMPOSER_FLAGS }}
5556
5657
- name: "Run tests"
57-
run: "vendor/bin/simple-phpunit --verbose"
58+
run: |
59+
vendor/bin/phpunit
60+
vendor/bin/phpunit --testsuite phpstan

.github/workflows/phpstan.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,5 @@ jobs:
4444
- name: "Install latest dependencies"
4545
run: "composer update ${{ env.COMPOSER_FLAGS }}"
4646

47-
- name: "Initialize PHPUnit sources"
48-
run: "vendor/bin/simple-phpunit --filter NO_TEST_JUST_AUTOLOAD_THANKS"
49-
5047
- name: "Run PHPStan"
5148
run: "composer phpstan"

composer.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
"php": "^7.2 || ^8.0"
2121
},
2222
"require-dev": {
23-
"symfony/phpunit-bridge": "^7",
24-
"phpstan/phpstan": "^1.3",
23+
"phpunit/phpunit": "^8 || ^9",
24+
"phpstan/phpstan": "^1.11.6",
2525
"phpstan/phpstan-strict-rules": "^1.1"
2626
},
27+
"conflict": {
28+
"phpstan/phpstan": "<1.11.6"
29+
},
2730
"autoload": {
2831
"psr-4": {
2932
"Composer\\Pcre\\": "src"
@@ -40,7 +43,7 @@
4043
}
4144
},
4245
"scripts": {
43-
"test": "SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT=1 vendor/bin/simple-phpunit",
44-
"phpstan": "phpstan analyse"
46+
"test": "@php vendor/bin/phpunit",
47+
"phpstan": "@php phpstan analyse"
4548
}
4649
}

extension.neon

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# composer/pcre PHPStan extensions
2+
#
3+
# These can be reused by third party packages by including 'vendor/composer/pcre/extension.neon'
4+
# in your phpstan config
5+
6+
conditionalTags:
7+
Composer\Pcre\PHPStan\PregMatchParameterOutTypeExtension:
8+
phpstan.staticMethodParameterOutTypeExtension: %featureToggles.narrowPregMatches%
9+
Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension:
10+
phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension: %featureToggles.narrowPregMatches%
11+
12+
services:
13+
-
14+
class: Composer\Pcre\PHPStan\PregMatchParameterOutTypeExtension
15+
-
16+
class: Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension

phpstan.neon.dist

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ parameters:
88
treatPhpDocTypesAsCertain: false
99

1010
bootstrapFiles:
11-
- tests/phpstan-locate-phpunit-autoloader.php
11+
- vendor/autoload.php
12+
13+
excludePaths:
14+
- tests/PHPStanTests/nsrt/*
1215

1316
includes:
17+
- extension.neon
1418
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
1519
- vendor/phpstan/phpstan-strict-rules/rules.neon
1620
- phpstan-baseline.neon

phpunit.xml.dist

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
backupGlobals="false"
66
colors="true"
77
bootstrap="vendor/autoload.php"
8+
defaultTestSuite="pcre"
89
>
910
<testsuites>
10-
<testsuite name="PCRE Test Suite">
11-
<directory>tests</directory>
11+
<testsuite name="pcre">
12+
<directory>tests/PregTests</directory>
13+
<directory>tests/RegexTests</directory>
14+
</testsuite>
15+
<testsuite name="phpstan">
16+
<directory>tests/PHPStanTests</directory>
1217
</testsuite>
1318
</testsuites>
1419

src/PHPStan/PregMatchFlags.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Composer\Pcre\PHPStan;
4+
5+
use PHPStan\Analyser\Scope;
6+
use PHPStan\Type\Constant\ConstantIntegerType;
7+
use PHPStan\Type\TypeCombinator;
8+
use PHPStan\Type\Type;
9+
use PhpParser\Node\Arg;
10+
11+
final class PregMatchFlags
12+
{
13+
static public function getType(?Arg $flagsArg, Scope $scope): ?Type
14+
{
15+
if ($flagsArg === null) {
16+
return new ConstantIntegerType(PREG_UNMATCHED_AS_NULL);
17+
}
18+
19+
$flagsType = $scope->getType($flagsArg->value);
20+
21+
$constantScalars = $flagsType->getConstantScalarValues();
22+
if ($constantScalars === []) {
23+
return null;
24+
}
25+
26+
$internalFlagsTypes = [];
27+
foreach ($flagsType->getConstantScalarValues() as $constantScalarValue) {
28+
if (!is_int($constantScalarValue)) {
29+
return null;
30+
}
31+
32+
$internalFlagsTypes[] = new ConstantIntegerType($constantScalarValue | PREG_UNMATCHED_AS_NULL);
33+
}
34+
return TypeCombinator::union(...$internalFlagsTypes);
35+
}
36+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Composer\Pcre\PHPStan;
4+
5+
use Composer\Pcre\Preg;
6+
use PhpParser\Node\Expr\StaticCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\MethodReflection;
9+
use PHPStan\Reflection\ParameterReflection;
10+
use PHPStan\TrinaryLogic;
11+
use PHPStan\Type\Php\RegexArrayShapeMatcher;
12+
use PHPStan\Type\StaticMethodParameterOutTypeExtension;
13+
use PHPStan\Type\Type;
14+
15+
final class PregMatchParameterOutTypeExtension implements StaticMethodParameterOutTypeExtension
16+
{
17+
/**
18+
* @var RegexArrayShapeMatcher
19+
*/
20+
private $regexShapeMatcher;
21+
22+
public function __construct(
23+
RegexArrayShapeMatcher $regexShapeMatcher
24+
)
25+
{
26+
$this->regexShapeMatcher = $regexShapeMatcher;
27+
}
28+
29+
public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
30+
{
31+
return
32+
$methodReflection->getDeclaringClass()->getName() === Preg::class
33+
&& $methodReflection->getName() === 'match'
34+
&& $parameter->getName() === 'matches';
35+
}
36+
37+
public function getParameterOutTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type
38+
{
39+
$args = $methodCall->getArgs();
40+
$patternArg = $args[0] ?? null;
41+
$matchesArg = $args[2] ?? null;
42+
$flagsArg = $args[3] ?? null;
43+
44+
if (
45+
$patternArg === null || $matchesArg === null
46+
) {
47+
return null;
48+
}
49+
50+
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
51+
if ($flagsType === null) {
52+
return null;
53+
}
54+
$patternType = $scope->getType($patternArg->value);
55+
56+
return $this->regexShapeMatcher->matchType($patternType, $flagsType, TrinaryLogic::createMaybe());
57+
}
58+
59+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Composer\Pcre\PHPStan;
4+
5+
use Composer\Pcre\Preg;
6+
use PhpParser\Node\Expr\StaticCall;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Analyser\SpecifiedTypes;
9+
use PHPStan\Analyser\TypeSpecifier;
10+
use PHPStan\Analyser\TypeSpecifierAwareExtension;
11+
use PHPStan\Analyser\TypeSpecifierContext;
12+
use PHPStan\Reflection\MethodReflection;
13+
use PHPStan\TrinaryLogic;
14+
use PHPStan\Type\Php\RegexArrayShapeMatcher;
15+
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
16+
17+
final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
18+
{
19+
/**
20+
* @var TypeSpecifier
21+
*/
22+
private $typeSpecifier;
23+
24+
/**
25+
* @var RegexArrayShapeMatcher
26+
*/
27+
private $regexShapeMatcher;
28+
29+
public function __construct(RegexArrayShapeMatcher $regexShapeMatcher)
30+
{
31+
$this->regexShapeMatcher = $regexShapeMatcher;
32+
}
33+
34+
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
35+
{
36+
$this->typeSpecifier = $typeSpecifier;
37+
}
38+
39+
public function getClass(): string
40+
{
41+
return Preg::class;
42+
}
43+
44+
public function isStaticMethodSupported(MethodReflection $methodReflection, StaticCall $node, TypeSpecifierContext $context): bool
45+
{
46+
return $methodReflection->getName() === 'match' && !$context->null();
47+
}
48+
49+
public function specifyTypes(MethodReflection $methodReflection, StaticCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
50+
{
51+
$args = $node->getArgs();
52+
$patternArg = $args[0] ?? null;
53+
$matchesArg = $args[2] ?? null;
54+
$flagsArg = $args[3] ?? null;
55+
56+
if (
57+
$patternArg === null || $matchesArg === null
58+
) {
59+
return new SpecifiedTypes();
60+
}
61+
62+
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
63+
if ($flagsType === null) {
64+
return new SpecifiedTypes();
65+
}
66+
$patternType = $scope->getType($patternArg->value);
67+
68+
$matchedType = $this->regexShapeMatcher->matchType($patternType, $flagsType, TrinaryLogic::createFromBoolean($context->true()));
69+
if ($matchedType === null) {
70+
return new SpecifiedTypes();
71+
}
72+
73+
$overwrite = false;
74+
if ($context->false()) {
75+
$overwrite = true;
76+
$context = $context->negate();
77+
}
78+
79+
return $this->typeSpecifier->create(
80+
$matchesArg->value,
81+
$matchedType,
82+
$context,
83+
$overwrite,
84+
$scope,
85+
$node
86+
);
87+
}
88+
}

tests/BaseTestCase.php

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,12 @@ protected function expectPcreException(string $pattern, ?string $error = null):
5151
// Only use a message if the error can be reliably determined
5252
if (PHP_VERSION_ID >= 80000) {
5353
$error = 'Internal error';
54-
} elseif (PHP_VERSION_ID >= 70201) {
54+
} else {
5555
$error = 'PREG_INTERNAL_ERROR';
5656
}
5757
}
5858

59-
if (null !== $error) {
60-
$message = sprintf('%s: failed executing "%s": %s', $this->pregFunction, $pattern, $error);
61-
} else {
62-
$message = null;
63-
}
59+
$message = sprintf('%s: failed executing "%s": %s', $this->pregFunction, $pattern, $error);
6460

6561
$this->doExpectException('Composer\Pcre\PcreException', $message);
6662
}

0 commit comments

Comments
 (0)