Skip to content

Commit

Permalink
Add PHPStan type extension for the replaceCallback callable (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
Seldaek authored Aug 19, 2024
1 parent d948ae8 commit 06d0e49
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 2 deletions.
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
},
"require-dev": {
"phpunit/phpunit": "^8 || ^9",
"phpstan/phpstan": "^1.11.9",
"phpstan/phpstan": "^1.11.10",
"phpstan/phpstan-strict-rules": "^1.1"
},
"conflict": {
"phpstan/phpstan": "<1.11.9"
"phpstan/phpstan": "<1.11.10"
},
"autoload": {
"psr-4": {
Expand Down
4 changes: 4 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ services:
class: Composer\Pcre\PHPStan\PregMatchTypeSpecifyingExtension
tags:
- phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension
-
class: Composer\Pcre\PHPStan\PregReplaceCallbackClosureTypeExtension
tags:
- phpstan.staticMethodParameterClosureTypeExtension

rules:
- Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule
Expand Down
5 changes: 5 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ parameters:
count: 1
path: src/Preg.php

-
message: "#^Creating new PHPStan\\\\Reflection\\\\Native\\\\NativeParameterReflection is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
count: 1
path: src/PHPStan/PregReplaceCallbackClosureTypeExtension.php

-
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\\.$#"
count: 2
Expand Down
67 changes: 67 additions & 0 deletions src/PHPStan/PregReplaceCallbackClosureTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php declare(strict_types=1);

namespace Composer\Pcre\PHPStan;

use Composer\Pcre\Preg;
use Composer\Pcre\Regex;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\Native\NativeParameterReflection;
use PHPStan\Reflection\ParameterReflection;
use PHPStan\TrinaryLogic;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Php\RegexArrayShapeMatcher;
use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;

final class PregReplaceCallbackClosureTypeExtension implements StaticMethodParameterClosureTypeExtension
{
/**
* @var RegexArrayShapeMatcher
*/
private $regexShapeMatcher;

public function __construct(RegexArrayShapeMatcher $regexShapeMatcher)
{
$this->regexShapeMatcher = $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'], 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 = null;
if ($flagsArg !== null) {
$flagsType = $scope->getType($flagsArg->value);
}

$matchesType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope);
if ($matchesType === null) {
return null;
}

return new ClosureType(
[
new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $matchesType, $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()),
],
new StringType()
);
}
}
80 changes: 80 additions & 0 deletions tests/PHPStanTests/nsrt/preg-replace-callback-php7.2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php // lint < 7.4

namespace PregMatchShapes;

use Composer\Pcre\Preg;
use Composer\Pcre\Regex;
use function PHPStan\Testing\assertType;

function (string $s): void {
Preg::replaceCallback(
$s,
function ($matches) {
assertType('array<int|string, string|null>', $matches);
return '';
},
$s
);

Regex::replaceCallback(
$s,
function ($matches) {
assertType('array<int|string, string|null>', $matches);
return '';
},
$s
);
};

function (string $s): void {
Preg::replaceCallback(
'|<p>(\s*)\w|',
function ($matches) {
assertType('array{string, string}', $matches);
return '';
},
$s
);
};

function (string $s): void {
Preg::replaceCallback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
assertType("array{0: string, 1?: ''|'foo', 2?: ''|'bar', 3?: 'baz'}", $matches);
return '';
},
$s,
-1,
$count,
PREG_UNMATCHED_AS_NULL
);
};

function (string $s): void {
Preg::replaceCallback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
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);
return '';
},
$s,
-1,
$count,
PREG_OFFSET_CAPTURE
);
};

function (string $s): void {
Preg::replaceCallback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
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);
return '';
},
$s,
-1,
$count,
PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL
);
};
80 changes: 80 additions & 0 deletions tests/PHPStanTests/nsrt/preg-replace-callback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php // lint >= 7.4

namespace PregMatchShapes;

use Composer\Pcre\Preg;
use Composer\Pcre\Regex;
use function PHPStan\Testing\assertType;

function (string $s): void {
Preg::replaceCallback(
$s,
function ($matches) {
assertType('array<int|string, string|null>', $matches);
return '';
},
$s
);

Regex::replaceCallback(
$s,
function ($matches) {
assertType('array<int|string, string|null>', $matches);
return '';
},
$s
);
};

function (string $s): void {
Preg::replaceCallback(
'|<p>(\s*)\w|',
function ($matches) {
assertType('array{string, string}', $matches);
return '';
},
$s
);
};

function (string $s): void {
Preg::replaceCallback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
assertType("array{string, 'foo'|null, 'bar'|null, 'baz'|null}", $matches);
return '';
},
$s,
-1,
$count,
PREG_UNMATCHED_AS_NULL
);
};

function (string $s): void {
Preg::replaceCallback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
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);
return '';
},
$s,
-1,
$count,
PREG_OFFSET_CAPTURE
);
};

function (string $s): void {
Preg::replaceCallback(
'/(foo)?(bar)?(baz)?/',
function ($matches) {
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);
return '';
},
$s,
-1,
$count,
PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL
);
};

0 comments on commit 06d0e49

Please sign in to comment.