Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved preg_match() output param type inference #7027

Merged
merged 8 commits into from Nov 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion dictionaries/CallMap.php
Expand Up @@ -10273,7 +10273,7 @@
'preg_filter' => ['null|string|string[]', 'pattern'=>'mixed', 'replacement'=>'mixed', 'subject'=>'mixed', 'limit='=>'int', '&w_count='=>'int'],
'preg_grep' => ['array|false', 'pattern'=>'string', 'array'=>'array', 'flags='=>'int'],
'preg_last_error' => ['int'],
'preg_match' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0|', 'offset='=>'int'],
'preg_match' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'],
weirdan marked this conversation as resolved.
Show resolved Hide resolved
'preg_match\'1' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'],
'preg_match_all' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'],
'preg_quote' => ['string', 'str'=>'string', 'delimiter='=>'string'],
Expand Down
2 changes: 1 addition & 1 deletion dictionaries/CallMap_historical.php
Expand Up @@ -14454,7 +14454,7 @@
'preg_filter' => ['null|string|string[]', 'pattern'=>'mixed', 'replacement'=>'mixed', 'subject'=>'mixed', 'limit='=>'int', '&w_count='=>'int'],
'preg_grep' => ['array|false', 'pattern'=>'string', 'array'=>'array', 'flags='=>'int'],
'preg_last_error' => ['int'],
'preg_match' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0|', 'offset='=>'int'],
'preg_match' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'],
'preg_match\'1' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'],
'preg_match_all' => ['int|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'],
'preg_quote' => ['string', 'str'=>'string', 'delimiter='=>'string'],
Expand Down
6 changes: 3 additions & 3 deletions src/Psalm/CodeLocation.php
Expand Up @@ -259,14 +259,14 @@ private function calculateRealLocation(): void
}

if (preg_match($regex, $preview_snippet, $matches, PREG_OFFSET_CAPTURE)) {
if (!isset($matches[1]) || (int)$matches[1][1] === -1) {
if (!isset($matches[1]) || $matches[1][1] === -1) {
throw new \LogicException(
"Failed to match anything to 1st capturing group, "
. "or regex doesn't contain 1st capturing group, regex type " . $this->regex_type
);
}
$this->selection_start = $this->selection_start + (int)$matches[1][1];
$this->selection_end = $this->selection_start + strlen((string)$matches[1][0]);
$this->selection_start = $this->selection_start + $matches[1][1];
$this->selection_end = $this->selection_start + strlen($matches[1][0]);
}
}

Expand Down
Expand Up @@ -635,7 +635,7 @@ public static function checkArgumentsMatch(
}
}

if ($method_id === 'preg_match_all' && count($args) > 3) {
if (($method_id === 'preg_match_all' || $method_id === 'preg_match') && count($args) > 3) {
$args = array_reverse($args, true);
}

Expand Down
Expand Up @@ -325,7 +325,7 @@ public static function parse(
$end_of_method_regex = '/(?<!array\()\) ?(\: ?(\??[\\\\a-zA-Z0-9_]+))?/';

if (preg_match($end_of_method_regex, $method_entry, $matches, PREG_OFFSET_CAPTURE)) {
$method_entry = substr($method_entry, 0, (int) $matches[0][1] + strlen((string) $matches[0][0]));
$method_entry = substr($method_entry, 0, $matches[0][1] + strlen($matches[0][0]));
}

$method_entry = str_replace([', ', '( '], [',', '('], $method_entry);
Expand Down
18 changes: 18 additions & 0 deletions stubs/CoreGenericFunctions.phpstub
Expand Up @@ -963,6 +963,24 @@ function preg_replace_callback($pattern, $callback, $subject, int $limit = -1, &
*/
function preg_match_all($pattern, $subject, &$matches = [], int $flags = 1, int $offset = 0) {}

/**
* @psalm-pure
* @template TFlags as int-mask<0, 256, 512>
*
* @param string $pattern
* @param string $subject
* @param mixed $matches
* @param TFlags $flags
* @param-out (TFlags is 256 ? array<array-key, array{string, 0|positive-int}|array{'', -1}> :
* TFlags is 512 ? array<array-key, string|null> :
* TFlags is 768 ? array<array-key, array{string, 0|positive-int}|array{null, -1}> :
* array<array-key, string>
* ) $matches
* @return 1|0|false
* @psalm-ignore-falsable-return
*/
function preg_match($pattern, $subject, &$matches = [], int $flags = 0, int $offset = 0) {}

/**
* @psalm-pure
*
Expand Down
56 changes: 51 additions & 5 deletions tests/FunctionCallTest.php
Expand Up @@ -1282,6 +1282,13 @@ function takesInt(int $i) : void {}

takesInt(preg_match("{foo}", "foo"));',
],
'pregMatch2' => [
'<?php
$r = preg_match("{foo}", "foo");',
'assertions' => [
'$r===' => '0|1|false',
],
],
'pregMatchWithMatches' => [
'<?php
/** @param string[] $matches */
Expand All @@ -1291,6 +1298,14 @@ function takesMatches(array $matches) : void {}

takesMatches($matches);',
],
'pregMatchWithMatches2' => [
'<?php
$r = preg_match("{foo}", "foo", $matches);',
'assertions' => [
'$r===' => '0|1|false',
'$matches===' => 'array<array-key, string>',
],
],
'pregMatchWithOffset' => [
'<?php
/** @param string[] $matches */
Expand All @@ -1300,18 +1315,46 @@ function takesMatches(array $matches) : void {}

takesMatches($matches);',
],
'pregMatchWithOffset2' => [
'<?php
$r = preg_match("{foo}", "foo", $matches, 0, 10);',
'assertions' => [
'$r===' => '0|1|false',
'$matches===' => 'array<array-key, string>',
],
],
'pregMatchWithFlags' => [
'<?php
function takesInt(int $i) : void {}

if (preg_match("{foo}", "this is foo", $matches, PREG_OFFSET_CAPTURE)) {
/**
* @psalm-suppress MixedArrayAccess
* @psalm-suppress MixedArgument
*/
takesInt($matches[0][1]);
}',
],
'pregMatchWithFlagOffsetCapture' => [
'<?php
$r = preg_match("{foo}", "foo", $matches, PREG_OFFSET_CAPTURE);',
'assertions' => [
'$r===' => '0|1|false',
'$matches===' => 'array<array-key, array{string, int}>',
],
],
'PHP72-pregMatchWithFlagUnmatchedAsNull' => [
weirdan marked this conversation as resolved.
Show resolved Hide resolved
'<?php
$r = preg_match("{foo}", "foo", $matches, PREG_UNMATCHED_AS_NULL);',
'assertions' => [
'$r===' => '0|1|false',
'$matches===' => 'array<array-key, null|string>',
],
],
'PHP72-pregMatchWithFlagOffsetCaptureAndUnmatchedAsNull' => [
'<?php
$r = preg_match("{foo}", "foo", $matches, PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL);',
'assertions' => [
'$r===' => '0|1|false',
'$matches===' => 'array<array-key, array{null|string, int}>',
],
],
'pregReplaceCallback' => [
'<?php
function foo(string $s) : string {
Expand Down Expand Up @@ -1449,7 +1492,10 @@ function test() : void {
],
'writeArgsAllowed' => [
'<?php
/** @return false|int */
/**
* @param 0|256|512|768 $flags
* @return false|int
*/
function safeMatch(string $pattern, string $subject, ?array $matches = null, int $flags = 0) {
return \preg_match($pattern, $subject, $matches, $flags);
}
Expand Down
14 changes: 9 additions & 5 deletions tests/Traits/ValidCodeAnalysisTestTrait.php
Expand Up @@ -35,14 +35,18 @@ public function testValidCode(
string $php_version = '7.3'
): void {
$test_name = $this->getTestName();
if (strpos($test_name, 'PHP73-') !== false) {
if (version_compare(PHP_VERSION, '7.3.0', '<')) {
$this->markTestSkipped('Test case requires PHP 7.3.');
}
} elseif (strpos($test_name, 'PHP71-') !== false) {
if (strpos($test_name, 'PHP71-') !== false) {
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
$this->markTestSkipped('Test case requires PHP 7.1.');
}
} elseif (strpos($test_name, 'PHP72-') !== false) {
if (version_compare(PHP_VERSION, '7.2.0', '<')) {
$this->markTestSkipped('Test case requires PHP 7.2.');
}
} elseif (strpos($test_name, 'PHP73-') !== false) {
if (version_compare(PHP_VERSION, '7.3.0', '<')) {
$this->markTestSkipped('Test case requires PHP 7.3.');
}
} elseif (strpos($test_name, 'PHP80-') !== false) {
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
$this->markTestSkipped('Test case requires PHP 8.0.');
Expand Down