diff --git a/.github/workflows/macos-scan.yml b/.github/workflows/macos-scan.yml new file mode 100644 index 00000000000..c298a291362 --- /dev/null +++ b/.github/workflows/macos-scan.yml @@ -0,0 +1,29 @@ +name: Run Psalm (mac OS) + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + ini-values: zend.assertions=1 + tools: composer:v2 + coverage: none + env: + fail-fast: true + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + env: + COMPOSER_ROOT_VERSION: dev-master + + - name: Run Psalm + run: ./psalm --output-format=github diff --git a/.github/workflows/shepherd.yml b/.github/workflows/shepherd.yml index 3440783d936..d11fed5aaa1 100644 --- a/.github/workflows/shepherd.yml +++ b/.github/workflows/shepherd.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.4' ini-values: zend.assertions=1 tools: composer:v2 coverage: none @@ -26,4 +26,4 @@ jobs: COMPOSER_ROOT_VERSION: dev-master - name: Run Psalm - run: ./psalm --threads=2 --output-format=github --shepherd + run: ./psalm --output-format=github --shepherd diff --git a/composer.json b/composer.json index 6384250bc36..98e159def3e 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "~8.1.17 || ~8.2.4 || ~8.3.0", + "php": "~8.1.17 || ~8.2.4 || ~8.3.0 || ~8.4.0", "ext-SimpleXML": "*", "ext-ctype": "*", "ext-dom": "*", diff --git a/config.xsd b/config.xsd index 55aedabe061..b58a3bbb9d7 100644 --- a/config.xsd +++ b/config.xsd @@ -315,6 +315,7 @@ + diff --git a/dictionaries/CallMap.php b/dictionaries/CallMap.php index 80de3809a85..48e23a2c603 100644 --- a/dictionaries/CallMap.php +++ b/dictionaries/CallMap.php @@ -1142,17 +1142,17 @@ 'crash' => [''], 'crc32' => ['int', 'string'=>'string'], 'crypt' => ['string', 'string'=>'string', 'salt'=>'string'], -'ctype_alnum' => ['bool', 'text'=>'string|int'], -'ctype_alpha' => ['bool', 'text'=>'string|int'], -'ctype_cntrl' => ['bool', 'text'=>'string|int'], -'ctype_digit' => ['bool', 'text'=>'string|int'], -'ctype_graph' => ['bool', 'text'=>'string|int'], -'ctype_lower' => ['bool', 'text'=>'string|int'], -'ctype_print' => ['bool', 'text'=>'string|int'], -'ctype_punct' => ['bool', 'text'=>'string|int'], -'ctype_space' => ['bool', 'text'=>'string|int'], -'ctype_upper' => ['bool', 'text'=>'string|int'], -'ctype_xdigit' => ['bool', 'text'=>'string|int'], +'ctype_alnum' => ['bool', 'text'=>'string'], +'ctype_alpha' => ['bool', 'text'=>'string'], +'ctype_cntrl' => ['bool', 'text'=>'string'], +'ctype_digit' => ['bool', 'text'=>'string'], +'ctype_graph' => ['bool', 'text'=>'string'], +'ctype_lower' => ['bool', 'text'=>'string'], +'ctype_print' => ['bool', 'text'=>'string'], +'ctype_punct' => ['bool', 'text'=>'string'], +'ctype_space' => ['bool', 'text'=>'string'], +'ctype_upper' => ['bool', 'text'=>'string'], +'ctype_xdigit' => ['bool', 'text'=>'string'], 'cubrid_affected_rows' => ['int', 'req_identifier='=>''], 'cubrid_bind' => ['bool', 'req_identifier'=>'resource', 'bind_param'=>'int', 'bind_value'=>'mixed', 'bind_value_type='=>'string'], 'cubrid_client_encoding' => ['string', 'conn_identifier='=>''], @@ -1293,7 +1293,7 @@ 'CURLFile::setMimeType' => ['void', 'mime_type'=>'string'], 'CURLFile::setPostFilename' => ['void', 'posted_filename'=>'string'], 'CURLStringFile::__construct' => ['void', 'data'=>'string', 'postname'=>'string', 'mime='=>'string'], -'current' => ['mixed|false', 'array'=>'array|object'], +'current' => ['mixed|false', 'array'=>'array'], 'cyrus_authenticate' => ['void', 'connection'=>'resource', 'mechlist='=>'string', 'service='=>'string', 'user='=>'string', 'minssf='=>'int', 'maxssf='=>'int', 'authname='=>'string', 'password='=>'string'], 'cyrus_bind' => ['bool', 'connection'=>'resource', 'callbacks'=>'array'], 'cyrus_close' => ['bool', 'connection'=>'resource'], @@ -3269,7 +3269,7 @@ 'get_call_stack' => [''], 'get_called_class' => ['class-string'], 'get_cfg_var' => ['string|false', 'option'=>'string'], -'get_class' => ['class-string', 'object='=>'object'], +'get_class' => ['class-string', 'object'=>'object'], 'get_class_methods' => ['list', 'object_or_class'=>'object|class-string'], 'get_class_vars' => ['array', 'class'=>'string'], 'get_current_user' => ['string'], @@ -3290,7 +3290,7 @@ 'get_magic_quotes_runtime' => ['int|false'], 'get_meta_tags' => ['array', 'filename'=>'string', 'use_include_path='=>'bool'], 'get_object_vars' => ['array', 'object'=>'object'], -'get_parent_class' => ['class-string|false', 'object_or_class='=>'object|class-string'], +'get_parent_class' => ['class-string|false', 'object_or_class'=>'object|class-string'], 'get_required_files' => ['list'], 'get_resource_id' => ['int', 'resource'=>'resource'], 'get_resource_type' => ['string', 'resource'=>'resource'], @@ -3307,7 +3307,7 @@ 'getimagesize' => ['array{0:int, 1: int, 2: int, 3: string, mime: string, channels?: 3|4, bits?: int}|false', 'filename'=>'string', '&w_image_info='=>'array'], 'getimagesizefromstring' => ['array{0:int, 1: int, 2: int, 3: string, mime: string, channels?: 3|4, bits?: int}|false', 'string'=>'string', '&w_image_info='=>'array'], 'getlastmod' => ['int|false'], -'getmxrr' => ['bool', 'hostname'=>'string', '&w_hosts'=>'array', '&w_weights='=>'array'], +'getmxrr' => ['bool', 'hostname'=>'string', '&w_hosts'=>'array', '&w_weights='=>'array'], 'getmygid' => ['int|false'], 'getmyinode' => ['int|false'], 'getmypid' => ['int|false'], @@ -6198,7 +6198,7 @@ 'kadm5_get_principals' => ['array', 'handle'=>'resource'], 'kadm5_init_with_password' => ['resource', 'admin_server'=>'string', 'realm'=>'string', 'principal'=>'string', 'password'=>'string'], 'kadm5_modify_principal' => ['bool', 'handle'=>'resource', 'principal'=>'string', 'options'=>'array'], -'key' => ['int|string|null', 'array'=>'array|object'], +'key' => ['int|string|null', 'array'=>'array'], 'key_exists' => ['bool', 'key'=>'string|int', 'array'=>'array'], 'krsort' => ['true', '&rw_array'=>'array', 'flags='=>'int'], 'ksort' => ['true', '&rw_array'=>'array', 'flags='=>'int'], @@ -6446,7 +6446,7 @@ 'litespeed_request_headers' => ['array'], 'litespeed_response_headers' => ['array'], 'Locale::acceptFromHttp' => ['string|false', 'header'=>'string'], -'Locale::canonicalize' => ['string', 'locale'=>'string'], +'Locale::canonicalize' => ['?string', 'locale'=>'string'], 'Locale::composeLocale' => ['string', 'subtags'=>'array'], 'Locale::filterMatches' => ['?bool', 'languageTag'=>'string', 'locale'=>'string', 'canonicalize='=>'bool'], 'Locale::getAllVariants' => ['array', 'locale'=>'string'], @@ -6598,7 +6598,7 @@ 'mapObj::zoomScale' => ['int', 'nScaleDenom'=>'float', 'oPixelPos'=>'pointObj', 'nImageWidth'=>'int', 'nImageHeight'=>'int', 'oGeorefExt'=>'rectObj', 'oMaxGeorefExt'=>'rectObj'], 'max' => ['mixed', 'value'=>'non-empty-array'], 'max\'1' => ['mixed', 'value'=>'', 'values'=>'', '...args='=>''], -'mb_check_encoding' => ['bool', 'value='=>'array|string|null', 'encoding='=>'string|null'], +'mb_check_encoding' => ['bool', 'value'=>'array|string', 'encoding='=>'string|null'], 'mb_chr' => ['non-empty-string|false', 'codepoint'=>'int', 'encoding='=>'string|null'], 'mb_convert_case' => ['string', 'string'=>'string', 'mode'=>'int', 'encoding='=>'string|null'], 'mb_convert_encoding' => ['string|false', 'string'=>'string', 'to_encoding'=>'string', 'from_encoding='=>'array|string|null'], @@ -7906,9 +7906,9 @@ 'mysqli_fetch_array\'2' => ['list|false|null', 'result'=>'mysqli_result', 'mode='=>'2'], 'mysqli_fetch_assoc' => ['array|false|null', 'result'=>'mysqli_result'], 'mysqli_fetch_column' => ['null|int|float|string|false', 'result'=>'mysqli_result', 'column='=>'int'], -'mysqli_fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result'], -'mysqli_fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result', 'index'=>'int'], -'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], +'mysqli_fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], +'mysqli_fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], +'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], 'mysqli_fetch_lengths' => ['array|false', 'result'=>'mysqli_result'], 'mysqli_fetch_object' => ['object|false|null', 'result'=>'mysqli_result', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_fetch_row' => ['list|false|null', 'result'=>'mysqli_result'], @@ -7920,7 +7920,7 @@ 'mysqli_get_charset' => ['?object', 'mysql'=>'mysqli'], 'mysqli_get_client_info' => ['string', 'mysql='=>'?mysqli'], 'mysqli_get_client_stats' => ['array'], -'mysqli_get_client_version' => ['int', 'link'=>'mysqli'], +'mysqli_get_client_version' => ['int'], 'mysqli_get_connection_stats' => ['array', 'mysql'=>'mysqli'], 'mysqli_get_host_info' => ['string', 'mysql'=>'mysqli'], 'mysqli_get_links_stats' => ['array'], @@ -7962,9 +7962,9 @@ 'mysqli_result::fetch_array\'2' => ['list|false|null', 'mode='=>'2'], 'mysqli_result::fetch_assoc' => ['array|false|null'], 'mysqli_result::fetch_column' => ['null|int|float|string|false', 'column='=>'int'], -'mysqli_result::fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false'], -'mysqli_result::fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'index'=>'int'], -'mysqli_result::fetch_fields' => ['list'], +'mysqli_result::fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], +'mysqli_result::fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], +'mysqli_result::fetch_fields' => ['list'], 'mysqli_result::fetch_object' => ['object|false|null', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_result::fetch_row' => ['list|false|null'], 'mysqli_result::field_seek' => ['true', 'index'=>'int'], @@ -8149,7 +8149,7 @@ 'newrelic_set_appname' => ['bool', 'name'=>'string', 'license='=>'string', 'xmit='=>'bool'], 'newrelic_set_user_attributes' => ['bool', 'user'=>'string', 'account'=>'string', 'product'=>'string'], 'newrelic_start_transaction' => ['bool', 'appname'=>'string', 'license='=>'string'], -'next' => ['mixed', '&r_array'=>'array|object'], +'next' => ['mixed', '&r_array'=>'array'], 'ngettext' => ['string', 'singular'=>'string', 'plural'=>'string', 'count'=>'int'], 'nl2br' => ['string', 'string'=>'string', 'use_xhtml='=>'bool'], 'nl_langinfo' => ['string|false', 'item'=>'int'], @@ -9418,9 +9418,9 @@ 'preg_filter' => ['string|string[]|null', 'pattern'=>'string|string[]', 'replacement'=>'string|string[]', 'subject'=>'string|string[]', '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\'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_match' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'], +'preg_match\'1' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], +'preg_match_all' => ['int<0,max>|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], 'preg_quote' => ['string', 'str'=>'string', 'delimiter='=>'?string'], 'preg_replace' => ['string|string[]|null', 'pattern'=>'string|array', 'replacement'=>'string|array', 'subject'=>'string|array', 'limit='=>'int', '&w_count='=>'int'], 'preg_replace_callback' => ['string|null', 'pattern'=>'string|array', 'callback'=>'callable(string[]):string', 'subject'=>'string', 'limit='=>'int', '&w_count='=>'int', 'flags='=>'int'], @@ -9429,7 +9429,7 @@ 'preg_replace_callback_array\'1' => ['string[]|null', 'pattern'=>'array', 'subject'=>'string[]', 'limit='=>'int', '&w_count='=>'int', 'flags='=>'int'], 'preg_split' => ['list|false', 'pattern'=>'string', 'subject'=>'string', 'limit'=>'int', 'flags='=>'null'], 'preg_split\'1' => ['list|list>|false', 'pattern'=>'string', 'subject'=>'string', 'limit='=>'int', 'flags='=>'int'], -'prev' => ['mixed', '&r_array'=>'array|object'], +'prev' => ['mixed', '&r_array'=>'array'], 'print' => ['int', 'arg'=>'string'], 'print_r' => ['string', 'value'=>'mixed'], 'print_r\'1' => ['true', 'value'=>'mixed', 'return='=>'bool'], @@ -10630,7 +10630,7 @@ 'ReflectionParameter::getDeclaringFunction' => ['ReflectionFunctionAbstract'], 'ReflectionParameter::getDefaultValue' => ['mixed'], 'ReflectionParameter::getDefaultValueConstantName' => ['?string'], -'ReflectionParameter::getName' => ['string'], +'ReflectionParameter::getName' => ['non-empty-string'], 'ReflectionParameter::getPosition' => ['int<0, max>'], 'ReflectionParameter::getType' => ['?ReflectionType'], 'ReflectionParameter::hasType' => ['bool'], @@ -10699,7 +10699,7 @@ 'register_tick_function' => ['bool', 'callback'=>'callable():void', '...args='=>'mixed'], 'rename' => ['bool', 'from'=>'string', 'to'=>'string', 'context='=>'resource'], 'rename_function' => ['bool', 'original_name'=>'string', 'new_name'=>'string'], -'reset' => ['mixed|false', '&r_array'=>'array|object'], +'reset' => ['mixed|false', '&r_array'=>'array'], 'ResourceBundle::__construct' => ['void', 'locale'=>'?string', 'bundle'=>'?string', 'fallback='=>'bool'], 'ResourceBundle::count' => ['int'], 'ResourceBundle::create' => ['?ResourceBundle', 'locale'=>'?string', 'bundle'=>'?string', 'fallback='=>'bool'], @@ -11100,7 +11100,7 @@ 'session_destroy' => ['bool'], 'session_encode' => ['string|false'], 'session_gc' => ['int|false'], -'session_get_cookie_params' => ['array'], +'session_get_cookie_params' => ['array{lifetime:?int,path:?string,domain:?string,secure:?bool,httponly:?bool,samesite:?string}'], 'session_id' => ['string|false', 'id='=>'?string'], 'session_is_registered' => ['bool', 'name'=>'string'], 'session_module_name' => ['string|false', 'module='=>'?string'], @@ -12825,9 +12825,9 @@ 'str_split' => ['list', 'string'=>'string', 'length='=>'positive-int'], 'str_starts_with' => ['bool', 'haystack'=>'string', 'needle'=>'string'], 'str_word_count' => ['array|int', 'string'=>'string', 'format='=>'int', 'characters='=>'?string'], -'strcasecmp' => ['int', 'string1'=>'string', 'string2'=>'string'], +'strcasecmp' => ['int<-1,1>', 'string1'=>'string', 'string2'=>'string'], 'strchr' => ['string|false', 'haystack'=>'string', 'needle'=>'string', 'before_needle='=>'bool'], -'strcmp' => ['int', 'string1'=>'string', 'string2'=>'string'], +'strcmp' => ['int<-1,1>', 'string1'=>'string', 'string2'=>'string'], 'strcoll' => ['int', 'string1'=>'string', 'string2'=>'string'], 'strcspn' => ['int', 'string'=>'string', 'characters'=>'string', 'offset='=>'int', 'length='=>'?int'], 'stream_bucket_append' => ['void', 'brigade'=>'resource', 'bucket'=>'object'], @@ -12910,10 +12910,10 @@ 'stripslashes' => ['string', 'string'=>'string'], 'stristr' => ['string|false', 'haystack'=>'string', 'needle'=>'string', 'before_needle='=>'bool'], 'strlen' => ['0|positive-int', 'string'=>'string'], -'strnatcasecmp' => ['int', 'string1'=>'string', 'string2'=>'string'], -'strnatcmp' => ['int', 'string1'=>'string', 'string2'=>'string'], -'strncasecmp' => ['int', 'string1'=>'string', 'string2'=>'string', 'length'=>'int'], -'strncmp' => ['int', 'string1'=>'string', 'string2'=>'string', 'length'=>'int'], +'strnatcasecmp' => ['int<-1,1>', 'string1'=>'string', 'string2'=>'string'], +'strnatcmp' => ['int<-1,1>', 'string1'=>'string', 'string2'=>'string'], +'strncasecmp' => ['int<-1,1>', 'string1'=>'string', 'string2'=>'string', 'length'=>'positive-int|0'], +'strncmp' => ['int<-1,1>', 'string1'=>'string', 'string2'=>'string', 'length'=>'positive-int|0'], 'strpbrk' => ['string|false', 'string'=>'string', 'characters'=>'string'], 'strpos' => ['int|false', 'haystack'=>'string', 'needle'=>'string', 'offset='=>'int'], 'strptime' => ['array|false', 'timestamp'=>'string', 'format'=>'string'], diff --git a/dictionaries/CallMap_73_delta.php b/dictionaries/CallMap_73_delta.php index 523bd28444c..c1b89d04b42 100644 --- a/dictionaries/CallMap_73_delta.php +++ b/dictionaries/CallMap_73_delta.php @@ -120,6 +120,10 @@ 'old' => ['bool', 'directory'=>'string', 'permissions='=>'int', 'recursive='=>'bool', 'context='=>'resource'], 'new' => ['bool', 'directory'=>'string', 'permissions='=>'int', 'recursive='=>'bool', 'context='=>'null|resource'], ], + 'session_get_cookie_params' => [ + 'old' => ['array{lifetime:?int,path:?string,domain:?string,secure:?bool,httponly:?bool}'], + 'new' => ['array{lifetime:?int,path:?string,domain:?string,secure:?bool,httponly:?bool,samesite:?string}'], + ] ], 'removed' => [ ], diff --git a/dictionaries/CallMap_81_delta.php b/dictionaries/CallMap_81_delta.php index bfb2da40bb6..176978a46b2 100644 --- a/dictionaries/CallMap_81_delta.php +++ b/dictionaries/CallMap_81_delta.php @@ -727,6 +727,30 @@ 'old' => ['bool', 'statement' => 'mysqli_stmt'], 'new' => ['bool', 'statement' => 'mysqli_stmt', 'params=' => 'list|null'], ], + 'mysqli_fetch_field' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], + ], + 'mysqli_fetch_field_direct' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], + ], + 'mysqli_fetch_fields' => [ + 'old' => ['list', 'result'=>'mysqli_result'], + 'new' => ['list', 'result'=>'mysqli_result'], + ], + 'mysqli_result::fetch_field' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], + ], + 'mysqli_result::fetch_field_direct' => [ + 'old' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], + 'new' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:0,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], + ], + 'mysqli_result::fetch_fields' => [ + 'old' => ['list'], + 'new' => ['list'], + ], 'mysqli_stmt_execute' => [ 'old' => ['bool', 'statement' => 'mysqli_stmt'], 'new' => ['bool', 'statement' => 'mysqli_stmt', 'params=' => 'list|null'], @@ -1211,6 +1235,74 @@ 'old' => ['int|false', '&rw_read'=>'?resource[]', '&rw_write'=>'?resource[]', '&rw_except'=>'?resource[]', 'seconds'=>'?int', 'microseconds='=>'int'], 'new' => ['int|false', '&rw_read'=>'?resource[]', '&rw_write'=>'?resource[]', '&rw_except'=>'?resource[]', 'seconds'=>'?int', 'microseconds='=>'?int'], ], + 'mb_check_encoding' => [ + 'old' => ['bool', 'value='=>'array|string|null', 'encoding='=>'string|null'], + 'new' => ['bool', 'value'=>'array|string', 'encoding='=>'string|null'], + ], + 'ctype_alnum' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_alpha' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_cntrl' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_digit' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_graph' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_lower' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_print' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_punct' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_space' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_upper' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'ctype_xdigit' => [ + 'old' => ['bool', 'text'=>'string|int'], + 'new' => ['bool', 'text'=>'string'], + ], + 'key' => [ + 'old' => ['int|string|null', 'array'=>'array|object'], + 'new' => ['int|string|null', 'array'=>'array'], + ], + 'current' => [ + 'old' => ['mixed|false', 'array'=>'array|object'], + 'new' => ['mixed|false', 'array'=>'array'], + ], + 'next' => [ + 'old' => ['mixed', '&r_array'=>'array|object'], + 'new' => ['mixed', '&r_array'=>'array'], + ], + 'prev' => [ + 'old' => ['mixed', '&r_array'=>'array|object'], + 'new' => ['mixed', '&r_array'=>'array'], + ], + 'reset' => [ + 'old' => ['mixed|false', '&r_array'=>'array|object'], + 'new' => ['mixed|false', '&r_array'=>'array'], + ], ], 'removed' => [ diff --git a/dictionaries/CallMap_82_delta.php b/dictionaries/CallMap_82_delta.php index 3064f54ff52..38dad00278b 100644 --- a/dictionaries/CallMap_82_delta.php +++ b/dictionaries/CallMap_82_delta.php @@ -57,6 +57,30 @@ 'old' => ['array|string|int|false', 'type='=>'string'], 'new' => ['array|string|int|false|null', 'type='=>'string'], ], + 'strcmp' => [ + 'old' => ['int', 'string1' => 'string', 'string2' => 'string'], + 'new' => ['int<-1,1>', 'string1' => 'string', 'string2' => 'string'], + ], + 'strcasecmp' => [ + 'old' => ['int', 'string1' => 'string', 'string2' => 'string'], + 'new' => ['int<-1,1>', 'string1' => 'string', 'string2' => 'string'], + ], + 'strnatcasecmp' => [ + 'old' => ['int', 'string1' => 'string', 'string2' => 'string'], + 'new' => ['int<-1,1>', 'string1' => 'string', 'string2' => 'string'], + ], + 'strnatcmp' => [ + 'old' => ['int', 'string1' => 'string', 'string2' => 'string'], + 'new' => ['int<-1,1>', 'string1' => 'string', 'string2' => 'string'], + ], + 'strncmp' => [ + 'old' => ['int', 'string1'=>'string', 'string2'=>'string', 'length'=>'int'], + 'new' => ['int<-1,1>', 'string1' => 'string', 'string2' => 'string', 'length'=>'positive-int|0'], + ], + 'strncasecmp' => [ + 'old' => ['int', 'string1'=>'string', 'string2'=>'string', 'length'=>'int'], + 'new' => ['int<-1,1>', 'string1' => 'string', 'string2' => 'string', 'length'=>'positive-int|0'], + ], ], 'removed' => [ diff --git a/dictionaries/CallMap_83_delta.php b/dictionaries/CallMap_83_delta.php index 8a4a76077b8..005017e4dfe 100644 --- a/dictionaries/CallMap_83_delta.php +++ b/dictionaries/CallMap_83_delta.php @@ -117,6 +117,14 @@ 'old' => ['string|false', 'haystack'=>'string', 'needle'=>'string'], 'new' => ['string|false', 'haystack'=>'string', 'needle'=>'string', 'before_needle='=>'bool'], ], + 'get_class' => [ + 'old' => ['class-string', 'object='=>'object'], + 'new' => ['class-string', 'object'=>'object'], + ], + 'get_parent_class' => [ + 'old' => ['class-string|false', 'object_or_class='=>'object|class-string'], + 'new' => ['class-string|false', 'object_or_class'=>'object|class-string'], + ], ], 'removed' => [ diff --git a/dictionaries/CallMap_historical.php b/dictionaries/CallMap_historical.php index 92746fd697b..aa56a806272 100644 --- a/dictionaries/CallMap_historical.php +++ b/dictionaries/CallMap_historical.php @@ -3393,7 +3393,7 @@ 'LimitIterator::seek' => ['int', 'offset'=>'int'], 'LimitIterator::valid' => ['bool'], 'Locale::acceptFromHttp' => ['string|false', 'header'=>'string'], - 'Locale::canonicalize' => ['string', 'locale'=>'string'], + 'Locale::canonicalize' => ['?string', 'locale'=>'string'], 'Locale::composeLocale' => ['string', 'subtags'=>'array'], 'Locale::filterMatches' => ['?bool', 'languageTag'=>'string', 'locale'=>'string', 'canonicalize='=>'bool'], 'Locale::getAllVariants' => ['array', 'locale'=>'string'], @@ -5968,7 +5968,7 @@ 'ReflectionParameter::getDeclaringFunction' => ['ReflectionFunctionAbstract'], 'ReflectionParameter::getDefaultValue' => ['mixed'], 'ReflectionParameter::getDefaultValueConstantName' => ['?string'], - 'ReflectionParameter::getName' => ['string'], + 'ReflectionParameter::getName' => ['non-empty-string'], 'ReflectionParameter::getPosition' => ['int<0, max>'], 'ReflectionParameter::getType' => ['?ReflectionType'], 'ReflectionParameter::hasType' => ['bool'], @@ -10671,7 +10671,7 @@ 'getimagesize' => ['array{0:int, 1: int, 2: int, 3: string, mime: string, channels?: 3|4, bits?: int}|false', 'filename'=>'string', '&w_image_info='=>'array'], 'getimagesizefromstring' => ['array{0:int, 1: int, 2: int, 3: string, mime: string, channels?: 3|4, bits?: int}|false', 'string'=>'string', '&w_image_info='=>'array'], 'getlastmod' => ['int|false'], - 'getmxrr' => ['bool', 'hostname'=>'string', '&w_hosts'=>'array', '&w_weights='=>'array'], + 'getmxrr' => ['bool', 'hostname'=>'string', '&w_hosts'=>'array', '&w_weights='=>'array'], 'getmygid' => ['int|false'], 'getmyinode' => ['int|false'], 'getmypid' => ['int|false'], @@ -12728,9 +12728,9 @@ 'mysqli_fetch_array\'1' => ['array|false|null', 'result'=>'mysqli_result', 'mode='=>'1'], 'mysqli_fetch_array\'2' => ['list|false|null', 'result'=>'mysqli_result', 'mode='=>'2'], 'mysqli_fetch_assoc' => ['array|false|null', 'result'=>'mysqli_result'], - 'mysqli_fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result'], - 'mysqli_fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'result'=>'mysqli_result', 'index'=>'int'], - 'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], + 'mysqli_fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result'], + 'mysqli_fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'result'=>'mysqli_result', 'index'=>'int'], + 'mysqli_fetch_fields' => ['list', 'result'=>'mysqli_result'], 'mysqli_fetch_lengths' => ['array|false', 'result'=>'mysqli_result'], 'mysqli_fetch_object' => ['object|false|null', 'result'=>'mysqli_result', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_fetch_row' => ['list|false|null', 'result'=>'mysqli_result'], @@ -12742,7 +12742,7 @@ 'mysqli_get_charset' => ['?object', 'mysql'=>'mysqli'], 'mysqli_get_client_info' => ['string', 'mysql='=>'?mysqli'], 'mysqli_get_client_stats' => ['array'], - 'mysqli_get_client_version' => ['int', 'link'=>'mysqli'], + 'mysqli_get_client_version' => ['int'], 'mysqli_get_connection_stats' => ['array', 'mysql'=>'mysqli'], 'mysqli_get_host_info' => ['string', 'mysql'=>'mysqli'], 'mysqli_get_links_stats' => ['array'], @@ -12783,9 +12783,9 @@ 'mysqli_result::fetch_array\'1' => ['array|false|null', 'mode='=>'1'], 'mysqli_result::fetch_array\'2' => ['list|false|null', 'mode='=>'2'], 'mysqli_result::fetch_assoc' => ['array|false|null'], - 'mysqli_result::fetch_field' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false'], - 'mysqli_result::fetch_field_direct' => ['object{name:non-empty-string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:string,catalog:string}|false', 'index'=>'int'], - 'mysqli_result::fetch_fields' => ['list'], + 'mysqli_result::fetch_field' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false'], + 'mysqli_result::fetch_field_direct' => ['object{name:string,orgname:string,table:string,orgtable:string,max_length:int,length:int,charsetnr:int,flags:int,type:int,decimals:int,db:string,def:\'\',catalog:\'def\'}|false', 'index'=>'int'], + 'mysqli_result::fetch_fields' => ['list'], 'mysqli_result::fetch_object' => ['object|false|null', 'class='=>'string', 'constructor_args='=>'array'], 'mysqli_result::fetch_row' => ['list|false|null'], 'mysqli_result::field_seek' => ['bool', 'index'=>'int'], @@ -13499,9 +13499,9 @@ 'preg_filter' => ['string|string[]|null', 'pattern'=>'string|string[]', 'replacement'=>'string|string[]', 'subject'=>'string|string[]', '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\'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_match' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'string[]', 'flags='=>'0', 'offset='=>'int'], + 'preg_match\'1' => ['0|1|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], + 'preg_match_all' => ['int<0,max>|false', 'pattern'=>'string', 'subject'=>'string', '&w_matches='=>'array', 'flags='=>'int', 'offset='=>'int'], 'preg_quote' => ['string', 'str'=>'string', 'delimiter='=>'string'], 'preg_replace' => ['string|string[]|null', 'pattern'=>'string|array', 'replacement'=>'string|array', 'subject'=>'string|array', 'limit='=>'int', '&w_count='=>'int'], 'preg_replace_callback' => ['string|null', 'pattern'=>'string|array', 'callback'=>'callable(string[]):string', 'subject'=>'string', 'limit='=>'int', '&w_count='=>'int'], @@ -13840,7 +13840,7 @@ 'session_decode' => ['bool', 'data'=>'string'], 'session_destroy' => ['bool'], 'session_encode' => ['string|false'], - 'session_get_cookie_params' => ['array'], + 'session_get_cookie_params' => ['array{lifetime:?int,path:?string,domain:?string,secure:?bool,httponly:?bool}'], 'session_id' => ['string|false', 'id='=>'string'], 'session_is_registered' => ['bool', 'name'=>'string'], 'session_module_name' => ['string|false', 'module='=>'string'], diff --git a/dictionaries/ImpureFunctionsList.php b/dictionaries/ImpureFunctionsList.php index 25f94ef5298..cbd5625b6c8 100644 --- a/dictionaries/ImpureFunctionsList.php +++ b/dictionaries/ImpureFunctionsList.php @@ -254,4 +254,9 @@ 'openssl_pkcs12_export_to_file' => true, 'openssl_pkey_export_to_file' => true, 'openssl_x509_export_to_file' => true, + // xml + 'xml_parser_set_option' => true, + 'xml_parser_free' => true, + // mail + 'mail' => true, ]; diff --git a/docs/annotating_code/supported_annotations.md b/docs/annotating_code/supported_annotations.md index cfc53a2b68a..8ea1e83eace 100644 --- a/docs/annotating_code/supported_annotations.md +++ b/docs/annotating_code/supported_annotations.md @@ -252,9 +252,10 @@ $b = $a->bar(); // this call fails ### `@psalm-internal` -Used to mark a class, property or function as internal to a given namespace. Psalm treats this slightly differently to -the PHPDoc `@internal` tag. For `@internal`, an issue is raised if the calling code is in a namespace completely -unrelated to the namespace of the calling code, i.e. not sharing the first element of the namespace. +Used to mark a class, property or function as internal to a given namespace or class or even method. +Psalm treats this slightly differently to the PHPDoc `@internal` tag. For `@internal`, +an issue is raised if the calling code is in a namespace completely unrelated to the namespace of the calling code, +i.e. not sharing the first element of the namespace. In contrast for `@psalm-internal`, the docblock line must specify a namespace. An issue is raised if the calling code is not within the given namespace. @@ -272,7 +273,15 @@ namespace A\B { namespace A\B\C { class Bat { public function batBat(): void { - $a = new \A\B\Foo(); // this is fine + $a = new \A\B\Foo(); // this is fine + } + } +} + +namespace A { + class B { + public function batBat(): void { + $a = new \A\B\Foo(); // this is fine } } } @@ -280,7 +289,28 @@ namespace A\B\C { namespace A\C { class Bat { public function batBat(): void { - $a = new \A\B\Foo(); // error + $a = new \A\B\Foo(); // error + } + } +} + +namespace X { + class Foo { + /** + * @psalm-internal Y\Bat::batBat + */ + public static function barBar(): void { + } + } +} + +namespace Y { + class Bat { + public function batBat() : void { + \X\Foo::barBar(); // this is fine + } + public function fooFoo(): void { + \X\Foo::barBar(); // error } } } diff --git a/docs/annotating_code/type_syntax/top_bottom_types.md b/docs/annotating_code/type_syntax/top_bottom_types.md index ead9ffcfd4e..83a04e927c6 100644 --- a/docs/annotating_code/type_syntax/top_bottom_types.md +++ b/docs/annotating_code/type_syntax/top_bottom_types.md @@ -12,6 +12,6 @@ It can be aliased to `no-return` or `never-return` in docblocks. Note: it replac This is the _bottom type_ in PHP's type system. It's used to describe a type that has no possible value. It can happen in multiple cases: - the actual `never` type from PHP 8.1 (can be used in docblocks for older versions). This type can be used as a return type for functions that will never return, either because they always throw exceptions or always exit() -- an union type that have been stripped for all its possible types. (For example, if a variable is `string|int` and we perform a is_bool() check in a condition, the type of the variable in the condition will be `never` as the condition will never be entered) +- a union type that has been stripped of all its possible types. (For example, if a variable is `string|int` and we perform an is_bool() check in a condition, the type of the variable in the condition will be `never` as the condition will never be entered) - it can represent a placeholder for types yet to come — a good example is the type of the empty array `[]`, which Psalm types as `array`, the content of the array is void so it can accept any content - it can also happen in the same context as the line above for templates that have yet to be defined diff --git a/docs/running_psalm/configuration.md b/docs/running_psalm/configuration.md index dd2867176df..356dff1cf68 100644 --- a/docs/running_psalm/configuration.md +++ b/docs/running_psalm/configuration.md @@ -444,10 +444,10 @@ Allows you to hard-code the number of threads Psalm will use (similar to `--thre maxStringLength="1000" > ``` -This setting controls the maximum length of literal strings that will be transformed into a literal string type during Psalm analysis. -Strings longer than this value (by default 1000 bytes) will be transformed in a generic `non-empty-string` type, instead. +This setting controls the maximum length of literal strings that will be transformed into a literal string type during Psalm analysis. +Strings longer than this value (by default 1000 bytes) will be transformed in a generic `non-empty-string` type, instead. -Please note that changing this setting might introduce unwanted side effects and those side effects won't be considered as bugs. +Please note that changing this setting might introduce unwanted side effects and those side effects won't be considered as bugs. #### maxShapedArraySize ```xml @@ -455,10 +455,10 @@ Please note that changing this setting might introduce unwanted side effects and maxShapedArraySize="100" > ``` -This setting controls the maximum size of shaped arrays that will be transformed into a shaped `array{key1: "value", key2: T}` type during Psalm analysis. -Arrays bigger than this value (100 by default) will be transformed in a generic `non-empty-array` type, instead. +This setting controls the maximum size of shaped arrays that will be transformed into a shaped `array{key1: "value", key2: T}` type during Psalm analysis. +Arrays bigger than this value (100 by default) will be transformed in a generic `non-empty-array` type, instead. -Please note that changing this setting might introduce unwanted side effects and those side effects won't be considered as bugs. +Please note that changing this setting might introduce unwanted side effects and those side effects won't be considered as bugs. #### restrictReturnTypes @@ -474,20 +474,20 @@ the inferred return type. This code: ```php function getOne(): int // declared type: int -{ +{ return 1; // inferred type: 1 (int literal) } ``` Will give this error: `LessSpecificReturnType - The inferred return type '1' for -a is more specific than the declared return type 'int'` +getOne is more specific than the declared return type 'int'` To fix the error, you should specify the more specific type in the doc-block: ```php /** * @return 1 */ -function getOne(): int -{ +function getOne(): int +{ return 1; } ``` diff --git a/docs/running_psalm/error_levels.md b/docs/running_psalm/error_levels.md index 2392aeb2eb7..7e5fc58dfb4 100644 --- a/docs/running_psalm/error_levels.md +++ b/docs/running_psalm/error_levels.md @@ -234,6 +234,7 @@ Level 5 and above allows a more non-verifiable code, and higher levels are even - [InvalidDocblockParamName](issues/InvalidDocblockParamName.md) - [InvalidFalsableReturnType](issues/InvalidFalsableReturnType.md) - [InvalidStringClass](issues/InvalidStringClass.md) +- [MissingClassConstType](issues/MissingClassConstType.md) - [MissingClosureParamType](issues/MissingClosureParamType.md) - [MissingClosureReturnType](issues/MissingClosureReturnType.md) - [MissingConstructor](issues/MissingConstructor.md) diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md index 45e76af86c6..5a006d1a15f 100644 --- a/docs/running_psalm/issues.md +++ b/docs/running_psalm/issues.md @@ -114,6 +114,7 @@ - [MismatchingDocblockParamType](issues/MismatchingDocblockParamType.md) - [MismatchingDocblockPropertyType](issues/MismatchingDocblockPropertyType.md) - [MismatchingDocblockReturnType](issues/MismatchingDocblockReturnType.md) + - [MissingClassConstType](issues/MissingClassConstType.md) - [MissingClosureParamType](issues/MissingClosureParamType.md) - [MissingClosureReturnType](issues/MissingClosureReturnType.md) - [MissingConstructor](issues/MissingConstructor.md) diff --git a/docs/running_psalm/issues/ConstructorSignatureMismatch.md b/docs/running_psalm/issues/ConstructorSignatureMismatch.md index 767375e14fa..710e313ea01 100644 --- a/docs/running_psalm/issues/ConstructorSignatureMismatch.md +++ b/docs/running_psalm/issues/ConstructorSignatureMismatch.md @@ -9,7 +9,7 @@ Emitted when a constructor parameter differs from a parent constructor parameter * @psalm-consistent-constructor */ class A { - public function __construct(int $i) {} + public function __construct(int $s) {} } class B extends A { public function __construct(string $s) {} diff --git a/docs/running_psalm/issues/MissingClassConstType.md b/docs/running_psalm/issues/MissingClassConstType.md new file mode 100644 index 00000000000..c4fa9049fc6 --- /dev/null +++ b/docs/running_psalm/issues/MissingClassConstType.md @@ -0,0 +1,21 @@ +# MissingClassConstType + +Emitted when a class constant doesn't have a declared type. + +```php + diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 8cfaaeacc89..800e7131982 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + tags['variablesfrom'][0]]]> @@ -460,8 +460,6 @@ - - @@ -543,7 +541,6 @@ self]]> - @@ -713,6 +710,7 @@ template_extended_params]]> template_types]]> + overridden_method_ids[$method_name])]]> @@ -937,9 +935,6 @@ - - - @@ -1190,6 +1185,7 @@ + overridden_method_ids[$method_name])]]> @@ -1210,9 +1206,6 @@ - - methods[$declaring_method_name]->stubbed]]> - @@ -1238,9 +1231,6 @@ - - methods[$implementing_method_id->method_name]->abstract]]> - @@ -1312,6 +1302,18 @@ + + + + + + + + + + + + readEnv['CI_PR_NUMBER']]]> @@ -1462,6 +1464,9 @@ + + + newModifier]]> @@ -1481,6 +1486,9 @@ line_number]]> type_end]]> type_start]]> + + + @@ -1514,6 +1522,9 @@ + + + @@ -1620,6 +1631,13 @@ cache->getFileMapCache()]]> + + + + + + + @@ -1714,6 +1732,7 @@ + @@ -1865,6 +1884,7 @@ template_extended_params]]> + offset_param_name])]]> @@ -1878,6 +1898,7 @@ template_extended_params[$container_class])]]> template_extended_params[$base_type->as_type->value])]]> template_extended_params[$base_type->value])]]> + lower_bounds[$atomic_type->offset_param_name])]]> @@ -2308,6 +2329,11 @@ + + + + + diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php index bc31ac99c01..288de79102a 100644 --- a/src/Psalm/Codebase.php +++ b/src/Psalm/Codebase.php @@ -809,7 +809,7 @@ public function getMethodReturnsByRef(string|MethodIdentifier $method_id): bool public function getMethodReturnTypeLocation( string|MethodIdentifier $method_id, - CodeLocation &$defined_location = null, + ?CodeLocation &$defined_location = null, ): ?CodeLocation { return $this->methods->getMethodReturnTypeLocation( MethodIdentifier::wrap($method_id), @@ -1343,7 +1343,7 @@ public function getFunctionArgumentAtPosition(string $file_path, Position $posit */ public function getSignatureInformation( string $function_symbol, - string $file_path = null, + ?string $file_path = null, ): ?SignatureInformation { $signature_label = ''; $signature_documentation = null; @@ -1568,7 +1568,7 @@ public function getCompletionItemsForClassishThing( string $type_string, string $gap, bool $snippets_supported = false, - array $allow_visibilities = null, + ?array $allow_visibilities = null, array $ignore_fq_class_names = [], ): array { if ($allow_visibilities === null) { @@ -1749,7 +1749,7 @@ public function filterCompletionItemsByBeginLiteralPart(array $items, string $li $res = []; foreach ($items as $item) { - if ($item->insertText && strpos($item->insertText, $literal_part) === 0) { + if ($item->insertText && str_starts_with($item->insertText, $literal_part)) { $res[] = $item; } } @@ -2052,8 +2052,21 @@ public function removeTemporaryFileChanges(string $file_path): void public function isTypeContainedByType( Union $input_type, Union $container_type, + bool $ignore_null = false, + bool $ignore_false = false, + bool $allow_interface_equality = false, + bool $allow_float_int_equality = true, ): bool { - return UnionTypeComparator::isContainedBy($this, $input_type, $container_type); + return UnionTypeComparator::isContainedBy( + $this, + $input_type, + $container_type, + $ignore_null, + $ignore_false, + null, + $allow_interface_equality, + $allow_float_int_equality, + ); } /** diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index d837c75bd4e..f5a325d1216 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -99,6 +99,7 @@ use function sha1; use function simplexml_import_dom; use function str_contains; +use function str_ends_with; use function str_replace; use function str_starts_with; use function strlen; @@ -131,8 +132,12 @@ */ final class Config { - private const DEFAULT_FILE_NAME = 'psalm.xml'; final public const DEFAULT_BASELINE_NAME = 'psalm-baseline.xml'; + private const DEFAULT_FILE_NAMES = [ + 'psalm.xml', + 'psalm.xml.dist', + 'psalm.dist.xml', + ]; final public const CONFIG_NAMESPACE = 'https://getpsalm.org/schema/config'; final public const REPORT_INFO = 'info'; final public const REPORT_ERROR = 'error'; @@ -620,10 +625,10 @@ public static function locateConfigFile(string $path): ?string } do { - $maybe_path = $dir_path . DIRECTORY_SEPARATOR . self::DEFAULT_FILE_NAME; - - if (file_exists($maybe_path) || file_exists($maybe_path .= '.dist')) { - return $maybe_path; + foreach (self::DEFAULT_FILE_NAMES as $defaultFileName) { + if (file_exists($maybe_path = $dir_path . DIRECTORY_SEPARATOR . $defaultFileName)) { + return $maybe_path; + } } $dir_path = dirname($dir_path); @@ -1163,13 +1168,13 @@ private static function fromXmlAndPaths( } if ($paths_to_check !== null) { - $paths_to_add_to_project_files = array(); + $paths_to_add_to_project_files = []; foreach ($paths_to_check as $path) { // if we have an .xml arg here, the files passed are invalid // valid cases (in which we don't want to add CLI passed files to projectFiles though) // are e.g. if running phpunit tests for psalm itself - if (substr($path, -4) === '.xml') { - $paths_to_add_to_project_files = array(); + if (str_ends_with($path, '.xml')) { + $paths_to_add_to_project_files = []; break; } @@ -1192,14 +1197,14 @@ private static function fromXmlAndPaths( $paths_to_add_to_project_files[] = $prospective_path; } - if ($paths_to_add_to_project_files !== array() && !isset($config_xml->projectFiles)) { + if ($paths_to_add_to_project_files !== [] && !isset($config_xml->projectFiles)) { if ($config_xml === null) { $config_xml = new SimpleXMLElement(''); } $config_xml->addChild('projectFiles'); } - if ($paths_to_add_to_project_files !== array() && isset($config_xml->projectFiles)) { + if ($paths_to_add_to_project_files !== [] && isset($config_xml->projectFiles)) { foreach ($paths_to_add_to_project_files as $path) { if (is_dir($path)) { $child = $config_xml->projectFiles->addChild('directory'); @@ -2198,7 +2203,7 @@ public function visitPreloadedStubFiles(Codebase $codebase, ?Progress $progress foreach ($stub_files as $file_path) { $file_path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file_path); // fix mangled phar paths on Windows - if (strpos($file_path, 'phar:\\\\') === 0) { + if (str_starts_with($file_path, 'phar:\\\\')) { $file_path = 'phar://'. substr($file_path, 7); } $codebase->scanner->addFileToDeepScan($file_path); @@ -2287,7 +2292,7 @@ public function visitStubFiles(Codebase $codebase, ?Progress $progress = null): foreach ($stub_files as $file_path) { $file_path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file_path); // fix mangled phar paths on Windows - if (strpos($file_path, 'phar:\\\\') === 0) { + if (str_starts_with($file_path, 'phar:\\\\')) { $file_path = 'phar://' . substr($file_path, 7); } $codebase->scanner->addFileToDeepScan($file_path); diff --git a/src/Psalm/DocComment.php b/src/Psalm/DocComment.php index 0a6b0cce668..5310e8b983f 100644 --- a/src/Psalm/DocComment.php +++ b/src/Psalm/DocComment.php @@ -42,13 +42,17 @@ final class DocComment /** * Parse a docblock comment into its parts. */ - public static function parsePreservingLength(Doc $docblock): ParsedDocblock + public static function parsePreservingLength(Doc $docblock, bool $no_psalm_error = false): ParsedDocblock { $parsed_docblock = DocblockParser::parse( $docblock->getText(), $docblock->getStartFilePos(), ); + if ($no_psalm_error) { + return $parsed_docblock; + } + foreach ($parsed_docblock->tags as $special_key => $_) { if (str_starts_with($special_key, 'psalm-')) { $special_key = substr($special_key, 6); diff --git a/src/Psalm/FileBasedPluginAdapter.php b/src/Psalm/FileBasedPluginAdapter.php index e6aef73fcc8..8a22a7863f1 100644 --- a/src/Psalm/FileBasedPluginAdapter.php +++ b/src/Psalm/FileBasedPluginAdapter.php @@ -27,7 +27,7 @@ final class FileBasedPluginAdapter implements PluginEntryPointInterface public function __construct( string $path, private readonly Config $config, - private Codebase $codebase, + private readonly Codebase $codebase, ) { if (!$path) { throw new UnexpectedValueException('$path cannot be empty'); diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 25021770c34..bb2edb6e1c8 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -834,7 +834,12 @@ public static function addContextProperties( $property_type = $property_storage->type; if (!$property_type->isMixed() - && !$property_storage->is_promoted + && (!$property_storage->is_promoted + || (strtolower($fq_class_name) !== strtolower($property_class_name) + && isset($storage->declaring_method_ids['__construct']) + && strtolower( + $storage->declaring_method_ids['__construct']->fq_class_name, + ) === strtolower($fq_class_name))) && !$property_storage->has_default && !($property_type->isNullable() && $property_type->from_docblock) ) { @@ -845,7 +850,13 @@ public static function addContextProperties( ]); } } else { - if (!$property_storage->has_default && !$property_storage->is_promoted) { + if (!$property_storage->has_default + && (!$property_storage->is_promoted + || (strtolower($fq_class_name) !== strtolower($property_class_name) + && isset($storage->declaring_method_ids['__construct']) + && strtolower( + $storage->declaring_method_ids['__construct']->fq_class_name, + ) === strtolower($fq_class_name)))) { $property_type = new Union([new TMixed()], [ 'initialized' => false, 'from_property' => true, @@ -1061,6 +1072,13 @@ private function checkPropertyInitialization( continue; } + if ($property->is_promoted + && strtolower($property_class_name) !== $fq_class_name_lc + && isset($storage->declaring_method_ids['__construct']) + && strtolower($storage->declaring_method_ids['__construct']->fq_class_name) === $fq_class_name_lc) { + $property_is_initialized = false; + } + if ($property->has_default || $property_is_initialized) { continue; } diff --git a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php index 758b44ef342..502b9504ec7 100644 --- a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php @@ -1593,7 +1593,7 @@ public function examineParamTypes( StatementsAnalyzer $statements_analyzer, Context $context, Codebase $codebase, - PhpParser\Node $stmt = null, + ?PhpParser\Node $stmt = null, ): void { $storage = $this->getFunctionLikeStorage($statements_analyzer); @@ -1993,6 +1993,8 @@ private function getFunctionInformation( && $codebase->config->ensure_override_attribute && $overridden_method_ids && $storage->cased_name !== '__construct' + && ($storage->cased_name !== '__toString' + || isset($appearing_class_storage->direct_class_interfaces['stringable'])) ) { IssueBuffer::maybeAdd( new MissingOverrideAttribute( diff --git a/src/Psalm/Internal/Analyzer/MethodComparator.php b/src/Psalm/Internal/Analyzer/MethodComparator.php index 913c31d5338..ff919b6c31e 100644 --- a/src/Psalm/Internal/Analyzer/MethodComparator.php +++ b/src/Psalm/Internal/Analyzer/MethodComparator.php @@ -42,7 +42,6 @@ use Psalm\Type\Union; use function array_filter; -use function count; use function in_array; use function str_starts_with; use function strtolower; @@ -506,15 +505,15 @@ private static function compareMethodParams( && $implementer_classlike_storage->user_defined && $implementer_param->location && $guide_method_storage->cased_name - && !str_starts_with($guide_method_storage->cased_name, '__') + && (!str_starts_with($guide_method_storage->cased_name, '__') + || ($guide_classlike_storage->preserve_constructor_signature + && $guide_method_storage->cased_name === '__construct')) && $config->isInProjectDirs( $implementer_param->location->file_path, ) ) { - if (!$guide_classlike_storage->user_defined && $i === 0 && count($guide_method_storage->params) < 2) { - // if it's third party defined and a single arg, renaming is unnecessary - // if we still want to psalter it, move this if and change the else below to elseif - } elseif ($config->allow_named_arg_calls + // even if it's just a single arg, it needs to be renamed in case it's called with a single named arg + if ($config->allow_named_arg_calls || ($guide_classlike_storage->location && !$config->isInProjectDirs($guide_classlike_storage->location->file_path) ) diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php index 8b02ae9a255..078c8403771 100644 --- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php @@ -109,7 +109,7 @@ final class ProjectAnalyzer /** * An object representing everything we know about the code */ - private Codebase $codebase; + private readonly Codebase $codebase; private readonly FileProvider $file_provider; @@ -320,21 +320,7 @@ public static function getFileReportOptions(array $report_file_paths, bool $show { $report_options = []; - $mapping = [ - 'checkstyle.xml' => Report::TYPE_CHECKSTYLE, - 'sonarqube.json' => Report::TYPE_SONARQUBE, - 'codeclimate.json' => Report::TYPE_CODECLIMATE, - 'summary.json' => Report::TYPE_JSON_SUMMARY, - 'junit.xml' => Report::TYPE_JUNIT, - '.xml' => Report::TYPE_XML, - '.json' => Report::TYPE_JSON, - '.txt' => Report::TYPE_TEXT, - '.emacs' => Report::TYPE_EMACS, - '.pylint' => Report::TYPE_PYLINT, - '.console' => Report::TYPE_CONSOLE, - '.sarif' => Report::TYPE_SARIF, - 'count.txt' => Report::TYPE_COUNT, - ]; + $mapping = Report::getMapping(); foreach ($report_file_paths as $report_file_path) { foreach ($mapping as $extension => $type) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Block/LoopAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Block/LoopAnalyzer.php index 60e3acaa964..84d2961e979 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Block/LoopAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Block/LoopAnalyzer.php @@ -46,7 +46,7 @@ public static function analyze( array $pre_conditions, array $post_expressions, LoopScope $loop_scope, - Context &$continue_context = null, + ?Context &$continue_context = null, bool $is_do = false, bool $always_enters_loop = false, ): ?bool { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php index 05543abbfad..d6bc276e025 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/ArrayAnalyzer.php @@ -45,10 +45,14 @@ use function array_merge; use function array_values; use function count; +use function filter_var; use function in_array; +use function is_int; +use function is_numeric; use function is_string; -use function preg_match; +use function trim; +use const FILTER_VALIDATE_INT; use const PHP_INT_MAX; /** @@ -238,6 +242,36 @@ public static function analyze( return true; } + /** + * @psalm-assert-if-false !numeric $literal_array_key + */ + public static function getLiteralArrayKeyInt( + string|int $literal_array_key, + ): false|int { + if (is_int($literal_array_key)) { + return $literal_array_key; + } + + if (!is_numeric($literal_array_key)) { + return false; + } + + // PHP 8 values with whitespace after number are counted as numeric + // and filter_var treats them as such too + // ensures that '15 ' will stay '15 ' + if (trim($literal_array_key) !== $literal_array_key) { + return false; + } + + // '+5' will pass the filter_var check but won't be changed in keys + if ($literal_array_key[0] === '+') { + return false; + } + + // e.g. 015 is numeric but won't be typecast as it's not a valid int + return filter_var($literal_array_key, FILTER_VALIDATE_INT); + } + private static function analyzeArrayItem( StatementsAnalyzer $statements_analyzer, Context $context, @@ -315,20 +349,17 @@ private static function analyzeArrayItem( } if ($item->key instanceof PhpParser\Node\Scalar\String_ - && preg_match('/^(0|[1-9][0-9]*)$/', $item->key->value) - && ( - (int) $item->key->value < PHP_INT_MAX || - $item->key->value === (string) PHP_INT_MAX - ) + && self::getLiteralArrayKeyInt($item->key->value) !== false ) { $key_type = Type::getInt(false, (int) $item->key->value); } if ($key_type->isSingleStringLiteral()) { $item_key_literal_type = $key_type->getSingleStringLiteral(); - $item_key_value = $item_key_literal_type->value; + $string_to_int = self::getLiteralArrayKeyInt($item_key_literal_type->value); + $item_key_value = $string_to_int === false ? $item_key_literal_type->value : $string_to_int; - if ($item_key_literal_type instanceof TLiteralClassString) { + if (is_string($item_key_value) && $item_key_literal_type instanceof TLiteralClassString) { $array_creation_info->class_strings[$item_key_value] = true; } } elseif ($key_type->isSingleIntLiteral()) { @@ -385,7 +416,6 @@ private static function analyzeArrayItem( $array_creation_info->array_keys[$item_key_value] = true; } - if (($data_flow_graph = $statements_analyzer->data_flow_graph) && ($data_flow_graph instanceof VariableUseGraph || !in_array('TaintedInput', $statements_analyzer->getSuppressedIssues())) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php index cad1f07833b..903b079280d 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php @@ -3726,7 +3726,7 @@ private static function getArrayKeyExistsAssertions( if (isset($expr->getArgs()[0]) && isset($expr->getArgs()[1]) && $first_var_type - && $first_var_name + && $first_var_name !== null && !$expr->getArgs()[0]->value instanceof PhpParser\Node\Expr\ClassConstFetch && $source instanceof StatementsAnalyzer && ($second_var_type = $source->node_data->getType($expr->getArgs()[1]->value)) @@ -3745,7 +3745,12 @@ private static function getArrayKeyExistsAssertions( if ($key_type->allStringLiterals() && !$key_type->possibly_undefined) { foreach ($key_type->getLiteralStrings() as $array_literal_type) { - $literal_assertions[] = new IsIdentical($array_literal_type); + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($array_literal_type->value); + if ($string_to_int === false) { + $literal_assertions[] = new IsIdentical($array_literal_type); + } else { + $literal_assertions[] = new IsLooselyEqual(new TLiteralInt($string_to_int)); + } } } elseif ($key_type->allIntLiterals() && !$key_type->possibly_undefined) { foreach ($key_type->getLiteralInts() as $array_literal_type) { @@ -3758,7 +3763,7 @@ private static function getArrayKeyExistsAssertions( } } - if ($literal_assertions && $first_var_name && $safe_to_track_literals) { + if ($literal_assertions && $first_var_name !== null && $safe_to_track_literals) { $if_types[$first_var_name] = [$literal_assertions]; } else { $array_root = isset($expr->getArgs()[1]->value) @@ -3774,8 +3779,11 @@ private static function getArrayKeyExistsAssertions( $first_arg = $expr->getArgs()[0]; if ($first_arg->value instanceof PhpParser\Node\Scalar\String_) { - $first_var_name = '\'' . $first_arg->value->value . '\''; - } elseif ($first_arg->value instanceof PhpParser\Node\Scalar\Int_) { + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($first_arg->value->value); + $first_var_name = $string_to_int === false + ? '\'' . $first_arg->value->value . '\'' + : (string) $string_to_int; + } elseif ($first_arg->value instanceof PhpParser\Node\Scalar\LNumber) { $first_var_name = (string)$first_arg->value->value; } } @@ -3792,7 +3800,12 @@ private static function getArrayKeyExistsAssertions( if ($const_type) { if ($const_type->isSingleStringLiteral()) { - $first_var_name = '\''.$const_type->getSingleStringLiteral()->value.'\''; + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt( + $const_type->getSingleStringLiteral()->value, + ); + $first_var_name = $string_to_int === false + ? '\'' . $const_type->getSingleStringLiteral()->value . '\'' + : (string) $string_to_int; } elseif ($const_type->isSingleIntLiteral()) { $first_var_name = (string)$const_type->getSingleIntLiteral()->value; } else { @@ -3809,7 +3822,11 @@ private static function getArrayKeyExistsAssertions( && ($first_var_type = $source->node_data->getType($expr->getArgs()[0]->value)) ) { foreach ($first_var_type->getLiteralStrings() as $array_literal_type) { - $if_types[$array_root . "['" . $array_literal_type->value . "']"] = [[new ArrayKeyExists()]]; + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($array_literal_type->value); + $literal_key = $string_to_int === false + ? "'" . $array_literal_type->value . "'" + : $string_to_int; + $if_types[$array_root . "[" . $literal_key . "]"] = [[new ArrayKeyExists()]]; } foreach ($first_var_type->getLiteralInts() as $array_literal_type) { $if_types[$array_root . "[" . $array_literal_type->value . "]"] = [[new ArrayKeyExists()]]; diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php index 91d28b02088..ec07e8c0715 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/ArrayAssignmentAnalyzer.php @@ -11,6 +11,7 @@ use Psalm\Codebase; use Psalm\Context; use Psalm\Internal\Analyzer\ClassLikeAnalyzer; +use Psalm\Internal\Analyzer\Statements\Expression\ArrayAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\ExpressionIdentifier; use Psalm\Internal\Analyzer\Statements\Expression\Fetch\ArrayFetchAnalyzer; use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer; @@ -48,7 +49,6 @@ use function implode; use function in_array; use function is_string; -use function preg_match; use function str_contains; use function strlen; @@ -1075,8 +1075,9 @@ private static function getArrayAssignmentOffsetType( $offset_type = $child_stmt_dim_type->getSingleStringLiteral(); } - if (preg_match('/^(0|[1-9][0-9]*)$/', $offset_type->value)) { - $var_id_addition = '[' . $offset_type->value . ']'; + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($offset_type->value); + if ($string_to_int !== false) { + $var_id_addition = '[' . $string_to_int . ']'; } else { $var_id_addition = '[\'' . $offset_type->value . '\']'; } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php index e96263fd83a..dcc21be9a17 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Assignment/InstancePropertyAssignmentAnalyzer.php @@ -1089,7 +1089,7 @@ private static function analyzeAtomicAssignment( * If we have an explicit list of all allowed magic properties on the class, and we're * not in that list, fall through */ - if (!$var_id || !$class_storage->hasSealedProperties($codebase->config)) { + if (!$class_storage->hasSealedProperties($codebase->config)) { if (!$context->collect_initializations && !$context->collect_mutations) { self::taintProperty( $statements_analyzer, diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php index 414a8773b5e..b0815c020a9 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssignmentAnalyzer.php @@ -1220,6 +1220,13 @@ private static function analyzeDestructuringAssignment( $offset_value = $assign_var_item->key->value; } + if ($offset_value !== null) { + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($offset_value); + if ($string_to_int !== false) { + $offset_value = $string_to_int; + } + } + $list_var_id = ExpressionIdentifier::getExtendedVarId( $var, $statements_analyzer->getFQCLN(), diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php index 04e1c9e2e48..10d14b45f18 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ArithmeticOpAnalyzer.php @@ -308,7 +308,7 @@ private static function analyzeOperands( bool &$has_valid_left_operand, bool &$has_valid_right_operand, bool &$has_string_increment, - Union &$result_type = null, + ?Union &$result_type = null, ): ?Union { if (($left_type_part instanceof TLiteralInt || $left_type_part instanceof TLiteralFloat) && ($right_type_part instanceof TLiteralInt || $right_type_part instanceof TLiteralFloat) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php index f06c4f8a1b6..84f75075ed7 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/BinaryOp/ConcatAnalyzer.php @@ -61,7 +61,7 @@ public static function analyze( PhpParser\Node\Expr $left, PhpParser\Node\Expr $right, Context $context, - Union &$result_type = null, + ?Union &$result_type = null, ): void { $codebase = $statements_analyzer->getCodebase(); diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php index 407304045c9..9efd019c84e 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentAnalyzer.php @@ -31,6 +31,7 @@ use Psalm\Internal\Type\TemplateStandinTypeReplacer; use Psalm\Internal\Type\TypeExpander; use Psalm\Issue\ArgumentTypeCoercion; +use Psalm\Issue\DeprecatedConstant; use Psalm\Issue\ImplicitToStringCast; use Psalm\Issue\InvalidArgument; use Psalm\Issue\InvalidLiteralArgument; @@ -40,6 +41,7 @@ use Psalm\Issue\NamedArgumentNotAllowed; use Psalm\Issue\NoValue; use Psalm\Issue\NullArgument; +use Psalm\Issue\ParentNotFound; use Psalm\Issue\PossiblyFalseArgument; use Psalm\Issue\PossiblyInvalidArgument; use Psalm\Issue\PossiblyNullArgument; @@ -60,7 +62,9 @@ use Psalm\Type\Atomic\TMixed; use Psalm\Type\Atomic\TNamedObject; use Psalm\Type\Union; +use UnexpectedValueException; +use function array_filter; use function count; use function explode; use function implode; @@ -881,6 +885,20 @@ public static function verifyType( true, $context->insideUse(), ); + + if (self::verifyCallableInContext( + $potential_method_id, + $cased_method_id, + $method_id, + $atomic_type, + $argument_offset, + $arg_location, + $context, + $codebase, + $statements_analyzer, + ) === false) { + continue; + } } $input_type->removeType($key); @@ -934,8 +952,25 @@ public static function verifyType( ) { $potential_method_ids = []; + $param_types_without_callable = array_filter( + $param_type->getAtomicTypes(), + static fn(Atomic $atomic) => !$atomic instanceof Atomic\TCallableInterface, + ); + $param_type_without_callable = [] !== $param_types_without_callable + ? new Union($param_types_without_callable) + : null; + foreach ($input_type->getAtomicTypes() as $input_type_part) { if ($input_type_part instanceof TKeyedArray) { + // If the param accept an array, we don't report arrays as wrong callbacks. + if (null !== $param_type_without_callable && UnionTypeComparator::isContainedBy( + $codebase, + $input_type, + $param_type_without_callable, + )) { + continue; + } + $potential_method_id = CallableTypeComparator::getCallableMethodIdFromTKeyedArray( $input_type_part, $codebase, @@ -943,18 +978,90 @@ public static function verifyType( $statements_analyzer->getFilePath(), ); + if ($potential_method_id === null && $codebase->analysis_php_version_id >= 8_02_00) { + [$lhs,] = $input_type_part->properties; + if ($lhs->isSingleStringLiteral() + && in_array( + strtolower($lhs->getSingleStringLiteral()->value), + ['self', 'parent', 'static'], + true, + )) { + IssueBuffer::maybeAdd( + new DeprecatedConstant( + 'Use of "' . $lhs->getSingleStringLiteral()->value . '" in callables is deprecated', + $arg_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + } + if ($potential_method_id && $potential_method_id !== 'not-callable') { + if (self::verifyCallableInContext( + $potential_method_id, + $cased_method_id, + $method_id, + $input_type_part, + $argument_offset, + $arg_location, + $context, + $codebase, + $statements_analyzer, + ) === false) { + continue; + } + $potential_method_ids[] = $potential_method_id; } } elseif ($input_type_part instanceof TLiteralString && strpos($input_type_part->value, '::') ) { + // If the param also accept a string, we don't report string as wrong callbacks. + if (null !== $param_type_without_callable && UnionTypeComparator::isContainedBy( + $codebase, + $input_type, + $param_type_without_callable, + )) { + continue; + } + $parts = explode('::', $input_type_part->value); /** @psalm-suppress PossiblyUndefinedIntArrayOffset */ - $potential_method_ids[] = new MethodIdentifier( + $potential_method_id = new MethodIdentifier( $parts[0], strtolower($parts[1]), ); + + if ($codebase->analysis_php_version_id >= 8_02_00 + && in_array( + strtolower($potential_method_id->fq_class_name), + ['self', 'parent', 'static'], + true, + )) { + IssueBuffer::maybeAdd( + new DeprecatedConstant( + 'Use of "' . $potential_method_id->fq_class_name . '" in callables is deprecated', + $arg_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } + + if (self::verifyCallableInContext( + $potential_method_id, + $cased_method_id, + $method_id, + $input_type_part, + $argument_offset, + $arg_location, + $context, + $codebase, + $statements_analyzer, + ) === false) { + continue; + } + + $potential_method_ids[] = $potential_method_id; } } @@ -1191,6 +1298,131 @@ public static function verifyType( return null; } + private static function verifyCallableInContext( + MethodIdentifier $potential_method_id, + ?string $cased_method_id, + ?MethodIdentifier $method_id, + Atomic $input_type_part, + int $argument_offset, + CodeLocation $arg_location, + Context $context, + Codebase $codebase, + StatementsAnalyzer $statements_analyzer, + ): ?bool { + $method_identifier = $cased_method_id !== null ? ' of ' . $cased_method_id : ''; + + if (!$method_id + || $potential_method_id->fq_class_name !== $context->self + || $method_id->fq_class_name !== $context->self) { + if ($input_type_part instanceof TKeyedArray) { + [$lhs,] = $input_type_part->properties; + } else { + $lhs = Type::getString($potential_method_id->fq_class_name); + } + + try { + $method_storage = $codebase->methods->getStorage($potential_method_id); + + $lhs_atomic = $lhs->getSingleAtomic(); + if ($lhs->isSingle() + && $lhs->hasNamedObjectType() + && ($lhs->isStaticObject() + || ($lhs_atomic instanceof TNamedObject + && !$lhs_atomic->definite_class + && $lhs_atomic->value === $context->self))) { + // callable $this + // some PHP-internal functions (e.g. array_filter) will call the callback within the current context + // unlike user-defined functions which call the callback in their context + // however this doesn't apply to all + // e.g. header_register_callback will not throw an error immediately like user-land functions + // however error log "Could not call the sapi_header_callback" if it's not public + // this is NOT a complete list, but just what was easily available and to be extended + $php_native_non_public_cb = [ + 'array_diff_uassoc', + 'array_diff_ukey', + 'array_filter', + 'array_intersect_uassoc', + 'array_intersect_ukey', + 'array_map', + 'array_reduce', + 'array_udiff', + 'array_udiff_assoc', + 'array_udiff_uassoc', + 'array_uintersect', + 'array_uintersect_assoc', + 'array_uintersect_uassoc', + 'array_walk', + 'array_walk_recursive', + 'preg_replace_callback', + 'preg_replace_callback_array', + 'call_user_func', + 'call_user_func_array', + 'forward_static_call', + 'forward_static_call_array', + 'is_callable', + 'ob_start', + 'register_shutdown_function', + 'register_tick_function', + 'session_set_save_handler', + 'set_error_handler', + 'set_exception_handler', + 'spl_autoload_register', + 'spl_autoload_unregister', + 'uasort', + 'uksort', + 'usort', + ]; + + if ($potential_method_id->fq_class_name !== $context->self + || ($cased_method_id !== null + && !$method_id + && !in_array($cased_method_id, $php_native_non_public_cb, true)) + || ($method_id + && $method_id->fq_class_name !== $context->self + && $method_id->fq_class_name !== 'Closure') + ) { + if ($method_storage->visibility !== ClassLikeAnalyzer::VISIBILITY_PUBLIC) { + IssueBuffer::maybeAdd( + new InvalidArgument( + 'Argument ' . ($argument_offset + 1) . $method_identifier + . ' expects a public callable, but a non-public callable provided', + $arg_location, + $cased_method_id, + ), + $statements_analyzer->getSuppressedIssues(), + ); + return false; + } + } + } elseif ($lhs->isSingle()) { + // instance from e.g. new Foo() or static string like Foo::bar + if ((!$method_storage->is_static && !$lhs->hasNamedObjectType()) + || $method_storage->visibility !== ClassLikeAnalyzer::VISIBILITY_PUBLIC) { + IssueBuffer::maybeAdd( + new InvalidArgument( + 'Argument ' . ($argument_offset + 1) . $method_identifier + . ' expects a public static callable, but a ' + . ($method_storage->visibility !== ClassLikeAnalyzer::VISIBILITY_PUBLIC ? + 'non-public ' : '') + . (!$method_storage->is_static ? 'non-static ' : '') + . 'callable provided', + $arg_location, + $cased_method_id, + ), + $statements_analyzer->getSuppressedIssues(), + ); + + return false; + } + } + } catch (UnexpectedValueException) { + // do nothing + } + } + + return null; + } + /** * @param PhpParser\Node\Scalar\String_|PhpParser\Node\Expr\Array_|PhpParser\Node\Expr\BinaryOp\Concat $input_expr */ @@ -1289,6 +1521,16 @@ private static function verifyExplicitParam( if ($callable_fq_class_name === 'parent') { $container_class = $statements_analyzer->getParentFQCLN(); + if ($container_class === null) { + IssueBuffer::accepts( + new ParentNotFound( + 'Cannot call method on parent' + . ' as this class does not extend another', + $arg_location, + ), + $statements_analyzer->getSuppressedIssues(), + ); + } } if (!$container_class) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 0d3b85b57e2..f5dd51a0166 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -1278,7 +1278,7 @@ private static function handleByRefReadonlyArg( try { $declaring_class_storage = $codebase->classlike_storage_provider->get($declaring_property_class); - } catch (InvalidArgumentException $_) { + } catch (InvalidArgumentException) { return; } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php index 1007b9d8855..65763215a18 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php @@ -49,6 +49,7 @@ use function explode; use function in_array; use function str_contains; +use function str_ends_with; use function strlen; use function strtolower; use function substr; @@ -637,7 +638,7 @@ private static function taintReturnType( $pattern = trim($pattern); if ($pattern[0] === '[' && $pattern[1] === '^' - && substr($pattern, -1) === ']' + && str_ends_with($pattern, ']') ) { $pattern = substr($pattern, 2, -1); diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgInfo.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgInfo.php index 54921838c0c..6b3203ab3ba 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgInfo.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/HighOrderFunctionArgInfo.php @@ -27,7 +27,7 @@ final class HighOrderFunctionArgInfo */ public function __construct( private readonly string $type, - private functionLikeStorage $function_storage, + private readonly FunctionLikeStorage $function_storage, private readonly ?ClassLikeStorage $class_storage = null, ) { } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php index c0139c041ca..f4b7e5c09f7 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php @@ -78,7 +78,7 @@ public static function analyze( StatementsAnalyzer $statements_analyzer, PhpParser\Node\Expr\New_ $stmt, Context $context, - TemplateResult $template_result = null, + ?TemplateResult $template_result = null, ): bool { $fq_class_name = null; @@ -312,7 +312,7 @@ private static function analyzeNamedConstructor( string $fq_class_name, bool $from_static, bool $can_extend, - TemplateResult $template_result = null, + ?TemplateResult $template_result = null, ): void { $storage = $codebase->classlike_storage_provider->get($fq_class_name); @@ -355,7 +355,7 @@ private static function analyzeNamedConstructor( if ($storage->abstract && !$can_extend) { if (IssueBuffer::accepts( new AbstractInstantiation( - 'Unable to instantiate a abstract class ' . $fq_class_name, + 'Unable to instantiate an abstract class ' . $fq_class_name, new CodeLocation($statements_analyzer->getSource(), $stmt), ), $statements_analyzer->getSuppressedIssues(), diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php index 5cd62f721c5..007886756e3 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php @@ -83,6 +83,28 @@ public static function analyze( $class_storage = $codebase->classlike_storage_provider->get($fq_class_name); $fq_class_name = $class_storage->name; + + if ($context->collect_initializations + && isset($stmt->name->name) + && $stmt->name->name === '__construct' + && isset($class_storage->declaring_method_ids['__construct'])) { + $construct_fq_class_name = $class_storage->declaring_method_ids['__construct']->fq_class_name; + $construct_class_storage = $codebase->classlike_storage_provider->get($construct_fq_class_name); + $construct_fq_class_name = $construct_class_storage->name; + + foreach ($construct_class_storage->properties as $property_name => $property_storage) { + if ($property_storage->is_promoted + && isset($context->vars_in_scope['$this->' . $property_name])) { + $context_type = $context->vars_in_scope['$this->' . $property_name]; + $context->vars_in_scope['$this->' . $property_name] = $context_type->setProperties( + [ + 'initialized_class' => $construct_fq_class_name, + 'initialized' => true, + ], + ); + } + } + } } elseif ($context->self) { if ($stmt->class->getFirst() === 'static' && isset($context->vars_in_scope['$this'])) { $fq_class_name = (string) $context->vars_in_scope['$this']; diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php index e61af332e2c..156424de827 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php @@ -791,7 +791,7 @@ private static function handleNamedCall( } } - if (!$callstatic_method_exists || $class_storage->hasSealedMethods($config)) { + if ($naive_method_exists || !$callstatic_method_exists || $class_storage->hasSealedMethods($config)) { $does_method_exist = MethodAnalyzer::checkMethodExists( $codebase, $method_id, diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php index 65f24af9f03..8cb088d450b 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php @@ -30,6 +30,7 @@ use Psalm\Type\Atomic\TFalse; use Psalm\Type\Atomic\TFloat; use Psalm\Type\Atomic\TInt; +use Psalm\Type\Atomic\TIntRange; use Psalm\Type\Atomic\TKeyedArray; use Psalm\Type\Atomic\TLiteralFloat; use Psalm\Type\Atomic\TLiteralInt; @@ -53,6 +54,7 @@ use function array_merge; use function array_pop; use function array_values; +use function range; use function strtolower; /** @@ -523,6 +525,18 @@ public static function castFloatAttempt( continue; } + if ($atomic_type instanceof TIntRange + && $atomic_type->min_bound !== null + && $atomic_type->max_bound !== null + && ($atomic_type->max_bound - $atomic_type->min_bound) < 500 + ) { + foreach (range($atomic_type->min_bound, $atomic_type->max_bound) as $literal_int_value) { + $valid_floats[] = new TLiteralFloat((float) $literal_int_value); + } + + continue; + } + if ($atomic_type instanceof TInt) { if ($atomic_type instanceof TLiteralInt) { $valid_floats[] = new TLiteralFloat((float) $atomic_type->value); @@ -707,9 +721,17 @@ public static function castStringAttempt( || $atomic_type instanceof TNumeric ) { if ($atomic_type instanceof TLiteralInt || $atomic_type instanceof TLiteralFloat) { - $castable_types[] = Type::getAtomicStringFromLiteral((string) $atomic_type->value); + $valid_strings[] = Type::getAtomicStringFromLiteral((string) $atomic_type->value); } elseif ($atomic_type instanceof TNonspecificLiteralInt) { $castable_types[] = new TNonspecificLiteralString(); + } elseif ($atomic_type instanceof TIntRange + && $atomic_type->min_bound !== null + && $atomic_type->max_bound !== null + && ($atomic_type->max_bound - $atomic_type->min_bound) < 500 + ) { + foreach (range($atomic_type->min_bound, $atomic_type->max_bound) as $literal_int_value) { + $valid_strings[] = Type::getAtomicStringFromLiteral((string) $literal_int_value); + } } else { $castable_types[] = new TNumericString(); } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/ExpressionIdentifier.php b/src/Psalm/Internal/Analyzer/Statements/Expression/ExpressionIdentifier.php index 660ad0ff087..67f22a0a73a 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/ExpressionIdentifier.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/ExpressionIdentifier.php @@ -118,9 +118,10 @@ public static function getExtendedVarId( if ($stmt->dim instanceof PhpParser\Node\Scalar\String_ || $stmt->dim instanceof PhpParser\Node\Scalar\Int_ ) { - $offset = $stmt->dim instanceof PhpParser\Node\Scalar\String_ + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($stmt->dim->value); + $offset = $string_to_int === false ? '\'' . $stmt->dim->value . '\'' - : $stmt->dim->value; + : (int) $stmt->dim->value; } elseif ($stmt->dim instanceof PhpParser\Node\Expr\Variable && is_string($stmt->dim->name) ) { @@ -148,7 +149,13 @@ public static function getExtendedVarId( ) ) { if ($stmt_dim_type->isSingleStringLiteral()) { - $offset = '\'' . $stmt_dim_type->getSingleStringLiteral()->value . '\''; + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt( + $stmt_dim_type->getSingleStringLiteral()->value, + ); + + $offset = $string_to_int === false + ? '\'' . $stmt_dim_type->getSingleStringLiteral()->value . '\'' + : (int) $stmt_dim_type->getSingleStringLiteral()->value; } elseif ($stmt_dim_type->isSingleIntLiteral()) { $offset = $stmt_dim_type->getSingleIntLiteral()->value; } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php index c23cf7610bc..547c212e6af 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Fetch/ArrayFetchAnalyzer.php @@ -9,6 +9,7 @@ use Psalm\Codebase; use Psalm\Context; use Psalm\Internal\Analyzer\FunctionLikeAnalyzer; +use Psalm\Internal\Analyzer\Statements\Expression\ArrayAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\Call\MethodCallAnalyzer; use Psalm\Internal\Analyzer\Statements\Expression\ExpressionIdentifier; use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer; @@ -93,8 +94,6 @@ use function implode; use function in_array; use function is_int; -use function is_numeric; -use function preg_match; use function strlen; use function strtolower; @@ -172,7 +171,7 @@ public static function analyze( $codebase = $statements_analyzer->getCodebase(); - if ($keyed_array_var_id + if ($keyed_array_var_id !== null && $context->hasVariable($keyed_array_var_id) && !$context->vars_in_scope[$keyed_array_var_id]->possibly_undefined && $stmt_var_type @@ -251,6 +250,10 @@ public static function analyze( } } + if ($context->inside_isset && !$stmt_type->hasMixed()) { + $stmt_type = Type::combineUnionTypes($stmt_type, Type::getNull()); + } + $statements_analyzer->node_data->setType($stmt, $stmt_type); if ($context->inside_isset @@ -305,7 +308,7 @@ public static function analyze( } } - if ($keyed_array_var_id + if ($keyed_array_var_id !== null && $context->hasVariable($keyed_array_var_id) && (!($stmt_type = $statements_analyzer->node_data->getType($stmt)) || $stmt_type->isVanillaMixed()) ) { @@ -476,8 +479,8 @@ public static function getArrayAccessTypeGivenOffset( bool $in_assignment, ?string $extended_var_id, Context $context, - PhpParser\Node\Expr $assign_value = null, - Union $replacement_type = null, + ?PhpParser\Node\Expr $assign_value = null, + ?Union $replacement_type = null, ): Union { $offset_type = $offset_type_original->getBuilder(); @@ -961,16 +964,25 @@ private static function checkLiteralStringArrayOffset( $found_match = false; foreach ($offset_type->getAtomicTypes() as $offset_type_part) { - if ($extended_var_id - && $offset_type_part instanceof TLiteralString - && isset( - $context->vars_in_scope[ - $extended_var_id . '[\'' . $offset_type_part->value . '\']' - ], - ) - && !$context->vars_in_scope[ - $extended_var_id . '[\'' . $offset_type_part->value . '\']' - ]->possibly_undefined + if ($extended_var_id === null + || !($offset_type_part instanceof TLiteralString)) { + continue; + } + + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt( + $offset_type_part->value, + ); + + $literal_access = $string_to_int === false + ? '\'' . $offset_type_part->value . '\'' + : $string_to_int; + if (isset( + $context->vars_in_scope[ + $extended_var_id . '[' . $literal_access . ']' + ], + ) && !$context->vars_in_scope[ + $extended_var_id . '[' . $literal_access . ']' + ]->possibly_undefined ) { $found_match = true; break; @@ -1000,8 +1012,9 @@ public static function replaceOffsetTypeWithInts(Union $offset_type): Union foreach ($offset_types as $key => $offset_type_part) { if ($offset_type_part instanceof TLiteralString) { - if (preg_match('/^(0|[1-9][0-9]*)$/', $offset_type_part->value)) { - $offset_type->addType(new TLiteralInt((int) $offset_type_part->value)); + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($offset_type_part->value); + if ($string_to_int !== false) { + $offset_type->addType(new TLiteralInt($string_to_int)); $offset_type->removeType($key); } } elseif ($offset_type_part instanceof TBool) { @@ -1135,7 +1148,7 @@ private static function handleArrayAccessOnArray( $single_atomic = $key_values[0]; $from_mixed_array = $type->type_params[1]->isMixed(); - // ok, type becomes an TKeyedArray + // ok, type becomes a TKeyedArray $type = new TKeyedArray( [ $single_atomic->value => $from_mixed_array ? Type::getMixed() : Type::getNever(), @@ -1539,7 +1552,10 @@ private static function handleArrayAccessOnKeyedArray( if ($key_values) { $properties = $type->properties; foreach ($key_values as $key_value) { - if ($type->is_list && (!is_numeric($key_value->value) || $key_value->value < 0)) { + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($key_value->value); + $key_value = $string_to_int === false ? $key_value : new TLiteralInt($string_to_int); + + if ($type->is_list && (!is_int($key_value->value) || $key_value->value < 0)) { $expected_offset_types[] = $type->getGenericKeyType(); $has_valid_offset = false; } elseif ((isset($properties[$key_value->value]) && !( diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/SimpleTypeInferer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/SimpleTypeInferer.php index 93b6d68ea48..f09c4a4cca0 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/SimpleTypeInferer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/SimpleTypeInferer.php @@ -37,7 +37,6 @@ use function array_values; use function count; use function is_string; -use function preg_match; use function strtolower; use const PHP_INT_MAX; @@ -57,7 +56,7 @@ public static function infer( NodeDataProvider $nodes, PhpParser\Node\Expr $stmt, Aliases $aliases, - FileSource $file_source = null, + ?FileSource $file_source = null, ?array $existing_class_constants = null, ?string $fq_classlike_name = null, ): ?Union { @@ -541,7 +540,7 @@ private static function inferArrayType( NodeDataProvider $nodes, PhpParser\Node\Expr\Array_ $stmt, Aliases $aliases, - FileSource $file_source = null, + ?FileSource $file_source = null, ?array $existing_class_constants = null, ?string $fq_classlike_name = null, ): ?Union { @@ -625,7 +624,7 @@ private static function handleArrayItem( ArrayCreationInfo $array_creation_info, PhpParser\Node\ArrayItem $item, Aliases $aliases, - FileSource $file_source = null, + ?FileSource $file_source = null, ?array $existing_class_constants = null, ?string $fq_classlike_name = null, ): bool { @@ -668,11 +667,7 @@ private static function handleArrayItem( $key_type = Type::getString(''); } if ($item->key instanceof PhpParser\Node\Scalar\String_ - && preg_match('/^(0|[1-9][0-9]*)$/', $item->key->value) - && ( - (int) $item->key->value < PHP_INT_MAX || - $item->key->value === (string) PHP_INT_MAX - ) + && ArrayAnalyzer::getLiteralArrayKeyInt($item->key->value) !== false ) { $key_type = Type::getInt(false, (int) $item->key->value); } @@ -684,9 +679,10 @@ private static function handleArrayItem( if ($key_type->isSingleStringLiteral()) { $item_key_literal_type = $key_type->getSingleStringLiteral(); - $item_key_value = $item_key_literal_type->value; + $string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($item_key_literal_type->value); + $item_key_value = $string_to_int === false ? $item_key_literal_type->value : $string_to_int; - if ($item_key_literal_type instanceof TLiteralClassString) { + if (is_string($item_key_value) && $item_key_literal_type instanceof TLiteralClassString) { $array_creation_info->class_strings[$item_key_value] = true; } } elseif ($key_type->isSingleIntLiteral()) { diff --git a/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php index 1e598336e72..3d714fed28a 100644 --- a/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/ExpressionAnalyzer.php @@ -79,7 +79,7 @@ public static function analyze( Context $context, bool $array_assignment = false, ?Context $global_context = null, - PhpParser\Node\Stmt $from_stmt = null, + ?PhpParser\Node\Stmt $from_stmt = null, ?TemplateResult $template_result = null, bool $assigned_to_reference = false, ): bool { diff --git a/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php b/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php index c8df750b6b3..f1363d24093 100644 --- a/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php @@ -795,6 +795,14 @@ private function parseStatementDocblock( $this->parsed_docblock = null; } + if ($this->parsed_docblock === null) { + try { + $this->parsed_docblock = DocComment::parsePreservingLength($docblock, true); + } catch (DocblockParseException) { + // already reported above + } + } + $comments = $this->parsed_docblock; if (isset($comments->tags['psalm-scope-this'])) { diff --git a/src/Psalm/Internal/Analyzer/TraitAnalyzer.php b/src/Psalm/Internal/Analyzer/TraitAnalyzer.php index 3d65d479008..459726b4073 100644 --- a/src/Psalm/Internal/Analyzer/TraitAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/TraitAnalyzer.php @@ -20,7 +20,7 @@ public function __construct( Trait_ $class, SourceAnalyzer $source, string $fq_class_name, - private Aliases $aliases, + private readonly Aliases $aliases, ) { $this->source = $source; $this->file_analyzer = $source->getFileAnalyzer(); diff --git a/src/Psalm/Internal/Cli/Psalm.php b/src/Psalm/Internal/Cli/Psalm.php index 268a5e3de10..bd43a169100 100644 --- a/src/Psalm/Internal/Cli/Psalm.php +++ b/src/Psalm/Internal/Cli/Psalm.php @@ -33,11 +33,13 @@ use Psalm\Progress\VoidProgress; use Psalm\Report; use Psalm\Report\ReportOptions; +use ReflectionClass; use RuntimeException; use Symfony\Component\Filesystem\Path; use function array_filter; use function array_key_exists; +use function array_keys; use function array_map; use function array_merge; use function array_slice; @@ -65,21 +67,25 @@ use function json_encode; use function max; use function microtime; +use function opcache_get_status; use function parse_url; use function preg_match; use function preg_replace; use function realpath; use function setlocale; +use function sort; use function str_repeat; use function str_starts_with; use function strlen; use function substr; +use function wordwrap; use const DIRECTORY_SEPARATOR; use const JSON_THROW_ON_ERROR; use const LC_CTYPE; use const PHP_EOL; use const PHP_URL_SCHEME; +use const PHP_VERSION_ID; use const STDERR; // phpcs:disable PSR1.Files.SideEffects @@ -89,6 +95,7 @@ require_once __DIR__ . '/../Composer.php'; require_once __DIR__ . '/../IncludeCollector.php'; require_once __DIR__ . '/../../IssueBuffer.php'; +require_once __DIR__ . '/../../Report.php'; /** * @internal @@ -909,17 +916,37 @@ private static function restart(array $options, int $threads, Progress $progress 'blackfire', ]); - if (defined('PHP_WINDOWS_VERSION_MAJOR')) { + $skipJit = defined('PHP_WINDOWS_VERSION_MAJOR') && PHP_VERSION_ID < PsalmRestarter::MIN_PHP_VERSION_WINDOWS_JIT; + if ($skipJit) { $ini_handler->disableExtensions(['opcache', 'Zend OPcache']); } // If Xdebug is enabled, restart without it $ini_handler->check(); - if (!function_exists('opcache_get_status') && !defined('PHP_WINDOWS_VERSION_MAJOR')) { - $progress->write(PHP_EOL - . 'Install the opcache extension to make use of JIT for a 20%+ performance boost!' - . PHP_EOL . PHP_EOL); + if (function_exists('opcache_get_status')) { + if (true === (opcache_get_status()['jit']['on'] ?? false)) { + $progress->write(PHP_EOL + . 'JIT acceleration: ON' + . PHP_EOL . PHP_EOL); + } else { + $progress->write(PHP_EOL + . 'JIT acceleration: OFF (an error occurred while enabling JIT)' . PHP_EOL + . 'Please report this to https://github.com/vimeo/psalm with your OS and PHP configuration!' + . PHP_EOL . PHP_EOL); + } + } else { + if ($skipJit) { + $progress->write(PHP_EOL + . 'JIT acceleration: OFF (disabled on Windows and PHP < 8.4)' . PHP_EOL + . 'Install PHP 8.4+ to make use of JIT on Windows for a 20%+ performance boost!' + . PHP_EOL . PHP_EOL); + } else { + $progress->write(PHP_EOL + . 'JIT acceleration: OFF (opcache not installed)' . PHP_EOL + . 'Install the opcache extension to make use of JIT for a 20%+ performance boost!' + . PHP_EOL . PHP_EOL); + } } } @@ -1232,6 +1259,21 @@ private static function generateStubs( */ private static function getHelpText(): string { + $formats = []; + /** @var string $value */ + foreach ((new ReflectionClass(Report::class))->getConstants() as $constant => $value) { + if (str_starts_with($constant, 'TYPE_')) { + $formats[] = $value; + } + } + sort($formats); + $outputFormats = wordwrap(implode(', ', $formats), 75, "\n "); + + /** @psalm-suppress ImpureMethodCall */ + $reports = array_keys(Report::getMapping()); + sort($reports); + $reportFormats = wordwrap('"' . implode('", "', $reports) . '"', 75, "\n "); + return << 2) { // ignore --config psalm.xml // ignore common phpunit args that accept a class instead of a path, as this can cause issues on Windows - $ignored_arguments = array( - 'config', - 'printer', - 'root', - ); + $ignored_arguments = ['config', 'printer', 'root']; if (in_array(substr($input_path, 2), $ignored_arguments, true)) { ++$i; diff --git a/src/Psalm/Internal/Codebase/Analyzer.php b/src/Psalm/Internal/Codebase/Analyzer.php index 80cdba04ddc..33c48358d9e 100644 --- a/src/Psalm/Internal/Codebase/Analyzer.php +++ b/src/Psalm/Internal/Codebase/Analyzer.php @@ -507,10 +507,6 @@ static function (): void { $this->argument_map[$file_path] = $argument_map; } } - - if ($pool->didHaveError()) { - exit(1); - } } else { $i = 0; @@ -1184,7 +1180,7 @@ public function addNodeType( string $file_path, PhpParser\Node $node, string $node_type, - PhpParser\Node $parent_node = null, + ?PhpParser\Node $parent_node = null, ): void { if ($node_type === '') { throw new UnexpectedValueException('non-empty node_type expected'); diff --git a/src/Psalm/Internal/Codebase/ClassLikes.php b/src/Psalm/Internal/Codebase/ClassLikes.php index a39f3354661..0f6318efc3f 100644 --- a/src/Psalm/Internal/Codebase/ClassLikes.php +++ b/src/Psalm/Internal/Codebase/ClassLikes.php @@ -1741,6 +1741,12 @@ private function checkMethodReferences(ClassLikeStorage $classlike_storage, Meth continue; } + if ($codebase->classImplements($classlike_storage->name, 'JsonSerializable') + && ($method_name === 'jsonserialize') + ) { + continue; + } + $has_variable_calls = $codebase->analyzer->hasMixedMemberName($method_name) || $codebase->analyzer->hasMixedMemberName(strtolower($classlike_storage->name . '::')); diff --git a/src/Psalm/Internal/Codebase/ConstantTypeResolver.php b/src/Psalm/Internal/Codebase/ConstantTypeResolver.php index 35d0c77058d..73584f3c4e8 100644 --- a/src/Psalm/Internal/Codebase/ConstantTypeResolver.php +++ b/src/Psalm/Internal/Codebase/ConstantTypeResolver.php @@ -60,7 +60,7 @@ final class ConstantTypeResolver public static function resolve( ClassLikes $classlikes, UnresolvedConstantComponent $c, - StatementsAnalyzer $statements_analyzer = null, + ?StatementsAnalyzer $statements_analyzer = null, array $visited_constant_ids = [], ): Atomic { $c_id = spl_object_id($c); diff --git a/src/Psalm/Internal/Codebase/InternalCallMapHandler.php b/src/Psalm/Internal/Codebase/InternalCallMapHandler.php index 9418d6a3e88..2838ad66401 100644 --- a/src/Psalm/Internal/Codebase/InternalCallMapHandler.php +++ b/src/Psalm/Internal/Codebase/InternalCallMapHandler.php @@ -27,7 +27,6 @@ use function str_ends_with; use function str_starts_with; use function strlen; -use function strpos; use function strtolower; use function substr; use function version_compare; @@ -299,7 +298,7 @@ public static function getCallablesFromCallMap(string $function_id): ?array // removes `rw_` leftover from `&rw_haystack` or `&rw_needle` or `&rw_actual_name` // it doesn't have any specific meaning apart from `&` signifying that // the parameter is passed by reference (handled above) - if ($by_reference && strlen($arg_name) > 3 && strpos($arg_name, 'rw_') === 0) { + if ($by_reference && strlen($arg_name) > 3 && str_starts_with($arg_name, 'rw_')) { $arg_name = substr($arg_name, 3); } diff --git a/src/Psalm/Internal/Codebase/Methods.php b/src/Psalm/Internal/Codebase/Methods.php index 82f57aa381c..f4c8f159f9c 100644 --- a/src/Psalm/Internal/Codebase/Methods.php +++ b/src/Psalm/Internal/Codebase/Methods.php @@ -453,13 +453,6 @@ public function getMethodParams( foreach ($params as $i => $param) { if (isset($overridden_storage->params[$i]->type) && $overridden_storage->params[$i]->has_docblock_type - && ( - ! $param->type - || $param->type->equals( - $overridden_storage->params[$i]->signature_type - ?? $overridden_storage->params[$i]->type, - ) - ) ) { $params[$i] = clone $param; /** @var Union $params[$i]->type */ @@ -941,7 +934,7 @@ public function getMethodReturnsByRef(MethodIdentifier $method_id): bool public function getMethodReturnTypeLocation( MethodIdentifier $method_id, - CodeLocation &$defined_location = null, + ?CodeLocation &$defined_location = null, ): ?CodeLocation { $method_id = $this->getDeclaringMethodId($method_id); diff --git a/src/Psalm/Internal/Codebase/Populator.php b/src/Psalm/Internal/Codebase/Populator.php index 4093c263db5..f00e45fa50e 100644 --- a/src/Psalm/Internal/Codebase/Populator.php +++ b/src/Psalm/Internal/Codebase/Populator.php @@ -52,7 +52,7 @@ final class Populator private array $invalid_class_storages = []; public function __construct( - private ClassLikeStorageProvider $classlike_storage_provider, + private readonly ClassLikeStorageProvider $classlike_storage_provider, private readonly FileStorageProvider $file_storage_provider, private readonly ClassLikes $classlikes, private readonly FileReferenceProvider $file_reference_provider, diff --git a/src/Psalm/Internal/Codebase/Scanner.php b/src/Psalm/Internal/Codebase/Scanner.php index eabb6673e90..85798191789 100644 --- a/src/Psalm/Internal/Codebase/Scanner.php +++ b/src/Psalm/Internal/Codebase/Scanner.php @@ -401,10 +401,6 @@ function (): void { ); } } - - if ($pool->didHaveError()) { - exit(1); - } } else { $i = 0; diff --git a/src/Psalm/Internal/Codebase/TaintFlowGraph.php b/src/Psalm/Internal/Codebase/TaintFlowGraph.php index ddd366476ff..88b02d3169e 100644 --- a/src/Psalm/Internal/Codebase/TaintFlowGraph.php +++ b/src/Psalm/Internal/Codebase/TaintFlowGraph.php @@ -315,177 +315,122 @@ private function getChildNodes( . ' -> ' . $this->getSuccessorPath($sinks[$to_id]); foreach ($matching_taints as $matching_taint) { - switch ($matching_taint) { - case TaintKind::INPUT_CALLABLE: - $issue = new TaintedCallable( - 'Detected tainted text', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_UNSERIALIZE: - $issue = new TaintedUnserialize( - 'Detected tainted code passed to unserialize or similar', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_INCLUDE: - $issue = new TaintedInclude( - 'Detected tainted code passed to include or similar', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_EVAL: - $issue = new TaintedEval( - 'Detected tainted code passed to eval or similar', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_SQL: - $issue = new TaintedSql( - 'Detected tainted SQL', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_HTML: - $issue = new TaintedHtml( - 'Detected tainted HTML', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_HAS_QUOTES: - $issue = new TaintedTextWithQuotes( - 'Detected tainted text with possible quotes', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_SHELL: - $issue = new TaintedShell( - 'Detected tainted shell code', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::USER_SECRET: - $issue = new TaintedUserSecret( - 'Detected tainted user secret leaking', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::SYSTEM_SECRET: - $issue = new TaintedSystemSecret( - 'Detected tainted system secret leaking', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_SSRF: - $issue = new TaintedSSRF( - 'Detected tainted network request', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_LDAP: - $issue = new TaintedLdap( - 'Detected tainted LDAP request', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_COOKIE: - $issue = new TaintedCookie( - 'Detected tainted cookie', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_FILE: - $issue = new TaintedFile( - 'Detected tainted file handling', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_HEADER: - $issue = new TaintedHeader( - 'Detected tainted header', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_XPATH: - $issue = new TaintedXpath( - 'Detected tainted xpath query', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_SLEEP: - $issue = new TaintedSleep( - 'Detected tainted sleep', - $issue_location, - $issue_trace, - $path, - ); - break; - - case TaintKind::INPUT_EXTRACT: - $issue = new TaintedExtract( - 'Detected tainted extract', - $issue_location, - $issue_trace, - $path, - ); - break; - - default: - $issue = new TaintedCustom( - 'Detected tainted ' . $matching_taint, - $issue_location, - $issue_trace, - $path, - ); - } + $issue = match ($matching_taint) { + TaintKind::INPUT_CALLABLE => new TaintedCallable( + 'Detected tainted text', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_UNSERIALIZE => new TaintedUnserialize( + 'Detected tainted code passed to unserialize or similar', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_INCLUDE => new TaintedInclude( + 'Detected tainted code passed to include or similar', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_EVAL => new TaintedEval( + 'Detected tainted code passed to eval or similar', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_SQL => new TaintedSql( + 'Detected tainted SQL', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_HTML => new TaintedHtml( + 'Detected tainted HTML', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_HAS_QUOTES => new TaintedTextWithQuotes( + 'Detected tainted text with possible quotes', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_SHELL => new TaintedShell( + 'Detected tainted shell code', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::USER_SECRET => new TaintedUserSecret( + 'Detected tainted user secret leaking', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::SYSTEM_SECRET => new TaintedSystemSecret( + 'Detected tainted system secret leaking', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_SSRF => new TaintedSSRF( + 'Detected tainted network request', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_LDAP => new TaintedLdap( + 'Detected tainted LDAP request', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_COOKIE => new TaintedCookie( + 'Detected tainted cookie', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_FILE => new TaintedFile( + 'Detected tainted file handling', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_HEADER => new TaintedHeader( + 'Detected tainted header', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_XPATH => new TaintedXpath( + 'Detected tainted xpath query', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_SLEEP => new TaintedSleep( + 'Detected tainted sleep', + $issue_location, + $issue_trace, + $path, + ), + TaintKind::INPUT_EXTRACT => new TaintedExtract( + 'Detected tainted extract', + $issue_location, + $issue_trace, + $path, + ), + default => new TaintedCustom( + 'Detected tainted ' . $matching_taint, + $issue_location, + $issue_trace, + $path, + ), + }; IssueBuffer::maybeAdd($issue); } diff --git a/src/Psalm/Internal/ErrorHandler.php b/src/Psalm/Internal/ErrorHandler.php index 9a41edf165d..fa93aa1a48e 100644 --- a/src/Psalm/Internal/ErrorHandler.php +++ b/src/Psalm/Internal/ErrorHandler.php @@ -16,7 +16,6 @@ use function set_exception_handler; use const E_ALL; -use const E_STRICT; use const STDERR; /** @@ -61,7 +60,7 @@ private function __construct() private static function setErrorReporting(): void { - error_reporting(E_ALL | E_STRICT); + error_reporting(E_ALL); ini_set('display_errors', '1'); } diff --git a/src/Psalm/Internal/Fork/Pool.php b/src/Psalm/Internal/Fork/Pool.php index 64fc2ade52f..c45cacf9328 100644 --- a/src/Psalm/Internal/Fork/Pool.php +++ b/src/Psalm/Internal/Fork/Pool.php @@ -79,8 +79,6 @@ final class Pool /** @var resource[] */ private array $read_streams = []; - private bool $did_have_error = false; - /** * @param array> $process_task_data_iterator * An array of task data items to be divided up among the @@ -291,6 +289,20 @@ private static function streamForChild(array $sockets) return $for_write; } + private function killAllChildren(): void + { + foreach ($this->child_pid_list as $child_pid) { + /** + * SIGTERM does not exist on windows + * + * @psalm-suppress UnusedPsalmSuppress + * @psalm-suppress UndefinedConstant + * @psalm-suppress MixedArgument + */ + posix_kill($child_pid, SIGTERM); + } + } + /** * Read the results that each child process has serialized on their write streams. * The results are returned in an array, one for each worker. The order of the results @@ -313,6 +325,7 @@ private function readResultsFromChildren(): array $content = array_fill_keys(array_keys($streams), ''); $terminationMessages = []; + $done = []; // Read the data off of all the stream. while (count($streams) > 0) { @@ -355,34 +368,25 @@ private function readResultsFromChildren(): array if ($message instanceof ForkProcessDoneMessage) { $terminationMessages[] = $message->data; } elseif ($message instanceof ForkTaskDoneMessage) { + $done[(int)$file] = true; if ($this->task_done_closure !== null) { ($this->task_done_closure)($message->data); } } elseif ($message instanceof ForkProcessErrorMessage) { - // Kill all children - foreach ($this->child_pid_list as $child_pid) { - /** - * SIGTERM does not exist on windows - * - * @psalm-suppress UnusedPsalmSuppress - * @psalm-suppress UndefinedConstant - * @psalm-suppress MixedArgument - */ - posix_kill($child_pid, SIGTERM); - } + $this->killAllChildren(); throw new Exception($message->message); } else { - error_log('Child should return ForkMessage - response type=' . gettype($message)); - $this->did_have_error = true; + $this->killAllChildren(); + throw new Exception('Child should return ForkMessage - response type=' . gettype($message)); } } } // If the stream has closed, stop trying to select on it. if (feof($file)) { - if ($content[(int)$file] !== '') { - error_log('Child did not send full message before closing the connection'); - $this->did_have_error = true; + if ($content[(int)$file] !== '' || !isset($done[(int)$file])) { + $this->killAllChildren(); + throw new Exception('Child did not send full message before closing the connection'); } fclose($file); @@ -444,8 +448,8 @@ public function wait(): array * @psalm-suppress UndefinedConstant */ if ($term_sig !== SIGALRM) { - $this->did_have_error = true; - error_log("Child terminated with return code $return_code and signal $term_sig"); + $this->killAllChildren(); + throw new Exception("Child terminated with return code $return_code and signal $term_sig"); } } } @@ -453,12 +457,4 @@ public function wait(): array return $content; } - - /** - * Returns true if this had an error, e.g. due to memory limits or due to a child process crashing. - */ - public function didHaveError(): bool - { - return $this->did_have_error; - } } diff --git a/src/Psalm/Internal/Fork/PsalmRestarter.php b/src/Psalm/Internal/Fork/PsalmRestarter.php index 139bd69d0b1..67c5b0491e9 100644 --- a/src/Psalm/Internal/Fork/PsalmRestarter.php +++ b/src/Psalm/Internal/Fork/PsalmRestarter.php @@ -23,11 +23,14 @@ use function strlen; use function strtolower; +use const PHP_VERSION_ID; + /** * @internal */ final class PsalmRestarter extends XdebugHandler { + public const MIN_PHP_VERSION_WINDOWS_JIT = 8_04_00; private const REQUIRED_OPCACHE_SETTINGS = [ 'enable_cli' => 1, 'jit' => 1205, @@ -36,9 +39,9 @@ final class PsalmRestarter extends XdebugHandler 'jit_buffer_size' => 128 * 1024 * 1024, 'max_accelerated_files' => 1_000_000, 'interned_strings_buffer' => 64, - 'jit_max_root_traces' => 1_000_000, - 'jit_max_side_traces' => 1_000_000, - 'jit_max_exit_counters' => 1_000_000, + 'jit_max_root_traces' => 100_000, + 'jit_max_side_traces' => 100_000, + 'jit_max_exit_counters' => 100_000, 'jit_hot_loop' => 1, 'jit_hot_func' => 1, 'jit_hot_return' => 1, @@ -83,7 +86,7 @@ protected function requiresRestart($default): bool $opcache_loaded = extension_loaded('opcache') || extension_loaded('Zend OPcache'); - if ($opcache_loaded && !defined('PHP_WINDOWS_VERSION_MAJOR')) { + if ($opcache_loaded) { // restart to enable JIT if it's not configured in the optimal way foreach (self::REQUIRED_OPCACHE_SETTINGS as $ini_name => $required_value) { $value = (string) ini_get("opcache.$ini_name"); @@ -162,7 +165,9 @@ protected function restart($command): void // executed in the parent process (before restart) // if it wasn't loaded then we apparently don't have opcache installed and there's no point trying // to tweak it - if ($opcache_loaded && !defined('PHP_WINDOWS_VERSION_MAJOR')) { + if ($opcache_loaded && + !(defined('PHP_WINDOWS_VERSION_MAJOR') && PHP_VERSION_ID < self::MIN_PHP_VERSION_WINDOWS_JIT) + ) { $additional_options = []; foreach (self::REQUIRED_OPCACHE_SETTINGS as $key => $value) { $additional_options []= "-dopcache.{$key}={$value}"; @@ -179,7 +184,7 @@ protected function restart($command): void 0, $additional_options, ); - assert(count($command) > 0); + assert(count($command) > 1); parent::restart($command); } diff --git a/src/Psalm/Internal/Json/Json.php b/src/Psalm/Internal/Json/Json.php index 1e56730f9e2..e4964b75014 100644 --- a/src/Psalm/Internal/Json/Json.php +++ b/src/Psalm/Internal/Json/Json.php @@ -6,8 +6,12 @@ use RuntimeException; +use function array_walk_recursive; +use function bin2hex; +use function is_string; use function json_encode; use function json_last_error_msg; +use function preg_replace_callback; use const JSON_PRETTY_PRINT; use const JSON_UNESCAPED_SLASHES; @@ -21,6 +25,30 @@ final class Json { public const PRETTY = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + // from https://stackoverflow.com/a/11709412 + private const INVALID_UTF_REGEXP = <<<'EOF' + /( + [\xC0-\xC1] # Invalid UTF-8 Bytes + | [\xF5-\xFF] # Invalid UTF-8 Bytes + | \xE0[\x80-\x9F] # Overlong encoding of prior code point + | \xF0[\x80-\x8F] # Overlong encoding of prior code point + | [\xC2-\xDF](?![\x80-\xBF]) # Invalid UTF-8 Sequence Start + | [\xE0-\xEF](?![\x80-\xBF]{2}) # Invalid UTF-8 Sequence Start + | [\xF0-\xF4](?![\x80-\xBF]{3}) # Invalid UTF-8 Sequence Start + | (?<=[\x00-\x7F\xF5-\xFF])[\x80-\xBF] # Invalid UTF-8 Sequence Middle + | (? $data * @psalm-pure */ - public static function encode(mixed $data, ?int $options = null): string + public static function encode(array $data, ?int $options = null): string { if ($options === null) { $options = self::DEFAULT; } $result = json_encode($data, $options); + + if ($result == false) { + $result = json_encode(self::scrub($data), $options); + } + if ($result === false) { /** @psalm-suppress ImpureFunctionCall */ throw new RuntimeException('Cannot create JSON string: '.json_last_error_msg()); @@ -44,4 +78,27 @@ public static function encode(mixed $data, ?int $options = null): string return $result; } + + /** @psalm-pure */ + private static function scrub(array $data): array + { + /** @psalm-suppress ImpureFunctionCall */ + array_walk_recursive( + $data, + /** + * @psalm-pure + * @param mixed $value + */ + function (mixed &$value): void { + if (is_string($value)) { + $value = preg_replace_callback( + self::INVALID_UTF_REGEXP, + static fn(array $matches): string => '', + $value, + ); + } + }, + ); + return $data; + } } diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php index d7fc2f2ffcd..6a503023079 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php @@ -51,6 +51,7 @@ use Psalm\Issue\InvalidEnumBackingType; use Psalm\Issue\InvalidEnumCaseValue; use Psalm\Issue\InvalidTypeImport; +use Psalm\Issue\MissingClassConstType; use Psalm\Issue\MissingDocblockType; use Psalm\Issue\MissingPropertyType; use Psalm\Issue\ParseError; @@ -81,6 +82,7 @@ use function ltrim; use function preg_match; use function preg_split; +use function sprintf; use function strtolower; use function trim; use function usort; @@ -95,7 +97,7 @@ final class ClassLikeNodeScanner { private readonly string $file_path; - private Config $config; + private readonly Config $config; /** * @var array @@ -118,7 +120,7 @@ public function __construct( private readonly Codebase $codebase, private readonly FileStorage $file_storage, private readonly FileScanner $file_scanner, - private Aliases $aliases, + private readonly Aliases $aliases, private readonly ?Name $namespace_name, ) { $this->file_path = $file_storage->file_path; @@ -418,9 +420,11 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool try { $type_string = CommentAnalyzer::splitDocLine($type_string)[0]; } catch (DocblockParseException $e) { - throw new DocblockParseException( - $type_string . ' is not a valid type: ' . $e->getMessage(), + $storage->docblock_issues[] = new InvalidDocblock( + $e->getMessage() . ' in docblock for ' . $fq_classlike_name, + $name_location ?? $class_location, ); + continue; } $type_string = CommentAnalyzer::sanitizeDocblockType($type_string); try { @@ -1317,10 +1321,8 @@ private function visitClassConstDeclaration( ); $type_location = null; - $suppressed_issues = []; - if ($var_comment !== null && $var_comment->type !== null) { + if ($var_comment && $var_comment->type !== null) { $const_type = $var_comment->type; - $suppressed_issues = $var_comment->suppressed_issues; if ($var_comment->type_start !== null && $var_comment->type_end !== null @@ -1336,6 +1338,7 @@ private function visitClassConstDeclaration( } else { $const_type = $inferred_type; } + $suppressed_issues = $var_comment ? $var_comment->suppressed_issues : []; $attributes = []; foreach ($stmt->attrGroups as $attr_group) { @@ -1399,6 +1402,23 @@ private function visitClassConstDeclaration( $description, ); + if ($this->codebase->analysis_php_version_id >= 8_03_00 + && !$storage->final + && $stmt->type === null + ) { + IssueBuffer::maybeAdd( + new MissingClassConstType( + sprintf( + 'Class constant "%s::%s" should have a declared type.', + $storage->name, + $const->name->name, + ), + new CodeLocation($this->file_scanner, $const), + ), + $suppressed_issues, + ); + } + if ($exists) { $existing_constants[$const->name->name] = $constant_storage; } diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php index 37697fdfded..e2a8e39e913 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockParser.php @@ -57,7 +57,8 @@ public static function parse( CodeLocation $code_location, string $cased_function_id, ): FunctionDocblockComment { - $parsed_docblock = DocComment::parsePreservingLength($comment); + // invalid @psalm annotations are already reported by the StatementsAnalyzer + $parsed_docblock = DocComment::parsePreservingLength($comment, true); $comment_text = $comment->getText(); @@ -578,6 +579,7 @@ public static function parse( $info->variadic = isset($parsed_docblock->tags['psalm-variadic']); $info->pure = isset($parsed_docblock->tags['psalm-pure']) + || isset($parsed_docblock->tags['phpstan-pure']) || isset($parsed_docblock->tags['pure']); if (isset($parsed_docblock->tags['psalm-mutation-free'])) { diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php index 766206be73f..08e97ccda1a 100644 --- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php +++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php @@ -87,7 +87,7 @@ public function __construct( private readonly FileStorage $file_storage, private readonly Aliases $aliases, private readonly array $type_aliases, - private ?ClassLikeStorage $classlike_storage, + private readonly ?ClassLikeStorage $classlike_storage, private readonly array $existing_function_template_types, ) { $this->file_path = $file_storage->file_path; @@ -100,7 +100,7 @@ public function __construct( public function start( PhpParser\Node\FunctionLike $stmt, bool $fake_method = false, - PhpParser\Comment\Doc $doc_comment = null, + ?PhpParser\Comment\Doc $doc_comment = null, ): FunctionStorage|MethodStorage|false { if ($stmt instanceof PhpParser\Node\Expr\Closure || $stmt instanceof PhpParser\Node\Expr\ArrowFunction diff --git a/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php b/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php index b22cb9fc7fc..c9ba8ba45ba 100644 --- a/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php +++ b/src/Psalm/Internal/PhpVisitor/ReflectorVisitor.php @@ -54,7 +54,7 @@ final class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements Fi { private Aliases $aliases; - private string $file_path; + private readonly string $file_path; private readonly bool $scan_deep; @@ -88,7 +88,7 @@ final class ReflectorVisitor extends PhpParser\NodeVisitorAbstract implements Fi /** * @var SplObjectStorage */ - private SplObjectStorage $closure_statements; + private readonly SplObjectStorage $closure_statements; public function __construct( private readonly Codebase $codebase, diff --git a/src/Psalm/Internal/Provider/FakeFileProvider.php b/src/Psalm/Internal/Provider/FakeFileProvider.php index 134243213b7..24b78fac33b 100644 --- a/src/Psalm/Internal/Provider/FakeFileProvider.php +++ b/src/Psalm/Internal/Provider/FakeFileProvider.php @@ -81,7 +81,7 @@ public function deleteFile(string $file_path): void * @param null|callable(string):bool $filter * @return list */ - public function getFilesInDir(string $dir_path, array $file_extensions, callable $filter = null): array + public function getFilesInDir(string $dir_path, array $file_extensions, ?callable $filter = null): array { $file_paths = parent::getFilesInDir($dir_path, $file_extensions, $filter); diff --git a/src/Psalm/Internal/Provider/FileProvider.php b/src/Psalm/Internal/Provider/FileProvider.php index 2f26b8e32b7..91803a37061 100644 --- a/src/Psalm/Internal/Provider/FileProvider.php +++ b/src/Psalm/Internal/Provider/FileProvider.php @@ -157,7 +157,7 @@ public function isDirectory(string $file_path): bool * @param null|callable(string):bool $filter * @return list */ - public function getFilesInDir(string $dir_path, array $file_extensions, callable $filter = null): array + public function getFilesInDir(string $dir_path, array $file_extensions, ?callable $filter = null): array { $file_paths = []; diff --git a/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php b/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php index fefb161bce5..1721696a01f 100644 --- a/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php +++ b/src/Psalm/Internal/Provider/FileReferenceCacheProvider.php @@ -50,14 +50,11 @@ class FileReferenceCacheProvider private const FILE_MISSING_MEMBER_CACHE_NAME = 'file_missing_member'; private const UNKNOWN_MEMBER_CACHE_NAME = 'unknown_member_references'; private const METHOD_PARAM_USE_CACHE_NAME = 'method_param_uses'; - - protected Config $config; protected Cache $cache; - public function __construct(Config $config) + public function __construct(protected Config $config) { - $this->config = $config; - $this->cache = new Cache($config); + $this->cache = new Cache($this->config); } public function hasConfigChanged(): bool diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayFilterReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayFilterReturnTypeProvider.php index 9268d932b77..b5096323ecb 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayFilterReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayFilterReturnTypeProvider.php @@ -175,7 +175,7 @@ static function ($keyed_type) use ($statements_source, $context) { $statements_source, ); - $mapping_function_ids = array(); + $mapping_function_ids = []; if ($callable_extended_var_id) { $possibly_function_ids = $context->vars_in_scope[$callable_extended_var_id] ?? null; // @todo for array callables @@ -189,9 +189,9 @@ static function ($keyed_type) use ($statements_source, $context) { if ($function_call_arg->value instanceof PhpParser\Node\Scalar\String_ || $function_call_arg->value instanceof PhpParser\Node\Expr\Array_ || $function_call_arg->value instanceof PhpParser\Node\Expr\BinaryOp\Concat - || $mapping_function_ids !== array() + || $mapping_function_ids !== [] ) { - if ($mapping_function_ids === array()) { + if ($mapping_function_ids === []) { $mapping_function_ids = CallAnalyzer::getFunctionIdsFromCallableArg( $statements_source, $function_call_arg->value, diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php index b3bc3ad48b4..511f269f9b8 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterInputReturnTypeProvider.php @@ -14,6 +14,7 @@ use Psalm\Type\Union; use UnexpectedValueException; +use function array_flip; use function array_search; use function in_array; use function is_array; @@ -50,7 +51,16 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev throw new UnexpectedValueException('Expected StatementsAnalyzer not StatementsSource'); } - $call_args = $event->getCallArgs(); + $arg_names = array_flip(['type', 'var_name', 'filter', 'options']); + $call_args = []; + foreach ($event->getCallArgs() as $idx => $arg) { + if (isset($arg->name)) { + $call_args[$arg_names[$arg->name->name]] = $arg; + } else { + $call_args[$idx] = $arg; + } + } + $function_id = $event->getFunctionId(); $code_location = $event->getCodeLocation(); $codebase = $statements_analyzer->getCodebase(); @@ -151,13 +161,13 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev return $fails_or_not_set_type; } - $possible_types = array( - '$_GET' => INPUT_GET, - '$_POST' => INPUT_POST, + $possible_types = [ + '$_GET' => INPUT_GET, + '$_POST' => INPUT_POST, '$_COOKIE' => INPUT_COOKIE, '$_SERVER' => INPUT_SERVER, - '$_ENV' => INPUT_ENV, - ); + '$_ENV' => INPUT_ENV, + ]; $first_arg_type_type = $first_arg_type->getSingleIntLiteral(); $global_name = array_search($first_arg_type_type->value, $possible_types); @@ -201,7 +211,7 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev } if (FilterUtils::hasFlag($flags_int_used, FILTER_REQUIRE_ARRAY) - && in_array($first_arg_type_type->value, array(INPUT_COOKIE, INPUT_SERVER, INPUT_ENV), true)) { + && in_array($first_arg_type_type->value, [INPUT_COOKIE, INPUT_SERVER, INPUT_ENV], true)) { // these globals can never be an array return $fails_or_not_set_type; } diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php index 0cade67528d..f4c602d61e1 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterUtils.php @@ -234,8 +234,7 @@ public static function getOptionsArgValueOrError( // silently ignored by the function, but this usually indicates a bug IssueBuffer::maybeAdd( new InvalidArgument( - 'The "options" key in ' . $function_id - . ' must be a an array', + 'The "options" key in ' . $function_id . ' must be an array', $code_location, $function_id, ), diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php index 7d56d35cce6..f3800f26713 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/FilterVarReturnTypeProvider.php @@ -11,6 +11,7 @@ use Psalm\Type\Union; use UnexpectedValueException; +use function array_flip; use function is_array; use function is_int; @@ -39,7 +40,16 @@ public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $ev throw new UnexpectedValueException(); } - $call_args = $event->getCallArgs(); + $arg_names = array_flip(['value', 'filter', 'options']); + $call_args = []; + foreach ($event->getCallArgs() as $idx => $arg) { + if (isset($arg->name)) { + $call_args[$arg_names[$arg->name->name]] = $arg; + } else { + $call_args[$idx] = $arg; + } + } + $function_id = $event->getFunctionId(); $code_location = $event->getCodeLocation(); $codebase = $statements_analyzer->getCodebase(); diff --git a/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php b/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php index e024bc95fc0..48369076150 100644 --- a/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php +++ b/src/Psalm/Internal/Type/Comparator/AtomicTypeComparator.php @@ -10,6 +10,7 @@ use Psalm\Type\Atomic\Scalar; use Psalm\Type\Atomic\TArray; use Psalm\Type\Atomic\TCallable; +use Psalm\Type\Atomic\TCallableInterface; use Psalm\Type\Atomic\TCallableKeyedArray; use Psalm\Type\Atomic\TCallableObject; use Psalm\Type\Atomic\TCallableString; @@ -187,7 +188,8 @@ public static function isContainedBy( } if (($container_type_part instanceof TCallable - && $input_type_part instanceof TCallable) + && $input_type_part instanceof TCallableInterface + ) || ($container_type_part instanceof TClosure && $input_type_part instanceof TClosure) ) { diff --git a/src/Psalm/Internal/Type/Comparator/CallableTypeComparator.php b/src/Psalm/Internal/Type/Comparator/CallableTypeComparator.php index 047ddd08bda..8d79a41037f 100644 --- a/src/Psalm/Internal/Type/Comparator/CallableTypeComparator.php +++ b/src/Psalm/Internal/Type/Comparator/CallableTypeComparator.php @@ -20,6 +20,7 @@ use Psalm\Type\Atomic; use Psalm\Type\Atomic\TArray; use Psalm\Type\Atomic\TCallable; +use Psalm\Type\Atomic\TCallableInterface; use Psalm\Type\Atomic\TClassString; use Psalm\Type\Atomic\TClosure; use Psalm\Type\Atomic\TKeyedArray; @@ -41,15 +42,30 @@ final class CallableTypeComparator { /** - * @param TCallable|TClosure $input_type_part * @param TCallable|TClosure $container_type_part */ public static function isContainedBy( Codebase $codebase, - Atomic $input_type_part, + TClosure|TCallableInterface $input_type_part, Atomic $container_type_part, ?TypeComparisonResult $atomic_comparison_result, ): bool { + if ($container_type_part instanceof TClosure) { + if ($input_type_part instanceof TCallableInterface + && !$input_type_part instanceof TCallable // it has stricter checks below + ) { + if ($atomic_comparison_result) { + $atomic_comparison_result->type_coerced = true; + } + return false; + } + } + if ($input_type_part instanceof TCallableInterface + && !$input_type_part instanceof TCallable // it has stricter checks below + ) { + return true; + } + if ($container_type_part->is_pure && !$input_type_part->is_pure) { if ($atomic_comparison_result) { $atomic_comparison_result->type_coerced = $input_type_part->is_pure === null; diff --git a/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php b/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php index f6ba06538c7..1f5c9216c6d 100644 --- a/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php +++ b/src/Psalm/Internal/Type/Comparator/ScalarTypeComparator.php @@ -87,6 +87,11 @@ public static function isContainedBy( if ($container_type_part instanceof TNonspecificLiteralString && ($input_type_part instanceof TLiteralString || $input_type_part instanceof TNonspecificLiteralString) ) { + if ($container_type_part instanceof TNonEmptyNonspecificLiteralString) { + return ($input_type_part instanceof TLiteralString && $input_type_part->value !== '') + || $input_type_part instanceof TNonEmptyNonspecificLiteralString; + } + return true; } @@ -117,13 +122,21 @@ public static function isContainedBy( return false; } - if ($input_type_part instanceof TCallableString - && ($container_type_part::class === TSingleLetter::class - || $container_type_part::class === TNonEmptyString::class + if ($input_type_part instanceof TCallableString) { + if ($container_type_part::class === TNonEmptyString::class || $container_type_part::class === TNonFalsyString::class - || $container_type_part::class === TLowercaseString::class) - ) { - return true; + ) { + return true; + } + + if ($container_type_part::class === TLowercaseString::class + || $container_type_part::class === TSingleLetter::class + ) { + if ($atomic_comparison_result) { + $atomic_comparison_result->type_coerced = true; + } + return false; + } } if (($container_type_part instanceof TLowercaseString diff --git a/src/Psalm/Internal/Type/ParseTree/Value.php b/src/Psalm/Internal/Type/ParseTree/Value.php index 04036e7b9f2..94a434da8ea 100644 --- a/src/Psalm/Internal/Type/ParseTree/Value.php +++ b/src/Psalm/Internal/Type/ParseTree/Value.php @@ -18,7 +18,7 @@ public function __construct( public int $offset_start, public int $offset_end, ?string $text, - ParseTree $parent = null, + ?ParseTree $parent = null, ) { $this->parent = $parent; $this->text = $text === $value ? null : $text; diff --git a/src/Psalm/Internal/Type/ParseTreeCreator.php b/src/Psalm/Internal/Type/ParseTreeCreator.php index cbf5fb81800..08779fd46a0 100644 --- a/src/Psalm/Internal/Type/ParseTreeCreator.php +++ b/src/Psalm/Internal/Type/ParseTreeCreator.php @@ -30,8 +30,8 @@ use function count; use function in_array; use function preg_match; +use function str_contains; use function strlen; -use function strpos; use function strtolower; use function substr; @@ -832,7 +832,7 @@ private function handleValue(array $type_token): void $nexter_token = $this->t + 1 < $this->type_token_count ? $this->type_tokens[$this->t + 1] : null; if ($nexter_token - && strpos($nexter_token[0], '@') !== false + && str_contains($nexter_token[0], '@') && $type_token[0] !== 'list' && $type_token[0] !== 'array' ) { diff --git a/src/Psalm/Internal/Type/TypeCombiner.php b/src/Psalm/Internal/Type/TypeCombiner.php index 2abaedef7b7..e7848fd8573 100644 --- a/src/Psalm/Internal/Type/TypeCombiner.php +++ b/src/Psalm/Internal/Type/TypeCombiner.php @@ -63,7 +63,6 @@ use function array_values; use function assert; use function count; -use function get_class; use function is_int; use function is_numeric; use function min; @@ -1001,7 +1000,20 @@ private static function scrapeStringProperties( if (!$type->as_type) { $combination->class_string_types['object'] = new TObject(); } else { - $combination->class_string_types[$type->as] = $type->as_type; + if (isset($combination->class_string_types[$type->as]) + && $combination->class_string_types[$type->as] instanceof TNamedObject + ) { + if ($combination->class_string_types[$type->as]->extra_types === []) { + // do nothing, existing type is wider or the same + } elseif ($type->as_type->extra_types === []) { + $combination->class_string_types[$type->as] = $type->as_type; + } else { + // todo: figure out what to do with class-string|class-string + $combination->class_string_types[$type->as] = $type->as_type; + } + } else { + $combination->class_string_types[$type->as] = $type->as_type; + } } } elseif ($type instanceof TLiteralString) { if ($combination->strings !== null && count($combination->strings) < $literal_limit) { @@ -1151,7 +1163,7 @@ private static function scrapeStringProperties( if ($has_empty_string) { $combination->value_types['string'] = new TString(); - } elseif ($has_non_lowercase_string && get_class($type) !== TNonEmptyString::class) { + } elseif ($has_non_lowercase_string && $type::class !== TNonEmptyString::class) { $combination->value_types['string'] = new TNonEmptyString(); } else { $combination->value_types['string'] = $type; @@ -1210,9 +1222,16 @@ private static function scrapeStringProperties( ) { $combination->value_types['string'] = new TNonEmptyString(); } elseif ($type::class === TNonEmptyNonspecificLiteralString::class - && $combination->value_types['string'] instanceof TNonEmptyString + && ( + $combination->value_types['string'] instanceof TNonEmptyString + || $combination->value_types['string'] instanceof TNonspecificLiteralString + ) ) { // do nothing + } elseif ($type::class === TNonspecificLiteralString::class + && $combination->value_types['string']::class === TNonEmptyNonspecificLiteralString::class + ) { + $combination->value_types['string'] = $type; } else { $combination->value_types['string'] = new TString(); } diff --git a/src/Psalm/Internal/Type/TypeParser.php b/src/Psalm/Internal/Type/TypeParser.php index 83173ded74a..b1656a2333f 100644 --- a/src/Psalm/Internal/Type/TypeParser.php +++ b/src/Psalm/Internal/Type/TypeParser.php @@ -9,6 +9,7 @@ use Psalm\Codebase; use Psalm\Exception\TypeParseTreeException; use Psalm\Internal\Analyzer\ProjectAnalyzer; +use Psalm\Internal\Analyzer\Statements\Expression\ArrayAnalyzer; use Psalm\Internal\Type\ParseTree\CallableParamTree; use Psalm\Internal\Type\ParseTree\CallableTree; use Psalm\Internal\Type\ParseTree\CallableWithReturnTypeTree; @@ -88,7 +89,6 @@ use function defined; use function end; use function explode; -use function filter_var; use function in_array; use function is_int; use function is_numeric; @@ -103,9 +103,6 @@ use function strtolower; use function strtr; use function substr; -use function trim; - -use const FILTER_VALIDATE_INT; /** * @psalm-suppress InaccessibleProperty Allowed during construction @@ -671,11 +668,8 @@ private static function getTypeFromGenericTree( } foreach ($generic_params[0]->getAtomicTypes() as $key => $atomic_type) { - // PHP 8 values with whitespace after number are counted as numeric - // and filter_var treats them as such too if ($atomic_type instanceof TLiteralString - && ($string_to_int = filter_var($atomic_type->value, FILTER_VALIDATE_INT)) !== false - && trim($atomic_type->value) === $atomic_type->value + && ($string_to_int = ArrayAnalyzer::getLiteralArrayKeyInt($atomic_type->value)) !== false ) { $builder = $generic_params[0]->getBuilder(); $builder->removeType($key); @@ -1482,7 +1476,7 @@ private static function getTypeFromKeyedArrayTree( $property_key = $property_branch->value; } if ($is_list && ( - !is_numeric($property_key) + ArrayAnalyzer::getLiteralArrayKeyInt($property_key) === false || ($had_optional && !$property_maybe_undefined) || $type === 'array' || $type === 'callable-array' @@ -1692,7 +1686,9 @@ private static function resolveTypeAliases(Codebase $codebase, array $intersecti $normalized_intersection_types = []; $modified = false; foreach ($intersection_types as $intersection_type) { - if (!$intersection_type instanceof TTypeAlias) { + if (!$intersection_type instanceof TTypeAlias + || !$codebase->classlike_storage_provider->has($intersection_type->declaring_fq_classlike_name) + ) { $normalized_intersection_types[] = [$intersection_type]; continue; } diff --git a/src/Psalm/Issue/MissingClassConstType.php b/src/Psalm/Issue/MissingClassConstType.php new file mode 100644 index 00000000000..38f29dbcec5 --- /dev/null +++ b/src/Psalm/Issue/MissingClassConstType.php @@ -0,0 +1,11 @@ +getCodebase(); - $fqcn_parts = explode('\\', get_class($e)); + $fqcn_parts = explode('\\', $e::class); $issue_type = array_pop($fqcn_parts); if (!$project_analyzer->show_issues) { diff --git a/src/Psalm/Plugin/DynamicTemplateProvider.php b/src/Psalm/Plugin/DynamicTemplateProvider.php index c89821f0612..4049309d419 100644 --- a/src/Psalm/Plugin/DynamicTemplateProvider.php +++ b/src/Psalm/Plugin/DynamicTemplateProvider.php @@ -21,7 +21,7 @@ public function __construct( /** * If {@see DynamicFunctionStorage} requires template params this method can create it. */ - public function createTemplate(string $param_name, Union $as = null): TTemplateParam + public function createTemplate(string $param_name, ?Union $as = null): TTemplateParam { return new TTemplateParam($param_name, $as ?? Type::getMixed(), $this->defining_class); } diff --git a/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php b/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php index ed9ed33e5e6..d5d6f6af6fb 100644 --- a/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php +++ b/src/Psalm/Plugin/EventHandler/Event/BeforeFileAnalysisEvent.php @@ -23,7 +23,7 @@ public function __construct( private readonly Context $file_context, private readonly FileStorage $file_storage, private readonly Codebase $codebase, - private array $stmts, + private readonly array $stmts, ) { } diff --git a/src/Psalm/Report.php b/src/Psalm/Report.php index 675f87968d3..3f6a74cf187 100644 --- a/src/Psalm/Report.php +++ b/src/Psalm/Report.php @@ -81,4 +81,27 @@ protected function xmlEncode(string $data): string } abstract public function create(): string; + + /** + * @return array + */ + public static function getMapping(): array + { + return [ + 'checkstyle.xml' => self::TYPE_CHECKSTYLE, + 'sonarqube.json' => self::TYPE_SONARQUBE, + 'codeclimate.json' => self::TYPE_CODECLIMATE, + 'summary.json' => self::TYPE_JSON_SUMMARY, + 'junit.xml' => self::TYPE_JUNIT, + '.xml' => self::TYPE_XML, + '.sarif.json' => self::TYPE_SARIF, + '.json' => self::TYPE_JSON, + '.txt' => self::TYPE_TEXT, + '.emacs' => self::TYPE_EMACS, + '.pylint' => self::TYPE_PYLINT, + '.console' => self::TYPE_CONSOLE, + '.sarif' => self::TYPE_SARIF, + 'count.txt' => self::TYPE_COUNT, + ]; + } } diff --git a/src/Psalm/Storage/FunctionLikeStorage.php b/src/Psalm/Storage/FunctionLikeStorage.php index da1fe09ecf0..f4f7d37f341 100644 --- a/src/Psalm/Storage/FunctionLikeStorage.php +++ b/src/Psalm/Storage/FunctionLikeStorage.php @@ -257,7 +257,7 @@ public function setParams(array $params): void /** * @internal */ - public function addParam(FunctionLikeParameter $param, bool $lookup_value = null): void + public function addParam(FunctionLikeParameter $param, ?bool $lookup_value = null): void { $this->params[] = $param; $this->param_lookup[$param->name] = $lookup_value ?? true; diff --git a/src/Psalm/Type.php b/src/Psalm/Type.php index 8d2393d0dc7..9c15e14f580 100644 --- a/src/Psalm/Type.php +++ b/src/Psalm/Type.php @@ -919,7 +919,7 @@ private static function intersectAtomicTypes( ) { return $intersection_atomic; } - } catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException) { // Ignore non-existing classes during initial scan } } diff --git a/src/Psalm/Type/Atomic.php b/src/Psalm/Type/Atomic.php index 3f1aa6be384..ece578d3ccd 100644 --- a/src/Psalm/Type/Atomic.php +++ b/src/Psalm/Type/Atomic.php @@ -420,7 +420,7 @@ private static function createInner( /** * This is the string that will be used to represent the type in Union::$types. This means that two types sharing - * the same getKey value will override themselves in an Union + * the same getKey value will override themselves in a Union */ abstract public function getKey(bool $include_extra = true): string; @@ -744,7 +744,7 @@ public function replaceTemplateTypesWithStandins( TemplateResult $template_result, Codebase $codebase, ?StatementsAnalyzer $statements_analyzer = null, - Atomic $input_type = null, + ?Atomic $input_type = null, ?int $input_arg_offset = null, ?string $calling_class = null, ?string $calling_function = null, diff --git a/src/Psalm/Type/Atomic/TCallable.php b/src/Psalm/Type/Atomic/TCallable.php index cbf8141d1db..96a7150b927 100644 --- a/src/Psalm/Type/Atomic/TCallable.php +++ b/src/Psalm/Type/Atomic/TCallable.php @@ -17,7 +17,7 @@ * * @psalm-immutable */ -final class TCallable extends Atomic +final class TCallable extends Atomic implements TCallableInterface { use UnserializeMemoryUsageSuppressionTrait; use CallableTrait; diff --git a/src/Psalm/Type/Atomic/TCallableInterface.php b/src/Psalm/Type/Atomic/TCallableInterface.php new file mode 100644 index 00000000000..54e9a3b26db --- /dev/null +++ b/src/Psalm/Type/Atomic/TCallableInterface.php @@ -0,0 +1,9 @@ +properties; - $key_parts_key = str_replace('\'', '', $array_key); + $key_parts_key = $array_key; + if ($array_key[0] === '\'' || $array_key[0] === '"') { + $key_parts_key = substr($array_key, 1, -1); + } if (!isset($array_properties[$key_parts_key])) { if ($existing_key_type_part->fallback_params !== null) { @@ -1142,7 +1153,7 @@ private static function adjustTKeyedArrayType( ; } - $base_key = implode($key_parts); + $base_key = implode('', $key_parts); $result_type = $result_type->setPossiblyUndefined( $result_type->possibly_undefined || count($array_key_offsets) > 1, @@ -1175,13 +1186,14 @@ private static function adjustTKeyedArrayType( $properties = $base_atomic_type->properties; $properties[$array_key_offset] = $result_type; if ($base_atomic_type->is_list - && (!is_numeric($array_key_offset) + && (ArrayAnalyzer::getLiteralArrayKeyInt($array_key_offset) === false || ($array_key_offset && !isset($properties[$array_key_offset-1]) ) ) ) { - if ($base_atomic_type->fallback_params && is_numeric($array_key_offset)) { + if ($base_atomic_type->fallback_params + && ArrayAnalyzer::getLiteralArrayKeyInt($array_key_offset) !== false) { $fallback = $base_atomic_type->fallback_params[1]->setPossiblyUndefined( $result_type->isNever(), ); diff --git a/src/Psalm/Type/UnionTrait.php b/src/Psalm/Type/UnionTrait.php index c3ff241bdf2..40a9cf6633b 100644 --- a/src/Psalm/Type/UnionTrait.php +++ b/src/Psalm/Type/UnionTrait.php @@ -62,7 +62,7 @@ trait UnionTrait { /** - * Constructs an Union instance + * Constructs a Union instance * * @psalm-external-mutation-free * @param non-empty-array $types @@ -1245,7 +1245,7 @@ public function hasLiteralInt(): bool /** * @psalm-mutation-free - * @return bool true if this is a int literal with only one possible value + * @return bool true if this is an int literal with only one possible value */ public function isSingleIntLiteral(): bool { diff --git a/stubs/CoreGenericClasses.phpstub b/stubs/CoreGenericClasses.phpstub index f9101633c3d..85a03d0a0b8 100644 --- a/stubs/CoreGenericClasses.phpstub +++ b/stubs/CoreGenericClasses.phpstub @@ -74,7 +74,7 @@ class Generator implements Traversable { interface ArrayAccess { /** - * Whether a offset exists + * Whether an offset exists * @link http://php.net/manual/en/arrayaccess.offsetexists.php * * @param TKey $offset An offset to check for. @@ -82,6 +82,7 @@ interface ArrayAccess { * The return value will be casted to boolean if non-boolean was returned. * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayObject */ public function offsetExists($offset); @@ -94,6 +95,7 @@ interface ArrayAccess { * @psalm-ignore-nullable-return * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayObject */ public function offsetGet($offset); @@ -106,6 +108,7 @@ interface ArrayAccess { * @return void * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayObject */ public function offsetSet($offset, $value); @@ -117,6 +120,7 @@ interface ArrayAccess { * @return void * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayObject */ public function offsetUnset($offset); } @@ -162,6 +166,7 @@ class ArrayObject implements IteratorAggregate, ArrayAccess, Serializable, Count * @return bool true if the requested index exists, otherwise false * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayAccess */ public function offsetExists($offset) { } @@ -173,6 +178,7 @@ class ArrayObject implements IteratorAggregate, ArrayAccess, Serializable, Count * @return TValue The value at the specified index or false. * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayAccess */ public function offsetGet($offset) { } @@ -185,6 +191,7 @@ class ArrayObject implements IteratorAggregate, ArrayAccess, Serializable, Count * @return void * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayAccess */ public function offsetSet($offset, $value) { } @@ -196,6 +203,7 @@ class ArrayObject implements IteratorAggregate, ArrayAccess, Serializable, Count * @return void * * @since 5.0.0 + * @no-named-arguments because of conflict with ArrayAccess */ public function offsetUnset($offset) { } diff --git a/stubs/CoreGenericFunctions.phpstub b/stubs/CoreGenericFunctions.phpstub index a5f996beca3..49d41ae490f 100644 --- a/stubs/CoreGenericFunctions.phpstub +++ b/stubs/CoreGenericFunctions.phpstub @@ -1249,7 +1249,7 @@ function preg_replace_callback($pattern, $callback, $subject, int $limit = -1, & * ) * ) * ) $matches - * @return int|false + * @return int<0,max>|false * @psalm-ignore-falsable-return */ function preg_match_all($pattern, $subject, &$matches = [], int $flags = 1, int $offset = 0) {} @@ -1674,7 +1674,7 @@ function stream_select(null|array &$read, null|array &$write, null|array &$excep * @psalm-taint-escape sql * @psalm-flow ($string) -> return */ -function mysqli_escape_string($string) {} +function mysqli_escape_string(mysqli $mysqli, $string) {} /** * @psalm-pure @@ -1682,7 +1682,7 @@ function mysqli_escape_string($string) {} * @psalm-taint-escape sql * @psalm-flow ($string) -> return */ -function mysqli_real_escape_string($string) {} +function mysqli_real_escape_string(mysqli $mysqli, $string) {} /** * @psalm-pure @@ -1826,7 +1826,27 @@ function time_sleep_until(float $timestamp): bool {} */ function get_browser(?string $user_agent = null, bool $return_array = false): object|array|false {} +/** + * @psalm-taint-sink callable $callback + */ +function forward_static_call(callable $callback, mixed ...$args): mixed {} + +/** + * @psalm-taint-sink callable $callback + */ +function forward_static_call_array(callable $callback, array $args): mixed {} + +/** + * @psalm-taint-sink callable $callback + */ +function register_shutdown_function(callable $callback, mixed ...$args): void {} + +/** + * @psalm-taint-sink callable $callback + */ +function register_tick_function(callable $callback, mixed ...$args): bool {} + /** * @psalm-taint-sink extract $array */ -function extract(array &$array, int $flags = EXTR_OVERWRITE, string $prefix = ""): int {} +function extract(array &$array, int $flags = EXTR_OVERWRITE, string $prefix = ""): int {} \ No newline at end of file diff --git a/stubs/extensions/mysqli.phpstub b/stubs/extensions/mysqli.phpstub index 39566bc9592..370db68690b 100644 --- a/stubs/extensions/mysqli.phpstub +++ b/stubs/extensions/mysqli.phpstub @@ -126,6 +126,11 @@ class mysqli * @var int<-1, max>|numeric-string */ public int|string $affected_rows; + + /** + * @psalm-taint-sink sql $query + */ + public function execute_query(string $query, ?array $params = null): mysqli_result|bool {} } /** @@ -190,6 +195,11 @@ class mysqli_stmt public string $sqlstate; } +/** + * @psalm-taint-sink sql $query + */ +function mysqli_execute_query(mysqli $mysql, string $query, ?array $params = null): mysqli_result|bool {} + /** * @psalm-taint-sink callable $class * diff --git a/stubs/extensions/redis.phpstub b/stubs/extensions/redis.phpstub index d14673868cf..97fd397b2dd 100644 --- a/stubs/extensions/redis.phpstub +++ b/stubs/extensions/redis.phpstub @@ -36,6 +36,7 @@ class Redis { /** @return false|int|Redis */ public function append(string $key, string $value) {} + /** @param array{string,string}|array{string}|string $credentials */ public function auth(mixed $credentials): bool {} public function bgSave(): bool {} diff --git a/tests/AnnotationTest.php b/tests/AnnotationTest.php index 3563f7b39e0..a1eacf2344c 100644 --- a/tests/AnnotationTest.php +++ b/tests/AnnotationTest.php @@ -537,6 +537,45 @@ public function ret(): array { return []; } '$_===' => 'list', ], ], + 'invalidPsalmForMethodShouldNotBreakDocblock' => [ + 'code' => 'foo("hello"); + ', + 'assertions' => [ + '$_===' => 'non-falsy-string', + ], + 'ignored_issues' => ['InvalidDocblock'], + ], + 'invalidPsalmForFunctionShouldNotBreakDocblock' => [ + 'code' => ' [ + '$_===' => 'non-falsy-string', + ], + 'ignored_issues' => ['InvalidDocblock'], + ], 'builtInClassInAShape' => [ 'code' => ' [], 'ignored_issues' => ['UndefinedDocblockClass'], ], + 'canExtendArrayObjectOffsetSet' => [ + 'code' => <<<'PHP' + */ + class C extends ArrayObject { + public function offsetSet(mixed $key, mixed $value): void { + parent::offsetSet($key, $value); + } + } + PHP, + 'assertions' => [], + 'ignored_issues' => [], + 'php_version' => '8.0', + ], ]; } @@ -1562,6 +1581,16 @@ function takesArrayOfFloats(array $arr): void { if ($x === null) {}', 'error_message' => 'PossiblyUndefinedArrayOffset', ], + 'cannotUseNamedArgumentsForArrayAccess' => [ + 'code' => <<<'PHP' + $a */ + function f(ArrayAccess $a): void { + echo $a->offsetGet(offset: 0); + } + PHP, + 'error_message' => 'NamedArgumentNotAllowed', + ], ]; } } diff --git a/tests/ArrayAssignmentTest.php b/tests/ArrayAssignmentTest.php index 343b86506b2..8ae84b946c8 100644 --- a/tests/ArrayAssignmentTest.php +++ b/tests/ArrayAssignmentTest.php @@ -1236,6 +1236,8 @@ function takesList(array $arr) : void { foreach ($arr[0] as $k => $v) {} } }', + 'assertions' => [], + 'ignored_issues' => ['RiskyTruthyFalsyComparison'], ], 'nonEmptyAssignmentToListElement' => [ 'code' => ' [ '$line===' => 'array{0: int, ...}', ], + 'ignored_issues' => ['RiskyTruthyFalsyComparison'], ], 'arrayUnshiftOnEmptyArrayMeansNonEmptyList' => [ 'code' => ' - * + * * @template TOrig as a|b * @template TT as class-string - * + * * @template TBool as bool */ class b { - /** - * @var array + /** + * @var array */ private array $a = [123 => 123]; - + /** @var array, int> */ public array $c = []; - + /** @var array, int> */ public array $d = []; - + /** @var array */ public array $e = []; - + /** @var array>, int> */ private array $f = [123 => 123]; - + /** @var array>, int> */ private array $g = ["test" => 123]; @@ -173,7 +173,7 @@ public function test(bool $v): array { return $v ? ["a" => 123] : [123 => 123]; } } - + /** @var b<"testKey", "testValue", array<"testKey", "testValue">, b, class-string, true> */ $b = new b; $b->d["testKey"] = 123; @@ -183,6 +183,30 @@ public function test(bool $v): array { //$b->e["b"] = 123; ', ], + 'intStringKeyAsInt' => [ + 'code' => ' "a"]; + $b = ["15.7" => "a"]; + // since PHP 8 this is_numeric but will not be int key + $c = ["15 " => "a"]; + $d = ["-15" => "a"]; + // see https://github.com/php/php-src/issues/9029#issuecomment-1186226676 + $e = ["+15" => "a"]; + $f = ["015" => "a"]; + $g = ["1e2" => "a"]; + $h = ["1_0" => "a"]; + ', + 'assertions' => [ + '$a===' => "array{15: 'a'}", + '$b===' => "array{'15.7': 'a'}", + '$c===' => "array{'15 ': 'a'}", + '$d===' => "array{-15: 'a'}", + '$e===' => "array{'+15': 'a'}", + '$f===' => "array{'015': 'a'}", + '$g===' => "array{'1e2': 'a'}", + '$h===' => "array{'1_0': 'a'}", + ], + ], ]; } diff --git a/tests/AssertAnnotationTest.php b/tests/AssertAnnotationTest.php index 1e74ea862e5..4b3aae4c8e8 100644 --- a/tests/AssertAnnotationTest.php +++ b/tests/AssertAnnotationTest.php @@ -92,6 +92,36 @@ function requiresString(string $_str): void {} $this->analyzeFile('somefile.php', new Context()); } + public function testAssertsAllongCallStaticMethodWork(): void + { + $this->addFile( + 'somefile.php', + 'analyzeFile('somefile.php', new Context()); + } + public function testAssertInvalidDocblockMessageDoesNotIncludeTrace(): void { $this->expectException(CodeException::class); diff --git a/tests/CallableTest.php b/tests/CallableTest.php index 18b2f343439..d98d1b57319 100644 --- a/tests/CallableTest.php +++ b/tests/CallableTest.php @@ -1788,6 +1788,421 @@ function takesCallable(callable $c) : void {} takesCallable(function() { return; });', ], + 'callableMethodOutOfClassContextStaticPublic' => [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ + 'code' => 'run_in_c(array($this, "hello")); + } + + public function hello(): void { + echo "hello"; + } + + /** + * @param callable $callable + * @return void + */ + public function run_in_c($callable) { + call_user_func($callable); + } + }', + ], + 'callableInstanceArrayMethodClassContextNonStaticNonPublic' => [ + 'code' => 'run_in_c(array($this, "hello")); + } + + protected function hello(): void { + echo "hello"; + } + + /** + * @param callable $callable + * @return void + */ + private function run_in_c($callable) { + call_user_func($callable); + } + }', + ], + 'callableClassConstantArrayMethodClassContextStaticNonPublic' => [ + 'code' => 'run_in_c(array(Foo::class, "hello")); + } + + protected static function hello(): void { + echo "hello"; + } + + /** + * @param callable $callable + * @return void + */ + private function run_in_c($callable) { + call_user_func($callable); + } + }', + ], + 'callableClassConstantArrayMethodClassContextNonStaticNonPublic' => [ + 'code' => 'run_in_c(array(Foo::class, "hello")); + } + + protected function hello(): void { + echo "hello"; + } + + /** + * @param callable $callable + * @return void + */ + private function run_in_c($callable) { + call_user_func($callable); + } + }', + ], + 'callableClassStringArrayMethodOtherClassContextStaticPublic' => [ + 'code' => 'run_in_c(array(Foo::class, "hello")); + } + + public static function hello(): void { + echo "hello"; + } + } + + class Bar { + /** + * @param callable $callable + * @return void + */ + public function run_in_c($callable) { + call_user_func($callable); + } + } + ', + ], + 'callableInstanceArrayMethodOtherClassContextNonStaticPublic' => [ + 'code' => 'run_in_c(array($this, "hello")); + } + + public function hello(): void { + echo "hello"; + } + } + + class Bar { + /** + * @param callable $callable + * @return void + */ + public function run_in_c($callable) { + call_user_func($callable); + } + } + ', + ], + 'callableClassLiteralStringMethodOtherClassContextStaticPublic' => [ + 'code' => 'run_in_c("Foo::hello"); + } + + public static function hello(): void { + echo "hello"; + } + } + + class Bar { + /** + * @param callable $callable + * @return void + */ + public function run_in_c($callable) { + call_user_func($callable); + } + } + ', + ], + # @todo valid 'notCallableListNoUndefinedClass' => [ 'code' => ' [ + 'code' => ' [ + 'code' => ' [ 'code' => ' [], 'php_version' => '8.0', ], + 'callableArrayPassedAsCallable' => [ + 'code' => <<<'PHP' + 'InvalidFunctionCall', ], + 'callableMethodOutOfClassContextNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableClassStringArrayMethodOutOfClassContextNonStatic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableClassStringArrayMethodOutOfClassContextNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableClassStringArrayMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableClassStringMethodOutOfClassContextNonStatic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableClassStringMethodOutOfClassContextNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableClassStringMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassStringArrayMethodOutOfClassContextNonStatic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassStringArrayMethodOutOfClassContextNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassStringArrayMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassLiteralStringArrayMethodOutOfClassContextNonStatic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassLiteralStringArrayMethodOutOfClassContextNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassLiteralStringArrayMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassConstantArrayMethodOutOfClassContextNonStatic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassConstantArrayMethodOutOfClassContextNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassConstantArrayMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassStringMethodOutOfClassContextNonStatic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassStringMethodOutOfClassContextNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInClassStringMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInstanceArrayMethodClassContextPhpNativeUnsupportedNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInstanceArrayMethodOutOfClassContextNonStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableInstanceArrayMethodOutOfClassContextStaticNonPublic' => [ + 'code' => ' 'InvalidArgument', + ], + 'callableClassStringArrayMethodOtherClassContextNonStaticPublic' => [ + 'code' => 'run_in_c(array(Foo::class, "hello")); + } + + public function hello(): void { + echo "hello"; + } + } + + class Bar { + /** + * @param callable $callable + * @return void + */ + public function run_in_c($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'InvalidArgument', + ], + 'callableInstanceArrayMethodOtherClassContextNonStaticNonPublic' => [ + 'code' => 'run_in_c(array($this, "hello")); + } + + protected function hello(): void { + echo "hello"; + } + } + + class Bar { + /** + * @param callable $callable + * @return void + */ + public function run_in_c($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'InvalidArgument', + ], + 'callableClassLiteralStringMethodOtherClassContextStaticNonPublic' => [ + 'code' => 'run_in_c("Foo::hello"); + } + + protected static function hello(): void { + echo "hello"; + } + } + + class Bar { + /** + * @param callable $callable + * @return void + */ + public function run_in_c($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'InvalidArgument', + ], + # @todo invalid 'ImpureFunctionCall' => [ 'code' => ' 'InvalidArgument', ], + 'callableArrayParentConstantDeprecated' => [ + 'code' => 'run(["parent", "hello"]); + } + + /** + * @param callable $callable + * @return void + */ + public function run($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'DeprecatedConstant', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + 'callableParentConstantDeprecated' => [ + 'code' => 'run("parent::hello"); + } + + /** + * @param callable $callable + * @return void + */ + public function run($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'DeprecatedConstant', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + 'callableSelfConstantDeprecated' => [ + 'code' => 'run("self::hello"); + } + + public static function hello(): void { + echo "hello"; + } + + /** + * @param callable $callable + * @return void + */ + public function run($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'DeprecatedConstant', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + 'callableStaticConstantDeprecated' => [ + 'code' => 'run("static::hello"); + } + + public static function hello(): void { + echo "hello"; + } + + /** + * @param callable $callable + * @return void + */ + public function run($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'DeprecatedConstant', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + 'callableArrayStaticConstantDeprecated' => [ + 'code' => 'run(["static", "hello"]); + } + + public static function hello(): void { + echo "hello"; + } + + /** + * @param callable $callable + * @return void + */ + public function run($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'DeprecatedConstant', + 'ignored_issues' => [], + 'php_version' => '8.2', + ], 'invalidFirstClassCallableCannotBeInferred' => [ 'code' => ' [], 'php_version' => '8.0', ], + 'parentCallableArrayWithoutParent' => [ + 'code' => 'run(["parent", "hello"]); + } + + /** + * @param callable $callable + * @return void + */ + public function run($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'ParentNotFound', + ], + 'parentCallableWithoutParent' => [ + 'code' => 'run("parent::hello"); + } + + /** + * @param callable $callable + * @return void + */ + public function run($callable) { + call_user_func($callable); + } + }', + 'error_message' => 'ParentNotFound', + ], + 'wrongCallableInUnion' => [ + 'code' => ' 'InvalidArgument', + ], ]; } } diff --git a/tests/CastTest.php b/tests/CastTest.php index 00d1eae45c0..2aeb87b686d 100644 --- a/tests/CastTest.php +++ b/tests/CastTest.php @@ -51,5 +51,15 @@ public function providerValidCodeParse(): iterable '$a===' => 'array{a: int, b: string, ...}', ], ]; + yield 'castIntRangeToString' => [ + 'code' => ' */ + $int_range = 2; + $string = (string) $int_range; + ', + 'assertions' => [ + '$string===' => "'-1'|'-2'|'-3'|'-4'|'-5'|'0'|'1'|'2'|'3'", + ], + ]; } } diff --git a/tests/ClassTest.php b/tests/ClassTest.php index e51d3bb2bed..ff312a72460 100644 --- a/tests/ClassTest.php +++ b/tests/ClassTest.php @@ -985,6 +985,29 @@ class A {} echo A::HELLO;', 'error_message' => 'UndefinedConstant', ], + 'consistentNamesConstructor' => [ + 'code' => ' 'ParamNameMismatch', + ], 'overridePublicAccessLevelToPrivate' => [ 'code' => 'assertEquals( diff --git a/tests/Config/PluginTest.php b/tests/Config/PluginTest.php index 6127135342b..0ba40e9d83e 100644 --- a/tests/Config/PluginTest.php +++ b/tests/Config/PluginTest.php @@ -46,6 +46,10 @@ class PluginTest extends TestCase public static function setUpBeforeClass(): void { + // hack to isolate Psalm from PHPUnit cli arguments + global $argv; + $argv = []; + self::$config = new TestConfig(); if (!defined('PSALM_VERSION')) { @@ -135,14 +139,20 @@ public function testStringAnalyzerPluginWithClassConstant(): void $file_path = (string) getcwd() . '/src/somefile.php'; + $this->addFile( $file_path, - ' "Psalm\Internal\Analyzer\ProjectAnalyzer", - ]; - }', + sprintf( + <<<'PHP' + "Psalm\Internal\Analyzer\ProjectAnalyzer", + ]; + } + PHP, + $this->project_analyzer->getCodebase()->analysis_php_version_id >= 8_03_00 ? 'array' : '', + ), ); $this->analyzeFile($file_path, new Context()); @@ -175,14 +185,18 @@ public function testStringAnalyzerPluginWithClassConstantConcat(): void $this->addFile( $file_path, - ' \Psalm\Internal\Analyzer\ProjectAnalyzer::class . "::foo", - ]; - }', + sprintf( + <<<'PHP' + \Psalm\Internal\Analyzer\ProjectAnalyzer::class . "::foo", + ]; + } + PHP, + $this->project_analyzer->getCodebase()->analysis_php_version_id >= 8_03_00 ? 'array' : '', + ), ); $this->analyzeFile($file_path, new Context()); diff --git a/tests/DocblockInheritanceTest.php b/tests/DocblockInheritanceTest.php index 84c50b6366b..f007c19e36d 100644 --- a/tests/DocblockInheritanceTest.php +++ b/tests/DocblockInheritanceTest.php @@ -162,6 +162,7 @@ public function a(array|int $className): int class B extends A { + /** @param array|int|bool $className */ public function a(array|int|bool $className): int { return 0; diff --git a/tests/DocumentationTest.php b/tests/DocumentationTest.php index 1518f792b56..7745cfb07f9 100644 --- a/tests/DocumentationTest.php +++ b/tests/DocumentationTest.php @@ -320,6 +320,7 @@ public function providerInvalidCodeParse(): array case 'InvalidOverride': case 'MissingOverrideAttribute': + case 'MissingClassConstType': $php_version = '8.3'; break; } diff --git a/tests/EndToEnd/PsalmEndToEndTest.php b/tests/EndToEnd/PsalmEndToEndTest.php index 1e1f8124ab6..c92bb736f05 100644 --- a/tests/EndToEnd/PsalmEndToEndTest.php +++ b/tests/EndToEnd/PsalmEndToEndTest.php @@ -28,6 +28,7 @@ use function unlink; use const DIRECTORY_SEPARATOR; +use const PHP_BINARY; /** * Tests some of the most important use cases of the psalm and psalter commands, by launching a new @@ -120,7 +121,7 @@ public function testAlter(): void public function testPsalter(): void { $this->runPsalmInit(); - (new Process(['php', $this->psalter, '--alter', '--issues=InvalidReturnType'], self::$tmpDir))->mustRun(); + (new Process([PHP_BINARY, $this->psalter, '--alter', '--issues=InvalidReturnType'], self::$tmpDir))->mustRun(); $this->assertSame(0, $this->runPsalm([], self::$tmpDir)['CODE']); } @@ -250,7 +251,7 @@ public function testLegacyConfigWithoutresolveFromConfigFile(): void file_put_contents(self::$tmpDir . '/src/psalm.xml', $psalmXmlContent); - $process = new Process(['php', $this->psalm, '--config=src/psalm.xml'], self::$tmpDir); + $process = new Process([PHP_BINARY, $this->psalm, '--config=src/psalm.xml'], self::$tmpDir); $process->run(); $this->assertSame(2, $process->getExitCode()); $this->assertStringContainsString('InvalidReturnType', $process->getOutput()); diff --git a/tests/EndToEnd/PsalmRunnerTrait.php b/tests/EndToEnd/PsalmRunnerTrait.php index 33d82e7c7a7..950f02190e6 100644 --- a/tests/EndToEnd/PsalmRunnerTrait.php +++ b/tests/EndToEnd/PsalmRunnerTrait.php @@ -10,6 +10,8 @@ use function array_unshift; use function in_array; +use const PHP_BINARY; + trait PsalmRunnerTrait { private string $psalm = __DIR__ . '/../../psalm'; @@ -39,9 +41,9 @@ private function runPsalm( // we run `php psalm` rather than just `psalm`. if ($relyOnConfigDir) { - $process = new Process(array_merge(['php', $this->psalm, '-c=' . $workingDir . '/psalm.xml'], $args), null); + $process = new Process(array_merge([PHP_BINARY, $this->psalm, '-c=' . $workingDir . '/psalm.xml'], $args), null); } else { - $process = new Process(array_merge(['php', $this->psalm], $args), $workingDir); + $process = new Process(array_merge([PHP_BINARY, $this->psalm], $args), $workingDir); } if (!$shouldFail) { diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index c64a959265a..b551896681e 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -404,6 +404,14 @@ function foo(string $s) : void { 'assertions' => [], 'ignored_issues' => ['MixedAssignment', 'MixedArgument'], ], + 'noRedundantErrorForCallableStrToLower' => [ + 'code' => <<<'PHP' + [ 'code' => ' [ 'code' => ' [ 'code' => ' ['8.0'], 'fiber::start', + 'get_class' => ['8.3'], + 'get_parent_class' => ['8.3'], 'imagefilledpolygon', 'imagegd', 'imagegd2', @@ -97,6 +99,7 @@ class InternalCallMapHandlerTest extends TestCase 'mailparse_msg_get_structure', 'mailparse_msg_parse', 'mailparse_stream_encode', + 'mb_check_encoding' => ['8.1', '8.2', '8.3'], 'memcached::cas', // memcached 3.2.0 has incorrect reflection 'memcached::casbykey', // memcached 3.2.0 has incorrect reflection 'oauth::fetch', @@ -197,7 +200,6 @@ class InternalCallMapHandlerTest extends TestCase 'infiniteiterator::getinneriterator' => ['8.1', '8.2', '8.3'], 'iteratoriterator::getinneriterator' => ['8.1', '8.2', '8.3'], 'limititerator::getinneriterator' => ['8.1', '8.2', '8.3'], - 'locale::canonicalize' => ['8.1', '8.2', '8.3'], 'locale::getallvariants' => ['8.1', '8.2', '8.3'], 'locale::getkeywords' => ['8.1', '8.2', '8.3'], 'locale::getprimarylanguage' => ['8.1', '8.2', '8.3'], diff --git a/tests/Internal/JsonTest.php b/tests/Internal/JsonTest.php new file mode 100644 index 00000000000..2096e5a1129 --- /dev/null +++ b/tests/Internal/JsonTest.php @@ -0,0 +1,17 @@ +assertEquals('{"data":""}', Json::encode(["data" => $invalidUtf])); + } +} diff --git a/tests/InternalAnnotationTest.php b/tests/InternalAnnotationTest.php index 208ae4d5c37..c54ea686e48 100644 --- a/tests/InternalAnnotationTest.php +++ b/tests/InternalAnnotationTest.php @@ -608,6 +608,66 @@ public function baz(): void } ', ], + 'psalmInternalClassWithCallClass' => [ + 'code' => ' [ + 'code' => ' [ + 'code' => ' [ 'code' => <<<'PHP' 'InternalMethod', ], + 'psalmInternalClassWithCallClass' => [ + 'code' => ' 'InternalClass', + ], + 'psalmInternalMethodWithCallClass' => [ + 'code' => ' 'InternalMethod', + ], + 'psalmInternalMethodWithMethod' => [ + 'code' => ' 'InternalMethod', + ], ]; } } diff --git a/tests/MethodCallTest.php b/tests/MethodCallTest.php index b8719abb685..7c28032e862 100644 --- a/tests/MethodCallTest.php +++ b/tests/MethodCallTest.php @@ -1146,9 +1146,9 @@ public static function new() : self { class Datetime extends \DateTime { - public static function createFromInterface(\DateTimeInterface $datetime): static + public static function createFromInterface(\DateTimeInterface $object): static { - return parent::createFromInterface($datetime); + return parent::createFromInterface($object); } }', 'assertions' => [], diff --git a/tests/MethodSignatureTest.php b/tests/MethodSignatureTest.php index 2bb229acf8f..28d5e2a8ca8 100644 --- a/tests/MethodSignatureTest.php +++ b/tests/MethodSignatureTest.php @@ -463,13 +463,13 @@ class A implements Serializable { private $id = 1; /** - * @param string $serialized + * @param string $data */ - public function unserialize($serialized) : void + public function unserialize($data) : void { [ $this->id, - ] = (array) \unserialize($serialized); + ] = (array) \unserialize($data); } public function serialize() : string @@ -1074,6 +1074,21 @@ public function fooFoo(int $a, bool $c): void { }', 'error_message' => 'ParamNameMismatch', ], + 'differentArgumentName' => [ + 'code' => ' 'ParamNameMismatch', + ], 'nonNullableSubclassParam' => [ 'code' => ' [ 'code' => ' 'ImplementedParamTypeMismatch', diff --git a/tests/MissingClassConstTypeTest.php b/tests/MissingClassConstTypeTest.php new file mode 100644 index 00000000000..adabb5ab269 --- /dev/null +++ b/tests/MissingClassConstTypeTest.php @@ -0,0 +1,83 @@ += PHP 8.3' => [ + 'code' => <<<'PHP' + [], + 'ignored_issues' => [], + 'php_version' => '8.3', + ], + 'no type; >= PHP 8.3; but class is final' => [ + 'code' => <<<'PHP' + [], + 'ignored_issues' => [], + 'php_version' => '8.3', + ], + 'no type; >= PHP 8.3; but psalm-suppressed' => [ + 'code' => <<<'PHP' + [], + 'ignored_issues' => [], + 'php_version' => '8.3', + ], + 'no type; < PHP 8.3' => [ + 'code' => <<<'PHP' + [], + 'ignored_issues' => [], + 'php_version' => '8.2', + ], + ]; + } + + public function providerInvalidCodeParse(): iterable + { + return [ + 'no type; >= PHP 8.3' => [ + 'code' => <<<'PHP' + MissingClassConstType::getIssueType(), + 'error_levels' => [], + 'php_version' => '8.3', + ], + ]; + } +} diff --git a/tests/MixinAnnotationTest.php b/tests/MixinAnnotationTest.php index b9863d36f6b..0d45f5978a4 100644 --- a/tests/MixinAnnotationTest.php +++ b/tests/MixinAnnotationTest.php @@ -640,6 +640,21 @@ class A {} (new A)->foo;', 'error_message' => 'UndefinedPropertyFetch', ], + 'undefinedMixinClassWithPropertyFetch_WithMagicMethod' => [ + 'code' => 'foo;', + 'error_message' => 'UndefinedMagicPropertyFetch', + ], 'undefinedMixinClassWithPropertyAssignment' => [ 'code' => 'foo = "bar";', 'error_message' => 'UndefinedPropertyAssignment', ], + 'undefinedMixinClassWithPropertyAssignment_WithMagicMethod' => [ + 'code' => 'foo = "bar";', + 'error_message' => 'UndefinedMagicPropertyAssignment', + ], 'undefinedMixinClassWithMethodCall' => [ 'code' => 'foo();', 'error_message' => 'UndefinedMethod', ], + 'undefinedMixinClassWithMethodCall_WithMagicMethod' => [ + 'code' => 'foo();', + 'error_message' => 'UndefinedMagicMethod', + ], + 'undefinedMixinClassWithStaticMethodCall' => [ + 'code' => ' 'UndefinedMethod', + ], + 'undefinedMixinClassWithStaticMethodCall_WithMagicMethod' => [ + 'code' => ' 'UndefinedMagicMethod', + ], 'inheritTemplatedMixinWithSelf' => [ 'code' => ' [], 'php_version' => '8.3', ], + 'ignoreImplicitStringable' => [ + 'code' => ' + [], + 'ignored_issues' => [], + 'php_version' => '8.3', + ], ]; } @@ -192,6 +205,19 @@ public function f(): void; 'error_levels' => [], 'php_version' => '8.3', ], + 'explicitStringable' => [ + 'code' => ' + 'MissingOverrideAttribute', + 'error_levels' => [], + 'php_version' => '8.3', + ], ]; } } diff --git a/tests/ProjectCheckerTest.php b/tests/ProjectCheckerTest.php index 4d51353d503..a959ab58e59 100644 --- a/tests/ProjectCheckerTest.php +++ b/tests/ProjectCheckerTest.php @@ -43,6 +43,10 @@ class ProjectCheckerTest extends TestCase public static function setUpBeforeClass(): void { + // hack to stop Psalm seeing the phpunit arguments + global $argv; + $argv = []; + self::$config = new TestConfig(); if (!defined('PSALM_VERSION')) { diff --git a/tests/PropertyTypeTest.php b/tests/PropertyTypeTest.php index 0a6cb497b83..e447c80d28a 100644 --- a/tests/PropertyTypeTest.php +++ b/tests/PropertyTypeTest.php @@ -589,6 +589,22 @@ class A { 'MixedAssignment', ], ], + 'promotedPropertyNoExtendedConstructor' => [ + 'code' => ' [], + 'ignored_issues' => [], + 'php_version' => '8.0', + ], 'propertyWithoutTypeSuppressingIssueAndAssertingNull' => [ 'code' => ' 'PropertyNotSetInConstructor', ], + 'promotedPropertyNotSetInExtendedConstructor' => [ + 'code' => ' 'PropertyNotSetInConstructor', + 'ignored_issues' => [], + 'php_version' => '8.0', + ], 'nullableTypedPropertyNoConstructor' => [ 'code' => 'escape_string($_GET["a"]); - $b = mysqli_escape_string($_GET["b"]); + $b = mysqli_escape_string($mysqli, $_GET["b"]); $c = $mysqli->real_escape_string($_GET["c"]); - $d = mysqli_real_escape_string($_GET["d"]); + $d = mysqli_real_escape_string($mysqli, $_GET["d"]); $mysqli->query("$a$b$c$d");', ], @@ -2459,12 +2459,14 @@ public static function getPrevious(string $s): string { ], 'assertMysqliOnlyEscapesSqlTaints3' => [ 'code' => ' 'TaintedHtml', ], 'assertMysqliOnlyEscapesSqlTaints4' => [ 'code' => ' 'TaintedHtml', ], 'assertDb2OnlyEscapesSqlTaints' => [ @@ -2596,6 +2598,58 @@ function evaluateExpression(DOMXPath $xpath) : mixed { extract($_POST);', 'error_message' => 'TaintedExtract', ], + 'taintedExecuteQueryFunction' => [ + 'code' => ' 'TaintedSql', + ], + 'taintedExecuteQueryMethod' => [ + 'code' => 'execute_query($query);', + 'error_message' => 'TaintedSql', + ], + 'taintedRegisterShutdownFunction' => [ + 'code' => ' 'TaintedCallable', + ], + 'taintedRegisterTickFunction' => [ + 'code' => ' 'TaintedCallable', + ], + 'taintedForwardStaticCall' => [ + 'code' => ' 'TaintedCallable', + ], + 'taintedForwardStaticCallArray' => [ + 'code' => ' 'TaintedCallable', + ], ]; } diff --git a/tests/Template/ClassTemplateTest.php b/tests/Template/ClassTemplateTest.php index 597dd15a6be..6a25665a3c3 100644 --- a/tests/Template/ClassTemplateTest.php +++ b/tests/Template/ClassTemplateTest.php @@ -5027,6 +5027,16 @@ function getMixedCollection(MyCollection $c): MyCollection { }', 'error_message' => 'InvalidReturnStatement', ], + 'noCrashOnBrokenTemplate' => [ + 'code' => <<<'PHP' + |string + */ + class C {} + PHP, + 'error_message' => 'InvalidDocblock', + ], ]; } } diff --git a/tests/Template/ConditionalReturnTypeTest.php b/tests/Template/ConditionalReturnTypeTest.php index 24d8a1beacf..3c363d35a6c 100644 --- a/tests/Template/ConditionalReturnTypeTest.php +++ b/tests/Template/ConditionalReturnTypeTest.php @@ -1011,6 +1011,34 @@ final class SpecificObject extends stdClass {} 'ignored_issues' => [], 'php_version' => '8.1', ], + 'nonEmptyLiteralString' => [ + 'code' => ' [ + '$something' => 'int|string', + '$something2' => 'string', + ], + 'ignored_issues' => [], + ], ]; } } diff --git a/tests/TypeCombinationTest.php b/tests/TypeCombinationTest.php index a30e9e55308..faf0059b68e 100644 --- a/tests/TypeCombinationTest.php +++ b/tests/TypeCombinationTest.php @@ -942,6 +942,27 @@ public function providerTestValidTypeCombination(): array '"0"', ], ], + 'unionOfClassStringAndClassStringWithIntersection' => [ + 'class-string', + [ + 'class-string', + 'class-string', + ], + ], + 'unionNonEmptyLiteralStringAndLiteralString' => [ + 'literal-string', + [ + 'non-empty-literal-string', + 'literal-string', + ], + ], + 'unionLiteralStringAndNonEmptyLiteralString' => [ + 'literal-string', + [ + 'literal-string', + 'non-empty-literal-string', + ], + ], ]; } diff --git a/tests/TypeComparatorTest.php b/tests/TypeComparatorTest.php index 00ad55bea49..3c62dd45613 100644 --- a/tests/TypeComparatorTest.php +++ b/tests/TypeComparatorTest.php @@ -8,6 +8,7 @@ use Psalm\Internal\Provider\FakeFileProvider; use Psalm\Internal\Provider\Providers; use Psalm\Internal\RuntimeCaches; +use Psalm\Internal\Type\Comparator\TypeComparisonResult; use Psalm\Internal\Type\Comparator\UnionTypeComparator; use Psalm\Internal\Type\TypeTokenizer; use Psalm\Tests\Internal\Provider\FakeParserCacheProvider; @@ -131,6 +132,43 @@ public function testTypeDoesNotAcceptType(string $parent_type_string, string $ch ); } + /** @dataProvider getCoercibleComparisons */ + public function testTypeIsCoercible(string $parent_type_string, string $child_type_string): void + { + $parent_type = Type::parseString($parent_type_string); + $child_type = Type::parseString($child_type_string); + + $result = new TypeComparisonResult(); + + $contained = UnionTypeComparator::isContainedBy( + $this->project_analyzer->getCodebase(), + $child_type, + $parent_type, + false, + false, + $result, + ); + + $this->assertFalse($contained, 'Type ' . $parent_type_string . ' should not contain ' . $child_type_string); + $this->assertTrue( + $result->type_coerced, + 'Type ' . $parent_type_string . ' should be coercible into ' . $child_type_string, + ); + } + + /** @return iterable */ + public function getCoercibleComparisons(): iterable + { + yield 'callableStringIntoLowercaseString' => [ + 'lowercase-string', + 'callable-string', + ]; + yield 'lowercaseStringIntoCallableString' => [ + 'callable-string', + 'lowercase-string', + ]; + } + /** * @return array */ @@ -157,14 +195,26 @@ public function getSuccessfulComparisons(): array 'array{foo?: string}&array', 'array', ], - 'Lowercase-stringAndCallable-string' => [ - 'lowercase-string', - 'callable-string', - ], 'callableUnionAcceptsCallableUnion' => [ '(callable(int,string[]): void)|(callable(int): void)', '(callable(int): void)|(callable(int,string[]): void)', ], + 'callableAcceptsCallableArray' => [ + 'callable', + "callable-array{0: class-string, 1: 'from'}", + ], + 'callableAcceptsCallableObject' => [ + 'callable', + "callable-object", + ], + 'callableAcceptsCallableString' => [ + 'callable', + 'callable-string', + ], + 'callableAcceptsCallableKeyedList' => [ + 'callable', + "callable-list{class-string, 'from'}", + ], ]; } diff --git a/tests/TypeParseTest.php b/tests/TypeParseTest.php index 5250079e45a..d73fe0a1609 100644 --- a/tests/TypeParseTest.php +++ b/tests/TypeParseTest.php @@ -1158,6 +1158,14 @@ public function testIntMaskOfWithValidValueOf(): void $this->assertSame('int-mask-of>', $docblock_type->getId()); } + public function testUnionOfClassStringAndClassStringWithIntersection(): void + { + $this->assertSame( + 'class-string', + (string) Type::parseString('class-string|class-string'), + ); + } + public function testReflectionTypeParse(): void { if (!function_exists('Psalm\Tests\someFunction')) { diff --git a/tests/TypeReconciliation/EmptyTest.php b/tests/TypeReconciliation/EmptyTest.php index 9d5658d3236..8804692efab 100644 --- a/tests/TypeReconciliation/EmptyTest.php +++ b/tests/TypeReconciliation/EmptyTest.php @@ -233,6 +233,8 @@ function _processScopes($scopes) : void { function foo(array $o) : void { if (empty($o[0]) && empty($o[1])) {} }', + 'assertions' => [], + 'ignored_issues' => ['RiskyTruthyFalsyComparison'], ], 'multipleEmptiesInConditionWithMixedOffset' => [ 'code' => ' [], + 'ignored_issues' => ['RiskyTruthyFalsyComparison'], ], 'doubleEmptyCheckTwoArrays' => [ 'code' => ' [], + 'ignored_issues' => ['RiskyTruthyFalsyComparison'], ], 'doubleEmptyCheckOnTKeyedArrayVariableOffsets' => [ 'code' => ' [], + 'ignored_issues' => ['RiskyTruthyFalsyComparison'], ], 'checkArrayEmptyUnknownRoot' => [ 'code' => ' 'true', ], ], + 'emptyArrayFetch' => [ + 'code' => ' $a */ + if (empty($a["a"])) {}', + ], ]; } @@ -760,6 +773,13 @@ function bar() { }', 'error_message' => 'RedundantConditionGivenDocblockType', ], + 'redundantEmptyArrayFetch' => [ + 'code' => ' $a */; + assert(isset($a["a"])); + if (empty($a["a"])) {}', + 'error_message' => 'DocblockTypeContradiction', + ], ]; } } diff --git a/tests/TypeReconciliation/RedundantConditionTest.php b/tests/TypeReconciliation/RedundantConditionTest.php index 59c66fdfbad..19abb93e479 100644 --- a/tests/TypeReconciliation/RedundantConditionTest.php +++ b/tests/TypeReconciliation/RedundantConditionTest.php @@ -635,6 +635,8 @@ function foo(array $a) : void { if (empty($a["foo"])) {} } }', + 'assertions' => [], + 'ignored_issues' => ['RiskyTruthyFalsyComparison'], ], 'suppressRedundantConditionAfterAssertNonEmpty' => [ 'code' => ' 'DocblockTypeContradiction', ], + 'array_key_exists_int_string_juggle' => [ + 'code' => ' 'RedundantCondition', + ], ]; } } diff --git a/tests/UnusedCodeTest.php b/tests/UnusedCodeTest.php index 5fadafb7ef9..382202ad39f 100644 --- a/tests/UnusedCodeTest.php +++ b/tests/UnusedCodeTest.php @@ -759,6 +759,16 @@ function getArg(string $method) : void { } }', ], + 'ignoreJsonSerialize' => [ + 'code' => ' [ 'code' => '