Skip to content

Commit 06d0e49

Browse files
authored
Add PHPStan type extension for the replaceCallback callable (#33)
1 parent d948ae8 commit 06d0e49

File tree

6 files changed

+238
-2
lines changed

6 files changed

+238
-2
lines changed

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
},
2222
"require-dev": {
2323
"phpunit/phpunit": "^8 || ^9",
24-
"phpstan/phpstan": "^1.11.9",
24+
"phpstan/phpstan": "^1.11.10",
2525
"phpstan/phpstan-strict-rules": "^1.1"
2626
},
2727
"conflict": {
28-
"phpstan/phpstan": "<1.11.9"
28+
"phpstan/phpstan": "<1.11.10"
2929
},
3030
"autoload": {
3131
"psr-4": {

extension.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ services:
1212
class: Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension
1313
tags:
1414
- phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension
15+
-
16+
class: Composer\Pcre\PHPStan\PregReplaceCallbackClosureTypeExtension
17+
tags:
18+
- phpstan.staticMethodParameterClosureTypeExtension
1519

1620
rules:
1721
- Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule

phpstan-baseline.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ parameters:
1010
count: 1
1111
path: src/Preg.php
1212

13+
-
14+
message: "#^Creating new PHPStan\\\\Reflection\\\\Native\\\\NativeParameterReflection is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
15+
count: 1
16+
path: src/PHPStan/PregReplaceCallbackClosureTypeExtension.php
17+
1318
-
1419
message: "#^Parameter \\#2 \\$callback of function preg_replace_callback expects callable\\(array\\<int\\|string, string\\>\\)\\: string, \\(callable\\(array\\<int\\|string, array\\{string\\|null, int\\<\\-1, max\\>\\}\\>\\)\\: string\\)\\|\\(callable\\(array\\<int\\|string, string\\|null\\>\\)\\: string\\) given\\.$#"
1520
count: 2
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Composer\Pcre\PHPStan;
4+
5+
use Composer\Pcre\Preg;
6+
use Composer\Pcre\Regex;
7+
use PhpParser\Node\Expr\StaticCall;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\MethodReflection;
10+
use PHPStan\Reflection\Native\NativeParameterReflection;
11+
use PHPStan\Reflection\ParameterReflection;
12+
use PHPStan\TrinaryLogic;
13+
use PHPStan\Type\ClosureType;
14+
use PHPStan\Type\Php\RegexArrayShapeMatcher;
15+
use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
16+
use PHPStan\Type\StringType;
17+
use PHPStan\Type\Type;
18+
19+
final class PregReplaceCallbackClosureTypeExtension implements StaticMethodParameterClosureTypeExtension
20+
{
21+
/**
22+
* @var RegexArrayShapeMatcher
23+
*/
24+
private $regexShapeMatcher;
25+
26+
public function __construct(RegexArrayShapeMatcher $regexShapeMatcher)
27+
{
28+
$this->regexShapeMatcher = $regexShapeMatcher;
29+
}
30+
31+
public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool
32+
{
33+
return in_array($methodReflection->getDeclaringClass()->getName(), [Preg::class, Regex::class], true)
34+
&& in_array($methodReflection->getName(), ['replaceCallback'], true)
35+
&& $parameter->getName() === 'replacement';
36+
}
37+
38+
public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type
39+
{
40+
$args = $methodCall->getArgs();
41+
$patternArg = $args[0] ?? null;
42+
$flagsArg = $args[5] ?? null;
43+
44+
if (
45+
$patternArg === null
46+
) {
47+
return null;
48+
}
49+
50+
$flagsType = null;
51+
if ($flagsArg !== null) {
52+
$flagsType = $scope->getType($flagsArg->value);
53+
}
54+
55+
$matchesType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope);
56+
if ($matchesType === null) {
57+
return null;
58+
}
59+
60+
return new ClosureType(
61+
[
62+
new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $matchesType, $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()),
63+
],
64+
new StringType()
65+
);
66+
}
67+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php // lint < 7.4
2+
3+
namespace PregMatchShapes;
4+
5+
use Composer\Pcre\Preg;
6+
use Composer\Pcre\Regex;
7+
use function PHPStan\Testing\assertType;
8+
9+
function (string $s): void {
10+
Preg::replaceCallback(
11+
$s,
12+
function ($matches) {
13+
assertType('array<int|string, string|null>', $matches);
14+
return '';
15+
},
16+
$s
17+
);
18+
19+
Regex::replaceCallback(
20+
$s,
21+
function ($matches) {
22+
assertType('array<int|string, string|null>', $matches);
23+
return '';
24+
},
25+
$s
26+
);
27+
};
28+
29+
function (string $s): void {
30+
Preg::replaceCallback(
31+
'|<p>(\s*)\w|',
32+
function ($matches) {
33+
assertType('array{string, string}', $matches);
34+
return '';
35+
},
36+
$s
37+
);
38+
};
39+
40+
function (string $s): void {
41+
Preg::replaceCallback(
42+
'/(foo)?(bar)?(baz)?/',
43+
function ($matches) {
44+
assertType("array{0: string, 1?: ''|'foo', 2?: ''|'bar', 3?: 'baz'}", $matches);
45+
return '';
46+
},
47+
$s,
48+
-1,
49+
$count,
50+
PREG_UNMATCHED_AS_NULL
51+
);
52+
};
53+
54+
function (string $s): void {
55+
Preg::replaceCallback(
56+
'/(foo)?(bar)?(baz)?/',
57+
function ($matches) {
58+
assertType("array{0: array{string, int<-1, max>}, 1?: array{''|'foo', int<-1, max>}, 2?: array{''|'bar', int<-1, max>}, 3?: array{'baz', int<-1, max>}}", $matches);
59+
return '';
60+
},
61+
$s,
62+
-1,
63+
$count,
64+
PREG_OFFSET_CAPTURE
65+
);
66+
};
67+
68+
function (string $s): void {
69+
Preg::replaceCallback(
70+
'/(foo)?(bar)?(baz)?/',
71+
function ($matches) {
72+
assertType("array{0: array{string, int<-1, max>}, 1?: array{''|'foo', int<-1, max>}, 2?: array{''|'bar', int<-1, max>}, 3?: array{'baz', int<-1, max>}}", $matches);
73+
return '';
74+
},
75+
$s,
76+
-1,
77+
$count,
78+
PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL
79+
);
80+
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php // lint >= 7.4
2+
3+
namespace PregMatchShapes;
4+
5+
use Composer\Pcre\Preg;
6+
use Composer\Pcre\Regex;
7+
use function PHPStan\Testing\assertType;
8+
9+
function (string $s): void {
10+
Preg::replaceCallback(
11+
$s,
12+
function ($matches) {
13+
assertType('array<int|string, string|null>', $matches);
14+
return '';
15+
},
16+
$s
17+
);
18+
19+
Regex::replaceCallback(
20+
$s,
21+
function ($matches) {
22+
assertType('array<int|string, string|null>', $matches);
23+
return '';
24+
},
25+
$s
26+
);
27+
};
28+
29+
function (string $s): void {
30+
Preg::replaceCallback(
31+
'|<p>(\s*)\w|',
32+
function ($matches) {
33+
assertType('array{string, string}', $matches);
34+
return '';
35+
},
36+
$s
37+
);
38+
};
39+
40+
function (string $s): void {
41+
Preg::replaceCallback(
42+
'/(foo)?(bar)?(baz)?/',
43+
function ($matches) {
44+
assertType("array{string, 'foo'|null, 'bar'|null, 'baz'|null}", $matches);
45+
return '';
46+
},
47+
$s,
48+
-1,
49+
$count,
50+
PREG_UNMATCHED_AS_NULL
51+
);
52+
};
53+
54+
function (string $s): void {
55+
Preg::replaceCallback(
56+
'/(foo)?(bar)?(baz)?/',
57+
function ($matches) {
58+
assertType("array{0: array{string, int<-1, max>}, 1?: array{''|'foo', int<-1, max>}, 2?: array{''|'bar', int<-1, max>}, 3?: array{'baz', int<-1, max>}}", $matches);
59+
return '';
60+
},
61+
$s,
62+
-1,
63+
$count,
64+
PREG_OFFSET_CAPTURE
65+
);
66+
};
67+
68+
function (string $s): void {
69+
Preg::replaceCallback(
70+
'/(foo)?(bar)?(baz)?/',
71+
function ($matches) {
72+
assertType("array{array{string|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches);
73+
return '';
74+
},
75+
$s,
76+
-1,
77+
$count,
78+
PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL
79+
);
80+
};

0 commit comments

Comments
 (0)