├── LICENSE ├── Plugin.php ├── composer.json ├── stubs ├── globals.php └── overrides.php └── test.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Joe Hoyle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Plugin.php: -------------------------------------------------------------------------------- 1 | , deprecated: bool, minimum_invoke_args: int<0, max>} 56 | */ 57 | class Plugin implements 58 | AfterEveryFunctionCallAnalysisInterface, 59 | BeforeFileAnalysisInterface, 60 | FunctionParamsProviderInterface, 61 | FunctionReturnTypeProviderInterface, 62 | PluginEntryPointInterface { 63 | 64 | /** 65 | * @var bool 66 | */ 67 | public static $requireAllParams = false; 68 | 69 | /** 70 | * @var array{useDefaultHooks: bool, hooks: list} 71 | */ 72 | public static $configHooks = [ 73 | 'useDefaultHooks' => false, 74 | 'hooks' => [], 75 | ]; 76 | 77 | /** 78 | * @var array 79 | */ 80 | public static $hooks = []; 81 | 82 | /** 83 | * @var string[] 84 | */ 85 | public static $parseErrors = []; 86 | 87 | public function __invoke( RegistrationInterface $registration, ?SimpleXMLElement $config = null ) : void { 88 | $registration->registerHooksFromClass( static::class ); 89 | 90 | // if all possible params of an apply_filters should be required 91 | if ( isset( $config->requireAllParams['value'] ) && (string) $config->requireAllParams['value'] === 'true' ) { 92 | static::$requireAllParams = true; 93 | } 94 | 95 | // if useDefaultStubs is not set or set to anything except false, we want to load the stubs included in this plugin 96 | if ( ! isset( $config->useDefaultStubs['value'] ) || (string) $config->useDefaultStubs['value'] !== 'false' ) { 97 | array_map( [ $registration, 'addStubFile' ], $this->getStubFiles() ); 98 | } 99 | 100 | // if useDefaultHooks is not set or set to anything except false, we want to load the hooks included in this plugin 101 | if ( ! isset( $config->useDefaultHooks['value'] ) || (string) $config->useDefaultHooks['value'] !== 'false' ) { 102 | static::$configHooks['useDefaultHooks'] = true; 103 | } 104 | 105 | if ( ! empty( $config->hooks ) ) { 106 | $hooks = []; 107 | 108 | $psalm_config = Config::getInstance(); 109 | if ( $psalm_config->resolve_from_config_file ) { 110 | $base_dir = $psalm_config->base_dir; 111 | } else { 112 | $base_dir = getcwd(); 113 | } 114 | 115 | foreach ( $config->hooks as $hook_data ) { 116 | foreach ( $hook_data as $type => $data ) { 117 | if ( $type === 'file' ) { 118 | // this is a SimpleXmlElement, therefore we need to cast it to string! 119 | $file = (string) $data['name']; 120 | if ( $file[0] !== '/' ) { 121 | $file = $base_dir . '/' . $file; 122 | } 123 | 124 | if ( ! is_file( $file ) ) { 125 | throw new BadMethodCallException( 126 | sprintf( 'Hook file "%s" does not exist', $file ) 127 | ); 128 | } 129 | 130 | // File as key, to avoid loading the same hooks multiple times. 131 | $hooks[ $file ] = $file; 132 | } elseif ( $type === 'directory' ) { 133 | $directory = rtrim( (string) $data['name'], '/' ); 134 | if ( $directory[0] !== '/' ) { 135 | $directory = $base_dir . '/' . $directory; 136 | } 137 | 138 | if ( ! is_dir( $directory ) ) { 139 | throw new BadMethodCallException( 140 | sprintf( 'Hook directory "%s" does not exist', $directory ) 141 | ); 142 | } 143 | 144 | if ( isset( $data['recursive'] ) && (string) $data['recursive'] === 'true' ) { 145 | $directories = glob( $directory . '/*', GLOB_ONLYDIR ); 146 | } 147 | 148 | if ( empty( $directories ) ) { 149 | $directories = [ $directory ]; 150 | } else { 151 | /** @var string[] $directories */ 152 | $directories[] = $directory; 153 | 154 | // Might have duplicates if the directory is explicitly 155 | // specified and also passed in recursive directory. 156 | $directories = array_unique( $directories ); 157 | } 158 | 159 | foreach ( $directories as $directory ) { 160 | foreach ( [ 'actions', 'filters', 'hooks' ] as $file_name ) { 161 | $file_path = $directory . '/' . $file_name . '.json'; 162 | if ( is_file( $file_path ) ) { 163 | $hooks[ $file_path ] = $file_path; 164 | } 165 | } 166 | } 167 | } 168 | } 169 | } 170 | 171 | // Don't need the keys anymore and ensures array_merge runs smoothly later on. 172 | static::$configHooks['hooks'] = array_values( $hooks ); 173 | } 174 | 175 | static::loadStubbedHooks(); 176 | } 177 | 178 | /** 179 | * Resolves a vendor-relative directory path to the absolute package directory. 180 | * 181 | * The plugin must run both from the source file in the repository (current working directory) 182 | * as well as when required as a composer package when the current working directory may not 183 | * have a vendor/ folder and the package directory is detected relative to this file. 184 | * 185 | * @param string $path Path of a folder, relative, inside `vendor/` (Composer). 186 | * Must start with `vendor/` marker. 187 | */ 188 | private static function getVendorDir( string $path ) : string { 189 | $vendor = 'vendor/'; 190 | $self = 'humanmade/psalm-plugin-wordpress'; 191 | 192 | if ( 0 !== strpos( $path, $vendor ) ) { 193 | throw new BadMethodCallException( 194 | sprintf( '$path must start with "%s", "%s" given', $vendor, $path ) 195 | ); 196 | } 197 | 198 | $cwd = getcwd(); 199 | 200 | // Prefer path relative to current working directory (original default). 201 | $cwd_path = $cwd . '/' . $path; 202 | if ( is_dir( $cwd_path ) ) { 203 | return $cwd_path; 204 | } 205 | 206 | // Check running as composer package inside a vendor folder. 207 | $pkg_self_dir = __DIR__; 208 | $vendor_dir = dirname( $pkg_self_dir, 2 ); 209 | if ( $pkg_self_dir === $vendor_dir . '/' . $self ) { 210 | // Likely plugin is running as composer package, let's try for the path. 211 | $pkg_path = substr( $path, strlen( $vendor ) ); 212 | $vendor_path = $vendor_dir . '/' . $pkg_path; 213 | if ( is_dir( $vendor_path ) ) { 214 | return $vendor_path; 215 | } 216 | } 217 | 218 | // Original default behaviour. 219 | return $cwd_path; 220 | } 221 | 222 | /** 223 | * @return string[] 224 | */ 225 | private function getStubFiles() : array { 226 | return [ 227 | self::getVendorDir( 'vendor/php-stubs/wordpress-stubs' ) . '/wordpress-stubs.php', 228 | self::getVendorDir( 'vendor/php-stubs/wordpress-globals' ) . '/wordpress-globals.php', 229 | self::getVendorDir( 'vendor/php-stubs/wp-cli-stubs' ) . '/wp-cli-stubs.php', 230 | self::getVendorDir( 'vendor/php-stubs/wp-cli-stubs' ) . '/wp-cli-commands-stubs.php', 231 | self::getVendorDir( 'vendor/php-stubs/wp-cli-stubs' ) . '/wp-cli-i18n-stubs.php', 232 | __DIR__ . '/stubs/globals.php', 233 | __DIR__ . '/stubs/overrides.php', 234 | ]; 235 | } 236 | 237 | protected static function loadStubbedHooks() : void { 238 | if ( static::$hooks ) { 239 | return; 240 | } 241 | 242 | if ( static::$configHooks['useDefaultHooks'] !== false ) { 243 | $wp_hooks_data_dir = self::getVendorDir( 'vendor/wp-hooks/wordpress-core/hooks' ); 244 | 245 | static::loadHooksFromFile( $wp_hooks_data_dir . '/actions.json' ); 246 | static::loadHooksFromFile( $wp_hooks_data_dir . '/filters.json' ); 247 | } 248 | 249 | foreach ( static::$configHooks['hooks'] as $file ) { 250 | static::loadHooksFromFile( $file ); 251 | } 252 | } 253 | 254 | protected static function loadHooksFromFile( string $filepath ) : void { 255 | $data = json_decode( file_get_contents( $filepath ), true ); 256 | if ( ! isset( $data['hooks'] ) || ! is_array( $data['hooks'] ) ) { 257 | static::$parseErrors[] = 'Invalid hook file ' . $filepath; 258 | return; 259 | } 260 | 261 | /** 262 | * @var list, 264 | * name: string, 265 | * aliases?: list, 266 | * file: string, 267 | * type: 'action'|'action_reference'|'action_deprecated'|'filter'|'filter_reference'|'filter_deprecated', 268 | * doc: array{ 269 | * description: string, 270 | * long_description: string, 271 | * long_description_html: string, 272 | * tags: list}> 273 | * } 274 | * }> 275 | */ 276 | $hooks = $data['hooks']; 277 | 278 | $plugin_slug = basename( dirname( $filepath ) ); 279 | foreach ( $hooks as $hook ) { 280 | $params = array_filter( $hook['doc']['tags'], function ( $tag ) { 281 | return $tag['name'] === 'param'; 282 | }); 283 | 284 | $params = array_map( function ( array $param ) : array { 285 | if ( isset( $param['types'] ) && $param['types'] !== [ 'array' ] ) { 286 | return $param; 287 | } 288 | 289 | if ( substr_count( $param['content'], '{' ) !== 1 ) { 290 | // Unable to parse nested array style PHPDoc. 291 | return $param; 292 | } 293 | 294 | // ? after variable name is kept, to mark it optional 295 | // sometimes a hyphen is used in the "variable" here, e.g. "$update-supported" 296 | $found = preg_match_all( '/@type\s+([^ ]+)\s+\$([\w-]+\??)/', $param['content'], $matches, PREG_SET_ORDER ); 297 | if ( ! $found ) { 298 | return $param; 299 | } 300 | 301 | $array_properties = []; 302 | foreach ( $matches as $match ) { 303 | // use as property as key to avoid setting the same property twice in case it is incorrectly set twice 304 | $array_properties[ $match[2] ] = $match[2] . ': ' . $match[1]; 305 | } 306 | 307 | $array_string = 'array{ ' . implode( ', ', $array_properties ) . ' }'; 308 | $param['types'] = [ $array_string ]; 309 | return $param; 310 | }, $params ); 311 | 312 | $types = array_column( $params, 'types' ); 313 | 314 | // remove empty elements which can happen with invalid phpdoc - must be done before parseString to avoid notice there 315 | $types = array_filter( $types ); 316 | 317 | $types = array_map( function ( $type ) : string { 318 | natcasesort( $type ); 319 | return implode( '|', $type ); 320 | }, $types ); 321 | 322 | // not all types are documented, assume "mixed" for undocumented 323 | if ( count( $types ) < $hook['args'] ) { 324 | $fill_types = array_fill( 0, $hook['args'], 'mixed' ); 325 | $types = $types + $fill_types; 326 | ksort( $types ); 327 | } 328 | 329 | // skip invalid ones 330 | try { 331 | $parsed_types = array_map( [ Type::class, 'parseString' ], $types ); 332 | } catch ( TypeParseTreeException $e ) { 333 | static::$parseErrors[] = $e->getMessage() . ' for hook ' . $hook['name'] . ' of hook file ' . $filepath . ' in ' . $plugin_slug . '/' . $hook['file']; 334 | 335 | continue; 336 | } 337 | 338 | if ( $hook['type'] === 'filter_deprecated' ) { 339 | $is_deprecated = true; 340 | $hook['type'] = 'filter'; 341 | } elseif ( $hook['type'] === 'action_deprecated' ) { 342 | $is_deprecated = true; 343 | $hook['type'] = 'action'; 344 | } else { 345 | $deprecated_tags = array_filter( $hook['doc']['tags'], function ( $tag ) { 346 | return $tag['name'] === 'deprecated'; 347 | }); 348 | $is_deprecated = $deprecated_tags === [] ? false : true; 349 | } 350 | 351 | static::registerHook( $hook['name'], $parsed_types, $hook['type'], $is_deprecated ); 352 | 353 | if ( isset( $hook['aliases'] ) ) { 354 | foreach ( $hook['aliases'] as $alias_name ) { 355 | static::registerHook( $alias_name, $parsed_types, $hook['type'], $is_deprecated ); 356 | } 357 | } 358 | } 359 | } 360 | 361 | public static function beforeAnalyzeFile( BeforeFileAnalysisEvent $event ) : void { 362 | $file_path = $event->getStatementsSource()->getFilePath(); 363 | $statements = $event->getCodebase()->getStatementsForFile( $file_path ); 364 | $traverser = new PhpParser\NodeTraverser; 365 | $hook_visitor = new HookNodeVisitor(); 366 | $traverser->addVisitor( $hook_visitor ); 367 | try { 368 | $traverser->traverse( $statements ); 369 | } catch ( Exception $e ) { 370 | 371 | } 372 | 373 | foreach ( $hook_visitor->hooks as $hook ) { 374 | static::registerHook( $hook['name'], $hook['types'], $hook['hook_type'], $hook['deprecated'] ); 375 | } 376 | } 377 | 378 | public static function getDynamicHookName( object $arg ) : ?string { 379 | // variable or 'foo' . $bar variable hook name 380 | // "foo_{$my_var}_bar" is the wp-hooks-generator style, we need to mimic 381 | if ( $arg instanceof Variable ) { 382 | return '{$' . $arg->name . '}'; 383 | } 384 | 385 | if ( $arg instanceof PhpParser\Node\Scalar\Encapsed || $arg instanceof PhpParser\Node\Scalar\InterpolatedString ) { 386 | $hook_name = ''; 387 | foreach ( $arg->parts as $part ) { 388 | $resolved_part = static::getDynamicHookName( $part ); 389 | if ( $resolved_part === null ) { 390 | return null; 391 | } 392 | 393 | $hook_name .= $resolved_part; 394 | } 395 | 396 | return $hook_name; 397 | } 398 | 399 | if ( $arg instanceof PhpParser\Node\Expr\BinaryOp\Concat ) { 400 | $hook_name = static::getDynamicHookName( $arg->left ); 401 | if ( is_null( $hook_name ) ) { 402 | return null; 403 | } 404 | 405 | $temp = static::getDynamicHookName( $arg->right ); 406 | if ( is_null( $temp ) ) { 407 | return null; 408 | } 409 | 410 | $hook_name .= $temp; 411 | 412 | return $hook_name; 413 | } 414 | 415 | if ( $arg instanceof String_ || $arg instanceof PhpParser\Node\Scalar\EncapsedStringPart || $arg instanceof PhpParser\Node\InterpolatedStringPart || $arg instanceof PhpParser\Node\Scalar\LNumber || $arg instanceof PhpParser\Node\Scalar\Int_ ) { 416 | return $arg->value; 417 | } 418 | 419 | if ( $arg instanceof PhpParser\Node\Expr\StaticPropertyFetch ) { 420 | // @todo the WP hooks generator doesn't support that yet and handling needs to be added for it there first 421 | // e.g. self::$foo 422 | return null; 423 | } 424 | 425 | if ( $arg instanceof PhpParser\Node\Expr\StaticCall ) { 426 | if ( ! $arg->name instanceof PhpParser\Node\Identifier ) { 427 | throw new UnexpectedValueException( 'Unsupported dynamic hook name with name type ' . get_class( $arg->name ) . ' on line ' . $arg->getStartLine(), 0 ); 428 | } 429 | 430 | // hook name with Foo:bar() 431 | if ( $arg->class instanceof Name ) { 432 | // @todo this can be handled here, however the WP hooks generator creates output that does not match the other format at all and needs to be fixed first 433 | return null; 434 | } 435 | 436 | // need to check recursively 437 | $temp = static::getDynamicHookName( $arg->class ); 438 | if ( is_null( $temp ) ) { 439 | throw new UnexpectedValueException( 'Unsupported dynamic hook name with class type ' . get_class( $arg->class ) . ' on line ' . $arg->getStartLine(), 0 ); 440 | } 441 | 442 | $append_method_call = '()'; 443 | return rtrim( $temp, '}' ) . '::' . $arg->name->toString() . $append_method_call . '}'; 444 | } 445 | 446 | if ( $arg instanceof PhpParser\Node\Expr\PropertyFetch || $arg instanceof PhpParser\Node\Expr\MethodCall ) { 447 | if ( ! $arg->name instanceof PhpParser\Node\Identifier ) { 448 | throw new UnexpectedValueException( 'Unsupported dynamic hook name with name type ' . get_class( $arg->name ) . ' on line ' . $arg->getStartLine(), 0 ); 449 | } 450 | 451 | // need to check recursively 452 | $temp = static::getDynamicHookName( $arg->var ); 453 | if ( is_null( $temp ) ) { 454 | throw new UnexpectedValueException( 'Unsupported dynamic hook name with var type ' . get_class( $arg->var ) . ' on line ' . $arg->getStartLine(), 0 ); 455 | } 456 | 457 | $append_method_call = $arg instanceof PhpParser\Node\Expr\MethodCall ? '()' : ''; 458 | 459 | return rtrim( $temp, '}' ) . '->' . $arg->name->toString() . $append_method_call . '}'; 460 | } 461 | 462 | if ( $arg instanceof FuncCall ) { 463 | // mostly relevant for add_action - can just assume any variable name without using the function name, since it's useless (e.g. basename, dirname,... are common ones) 464 | return '{$variable}'; 465 | } 466 | 467 | if ( $arg instanceof PhpParser\Node\Expr\ArrayDimFetch ) { 468 | $key_hook_name = static::getDynamicHookName( $arg->dim ); 469 | if ( is_null( $key_hook_name ) ) { 470 | throw new UnexpectedValueException( 'Unsupported dynamic hook name with key type ' . get_class( $arg->dim ) . ' on line ' . $arg->getStartLine(), 0 ); 471 | } 472 | 473 | // need to check recursively 474 | $temp = static::getDynamicHookName( $arg->var ); 475 | if ( is_null( $temp ) ) { 476 | throw new UnexpectedValueException( 'Unsupported dynamic hook name with var type ' . get_class( $arg->var ) . ' on line ' . $arg->getStartLine(), 0 ); 477 | } 478 | 479 | if ( $key_hook_name[0] === '{' ) { 480 | $key = trim( $key_hook_name, '{}' ); 481 | } elseif ( is_numeric( $key_hook_name ) ) { 482 | $key = $key_hook_name; 483 | } else { 484 | $key = "'" . $key_hook_name . "'"; 485 | } 486 | 487 | return rtrim( $temp, '}' ) . '[' . $key . ']}'; 488 | } 489 | 490 | // isn't actually supported by the wp-hooks-generator yet and will be handled as regular string there 491 | // just handle it generically here for the time being 492 | if ( $arg instanceof PhpParser\Node\Expr\ConstFetch || $arg instanceof PhpParser\Node\Expr\ClassConstFetch ) { 493 | return '{$variable}'; 494 | } 495 | 496 | // other types not supported yet 497 | // add handling if encountered @todo 498 | throw new UnexpectedValueException( 'Unsupported dynamic hook name with type ' . get_class( $arg ) . ' on line ' . $arg->getStartLine(), 0 ); 499 | } 500 | 501 | /** 502 | * @return Hook|null 503 | */ 504 | public static function getDynamicHookData( string $hook_name, bool $is_action_not_filter = false ) : ?array { 505 | // fully dynamic hooks like {$tag} cannot be used here, as they would match many hooks 506 | // same for {$tag}_{$hello} 507 | $normalized_hook_name = preg_replace( '/{(?:[^{}]+|(?R))*+}/', '{$abc}', $hook_name ); 508 | $hook_dynamic_removed = ltrim( $normalized_hook_name, '_' ); 509 | if ( empty( $hook_dynamic_removed ) ) { 510 | return null; 511 | } 512 | 513 | // register dynamic actions - here we can use the static name from the add_action to register it 514 | // this ensures that add_action for those will give correct error (e.g. if the 4th argument is not 0 for ajax) and we don't need to ignore these 515 | $all_hook_names = array_keys( static::$hooks ); 516 | // dynamic hooks exist as some_{$variable}_text 517 | $dynamic_hook_names = preg_grep( '/{\$/', $all_hook_names ); 518 | 519 | // normalize variable name length, to ensure longer variable names don't cause wrong sorting leading to incorrect replacements later on 520 | $normalized_dynamic_hook_names = preg_replace( '/{(?:[^{}]+|(?R))*+}/', '{$abc}', $dynamic_hook_names ); 521 | 522 | $dynamic_hook_names = array_combine( $dynamic_hook_names, $normalized_dynamic_hook_names ); 523 | 524 | // sort descending from longest to shortest, to avoid shorter dynamic hooks accidentally matching 525 | uasort( $dynamic_hook_names, function( string $a, string $b ) { 526 | return strlen( $b ) - strlen( $a ); 527 | }); 528 | 529 | // the hook name has a variable, so we first check it against hooks that use a variable too only, to see if we get a match here already 530 | $dynamic_hook_name_key = array_search( $normalized_hook_name, $dynamic_hook_names, true ); 531 | if ( $dynamic_hook_name_key !== false && $is_action_not_filter && static::$hooks[ $dynamic_hook_name_key ]['hook_type'] !== 'action' ) { 532 | // action used as filter 533 | return null; 534 | } elseif ( $dynamic_hook_name_key !== false && ! $is_action_not_filter && static::$hooks[ $dynamic_hook_name_key ]['hook_type'] !== 'filter' ) { 535 | // filter used as action 536 | return null; 537 | } elseif ( $dynamic_hook_name_key !== false ) { 538 | $dynamic_hook = static::$hooks[ $dynamic_hook_name_key ]; 539 | 540 | // it's already in the correct format, so we just need to assign it to the non-dynamic name 541 | static::$hooks[ $hook_name ] = [ 542 | 'hook_type' => $dynamic_hook['hook_type'], 543 | 'types' => $dynamic_hook['types'], 544 | 'deprecated' => $dynamic_hook['deprecated'], 545 | 'minimum_invoke_args' => $dynamic_hook['minimum_invoke_args'], 546 | ]; 547 | 548 | return static::$hooks[ $hook_name ]; 549 | } 550 | 551 | foreach ( $dynamic_hook_names as $dynamic_hook_name => $normalized_dynamic_hook_name ) { 552 | if ( $is_action_not_filter && ! in_array( static::$hooks[ $dynamic_hook_name ]['hook_type'], [ 'action', 'action_reference', 'action_deprecated' ], true ) ) { 553 | // don't match actions with filters here, so we will get an error later on 554 | continue; 555 | } 556 | 557 | if ( ! $is_action_not_filter && ! in_array( static::$hooks[ $dynamic_hook_name ]['hook_type'], [ 'filter', 'filter_reference', 'filter_deprecated' ], true ) ) { 558 | continue; 559 | } 560 | 561 | // fully dynamic hooks like {$tag} cannot be used here, as they would match all hooks 562 | // same for {$tag}_{$hello} 563 | $dynamic_removed = ltrim( str_replace( '{$abc}', '', $normalized_dynamic_hook_name ), '_' ); 564 | if ( empty( $dynamic_removed ) ) { 565 | continue; 566 | } 567 | 568 | // need to escape it beforehand, since we insert regex into it 569 | $preg_hook_name = preg_quote( $normalized_dynamic_hook_name, '/' ); 570 | // dot for dynamic hook names, e.g. load-edit.php 571 | // may contain a variable if the hook name is dynamic too 572 | $preg_hook_name = str_replace( '\{\$abc\}', '({\$)?[\w:>.[\]\'$\/-]+}?', $preg_hook_name ); 573 | 574 | if ( preg_match( '/^' . $preg_hook_name . '$/', $hook_name ) === 1 ) { 575 | $dynamic_hook = static::$hooks[ $dynamic_hook_name ]; 576 | 577 | // it's already in the correct format, so we just need to assign it to the non-dynamic name 578 | static::$hooks[ $hook_name ] = [ 579 | 'hook_type' => $dynamic_hook['hook_type'], 580 | 'types' => $dynamic_hook['types'], 581 | 'deprecated' => $dynamic_hook['deprecated'], 582 | 'minimum_invoke_args' => $dynamic_hook['minimum_invoke_args'], 583 | ]; 584 | 585 | return static::$hooks[ $hook_name ]; 586 | } 587 | } 588 | 589 | return null; 590 | } 591 | 592 | public static function afterEveryFunctionCallAnalysis( AfterEveryFunctionCallAnalysisEvent $event ) : void { 593 | $apply_filter_functions = [ 594 | 'apply_filters', 595 | 'apply_filters_ref_array', 596 | 'apply_filters_deprecated', 597 | ]; 598 | 599 | $do_action_functions = [ 600 | 'do_action', 601 | 'do_action_ref_array', 602 | 'do_action_deprecated', 603 | ]; 604 | 605 | $function_id = $event->getFunctionId(); 606 | if ( in_array( $function_id, $apply_filter_functions, true ) ) { 607 | $hook_type = 'filter'; 608 | } elseif ( in_array( $function_id, $do_action_functions, true ) ) { 609 | $hook_type = 'action'; 610 | } elseif ( preg_match( '/_deprecated_hook$/', $function_id ) !== 1 ) { 611 | // there are custom implementations of "deprecated_hook", e.g. for "wcs", so we need to preg match it 612 | return; 613 | } 614 | 615 | $call_args = $event->getExpr()->getArgs(); 616 | if ( ! isset( $call_args[0] ) ) { 617 | return; 618 | } 619 | 620 | if ( ! $call_args[0]->value instanceof String_ ) { 621 | $statements_source = $event->getStatementsSource(); 622 | try { 623 | $union = $statements_source->getNodeTypeProvider()->getType( $call_args[0]->value ); 624 | } catch (UnexpectedValueException $e) { 625 | $union = null; 626 | } 627 | if ( ! $union ) { 628 | $union = static::getTypeFromArg( $call_args[0]->value, $event->getContext(), $statements_source ); 629 | } 630 | 631 | $potential_hook_name = false; 632 | if ( $union && $union->isSingleStringLiteral() ) { 633 | $potential_hook_name = $union->getSingleStringLiteral()->value; 634 | } 635 | 636 | if ( $potential_hook_name && isset( static::$hooks[ $potential_hook_name ] ) ) { 637 | $hook_name = $potential_hook_name; 638 | } else { 639 | $hook_name = static::getDynamicHookName( $call_args[0]->value ); 640 | if ( is_null( $hook_name ) && ! $potential_hook_name ) { 641 | return; 642 | } 643 | 644 | if ( $potential_hook_name ) { 645 | if ( is_null( $hook_name ) || ! isset( static::$hooks[ $hook_name ] ) ) { 646 | // if it's not registered yet, use the resolved hook name 647 | $hook_name = $potential_hook_name; 648 | } elseif ( isset( static::$hooks[ $hook_name ] ) ) { 649 | // if it's registered already, store the resolved name hook too 650 | static::$hooks[ $potential_hook_name ] = static::$hooks[ $hook_name ]; 651 | } 652 | } 653 | } 654 | } else { 655 | $hook_name = $call_args[0]->value->value; 656 | } 657 | 658 | if ( preg_match( '/_deprecated_hook$/', $function_id ) === 1 ) { 659 | // hook type is irrelevant and won't be used when overriding 660 | // in case the hook is not registered yet, it will eventually be registered and the hook type and types will be set 661 | static::registerHook( $hook_name, [], '', true ); 662 | return; 663 | } 664 | 665 | // Check if this hook is already documented. 666 | if ( isset( static::$hooks[ $hook_name ] ) ) { 667 | if ( 668 | ! in_array( $function_id, ['apply_filters_deprecated', 'do_action_deprecated'], true ) && 669 | static::$hooks[ $hook_name ]['deprecated'] === true 670 | ) { 671 | $statements_source = $event->getStatementsSource(); 672 | $code_location = new CodeLocation( $event->getStatementsSource(), $event->getExpr() ); 673 | $suggestion = $hook_type === 'filter' ? 'apply_filters_deprecated' : 'do_action_deprecated'; 674 | IssueBuffer::accepts( 675 | new DeprecatedHook( 676 | 'Hook "' . $hook_name . '" is deprecated. If you still need this, check if there is a replacement for it. Otherwise, if this is a 3rd party ' . $hook_type . ', you can remove it. If it is your own/custom, please use "' . $suggestion . '" here instead and add an "@deprecated new" comment in the phpdoc', 677 | $code_location 678 | ), 679 | $statements_source->getSuppressedIssues() 680 | ); 681 | } 682 | return; 683 | } 684 | 685 | $statements_source = $event->getStatementsSource(); 686 | $types = array_map( function ( Arg $arg ) use ( $statements_source ) { 687 | try { 688 | $type = $statements_source->getNodeTypeProvider()->getType( $arg->value ); 689 | } catch ( UnexpectedValueException $e ) { 690 | $type = null; 691 | } 692 | 693 | if ( ! $type ) { 694 | $type = Type::getMixed(); 695 | } else { 696 | $sub_types = array_values( $type->getAtomicTypes() ); 697 | $sub_types = array_map( function ( Atomic $type ) : Atomic { 698 | if ( $type instanceof Atomic\TTrue || $type instanceof Atomic\TFalse ) { 699 | return new Atomic\TBool; 700 | } elseif ( $type instanceof Atomic\TLiteralString ) { 701 | return new Atomic\TString; 702 | } elseif ( $type instanceof Atomic\TLiteralInt ) { 703 | return new Atomic\TInt; 704 | } elseif ( $type instanceof Atomic\TLiteralFloat ) { 705 | return new Atomic\TFloat; 706 | } 707 | 708 | return $type; 709 | }, $sub_types ); 710 | $type = new Union( $sub_types ); 711 | } 712 | 713 | return $type; 714 | }, array_slice( $call_args, 1 ) ); 715 | 716 | $is_deprecated = false; 717 | if ( in_array( $function_id, ['apply_filters_deprecated', 'do_action_deprecated'], true ) ) { 718 | $is_deprecated = true; 719 | } 720 | 721 | static::registerHook( $hook_name, $types, $hook_type, $is_deprecated ); 722 | } 723 | 724 | /** 725 | * @return non-empty-list 726 | */ 727 | public static function getFunctionIds() : array { 728 | return [ 729 | 'add_action', 730 | 'add_filter', 731 | 'do_action', 732 | 'do_action_ref_array', 733 | 'do_action_deprecated', 734 | 'apply_filters', 735 | 'apply_filters_ref_array', 736 | 'apply_filters_deprecated', 737 | 'did_action', 738 | 'did_filter', 739 | 'doing_action', 740 | 'doing_filter', 741 | 'has_action', 742 | 'has_filter', 743 | 'remove_action', 744 | 'remove_filter', 745 | 'remove_all_actions', 746 | 'remove_all_filters', 747 | 'wp_parse_url', 748 | ]; 749 | } 750 | 751 | /** 752 | * @return ?array 753 | */ 754 | public static function getFunctionParams( FunctionParamsProviderEvent $event ) : ?array { 755 | $function_id = $event->getFunctionId(); 756 | if ( ! in_array( $function_id, static::getFunctionIds(), true ) ) { 757 | return null; 758 | } 759 | 760 | if ( $function_id === 'wp_parse_url' ) { 761 | return null; 762 | } 763 | 764 | // @todo not supported yet below 765 | if ( in_array( $function_id, ['do_action_ref_array', 'do_action_deprecated', 'apply_filters_ref_array', 'apply_filters_deprecated'], true ) ) { 766 | return null; 767 | } 768 | 769 | static::loadStubbedHooks(); 770 | 771 | $statements_source = $event->getStatementsSource(); 772 | $code_location = $event->getCodeLocation(); 773 | 774 | // output any parse errors 775 | foreach ( static::$parseErrors as $error_message ) { 776 | // do not allow suppressing this 777 | IssueBuffer::accepts( 778 | new InvalidDocblock( 779 | $error_message, 780 | $code_location // this can be completely wrong (even completely wrong file), since the parse error might be taken from the hooks file, so ideally we would create the correct code location before adding to $parseErrors 781 | ) 782 | ); 783 | } 784 | static::$parseErrors = []; 785 | 786 | $is_action = $function_id === 'add_action'; 787 | $is_do_action = $function_id === 'do_action'; 788 | $is_action_not_filter = $is_action || $is_do_action; 789 | $is_invoke = $is_do_action || $function_id === 'apply_filters'; 790 | 791 | $is_utility = false; 792 | if ( 793 | in_array( 794 | $function_id, 795 | array( 796 | 'did_action', 797 | 'doing_action', 798 | 'has_action', 799 | 'remove_action', 800 | 'remove_all_actions', 801 | ), 802 | true 803 | ) 804 | ) { 805 | $is_utility = true; 806 | $is_action_not_filter = true; 807 | } elseif ( 808 | in_array( $function_id, 809 | array( 810 | 'did_filter', 811 | 'doing_filter', 812 | 'has_filter', 813 | 'remove_filter', 814 | 'remove_all_filters', 815 | ), 816 | true 817 | ) 818 | ) { 819 | $is_utility = true; 820 | } 821 | 822 | $call_args = $event->getCallArgs(); 823 | if ( ! isset( $call_args[0] ) ) { 824 | return null; 825 | } 826 | 827 | if ( ! $call_args[0]->value instanceof String_ ) { 828 | try { 829 | $union = $statements_source->getNodeTypeProvider()->getType( $call_args[0]->value ); 830 | } catch (UnexpectedValueException $e) { 831 | $union = null; 832 | } 833 | if ( ! $union ) { 834 | $union = static::getTypeFromArg( $call_args[0]->value, $event->getContext(), $statements_source ); 835 | } 836 | 837 | $potential_hook_name = false; 838 | if ( $union && $union->isSingleStringLiteral() ) { 839 | $potential_hook_name = $union->getSingleStringLiteral()->value; 840 | } 841 | 842 | if ( $potential_hook_name && isset( static::$hooks[ $potential_hook_name ] ) ) { 843 | $hook_name = $potential_hook_name; 844 | } else { 845 | $hook_name = static::getDynamicHookName( $call_args[0]->value ); 846 | if ( is_null( $hook_name ) && ! $potential_hook_name ) { 847 | return null; 848 | } 849 | 850 | if ( $potential_hook_name ) { 851 | if ( is_null( $hook_name ) || ! isset( static::$hooks[ $hook_name ] ) ) { 852 | $hook_name = $potential_hook_name; 853 | } 854 | } 855 | } 856 | } else { 857 | $hook_name = $call_args[0]->value->value; 858 | } 859 | 860 | $hook = static::$hooks[ $hook_name ] ?? null; 861 | 862 | if ( is_null( $hook ) ) { 863 | $hook = static::getDynamicHookData( $hook_name, $is_action_not_filter ); 864 | } 865 | 866 | // if we declare/invoke the hook, the hook obviously exists 867 | if ( ! $is_invoke && ! $hook ) { 868 | if ( $code_location ) { 869 | IssueBuffer::accepts( 870 | new HookNotFound( 871 | 'Hook "' . $hook_name . '" not found', 872 | $code_location 873 | ), 874 | $statements_source->getSuppressedIssues() 875 | ); 876 | } 877 | return []; 878 | } 879 | 880 | // if it were an add_filter/add_action, we would have returned above if the $hook is not set 881 | // if the $hook is still not set, it means we have a do_action/apply_filters here 882 | // it may be empty if it's missing in the actions/filters.json 883 | // or if it's not documented in the file. e.g. a do_action without a phpdoc, an action that is only declared in a wp_schedule_event,... and thus not picked up by beforeAnalyzeFile 884 | // since we have no details on it, we skip it 885 | if ( ! $hook ) { 886 | // like this, it will give error that the do_action does not expect any arguments, thus prompting the dev to add phpdoc or remove args 887 | // if this should be ignored return []; instead 888 | // return [ 889 | // new FunctionLikeParameter( 'hook_name', false, Type::getNonEmptyString(), null, null, null, false ), 890 | // ]; 891 | return []; 892 | } 893 | 894 | $hook_type = $hook['hook_type'] ?? ''; 895 | 896 | // action_reference for do_action_ref_array 897 | if ( $is_action_not_filter && ! in_array( $hook_type, [ 'action', 'action_reference', 'action_deprecated' ], true ) ) { 898 | if ( $code_location ) { 899 | IssueBuffer::accepts( 900 | new HookNotFound( 901 | 'Hook "' . $hook_name . '" is a filter not an action', 902 | $code_location 903 | ), 904 | $statements_source->getSuppressedIssues() 905 | ); 906 | } 907 | 908 | return []; 909 | } 910 | 911 | // filter_reference for apply_filters_ref_array 912 | if ( ! $is_action_not_filter && ! in_array( $hook_type, [ 'filter', 'filter_reference', 'filter_deprecated' ], true ) ) { 913 | if ( $code_location ) { 914 | IssueBuffer::accepts( 915 | new HookNotFound( 916 | 'Hook "' . $hook_name . '" is an action not a filter', 917 | $code_location 918 | ), 919 | $statements_source->getSuppressedIssues() 920 | ); 921 | } 922 | 923 | return []; 924 | } 925 | 926 | if ( ! $is_invoke && $hook['deprecated'] === true && $code_location ) { 927 | IssueBuffer::accepts( 928 | new DeprecatedHook( 929 | 'Hook "' . $hook_name . '" is deprecated', 930 | $code_location 931 | ), 932 | $statements_source->getSuppressedIssues() 933 | ); 934 | } 935 | 936 | // don't modify the param types for utility types, since the stubbed ones are fine 937 | if ( $is_utility ) { 938 | return null; 939 | } 940 | 941 | // Check how many args the filter is registered with. 942 | $accepted_args_provided = false; 943 | if ( $is_invoke ) { 944 | // need to deduct 1, since the first argument (string) is the hardcoded hook name, which is added manually later on, since it's not in the PHPDoc 945 | $num_args = count( $call_args ) - 1; 946 | } else { 947 | $num_args = 1; 948 | if ( isset( $call_args[ 3 ]->value->value ) && !isset( $call_args[ 3 ]->name ) ) { 949 | $num_args = max( 0, (int) $call_args[ 3 ]->value->value ); 950 | $accepted_args_provided = true; 951 | } 952 | 953 | // named arguments 954 | foreach ( $call_args as $call_arg ) { 955 | if ( isset( $call_arg->name ) && $call_arg->name instanceof PhpParser\Node\Identifier && $call_arg->name->name === 'accepted_args' ) { 956 | $num_args = max( 0, (int) $call_arg->value->value ); 957 | $accepted_args_provided = true; 958 | break; 959 | } 960 | } 961 | } 962 | 963 | // if the PHPDoc is missing from the do_action/apply_filters, the types can be empty - we assign "mixed" when loading stubs in that case though to avoid this 964 | // this means these hooks really do not accept any args and the arg 965 | // this will cause "InvalidArgument" error for add_action/add_filter and TooManyArguments error for do_action/apply_filters 966 | // except where it actually has 0 args 967 | if ( empty( $hook['types'] ) && $num_args !== 0 ) { 968 | if ( $is_invoke ) { 969 | // impossible, as it would have been populated with "mixed" already 970 | // kept for completeness sake 971 | $hook_types = array_fill( 0, $num_args, Type::getMixed() ); 972 | } else { 973 | // add_action default has 1 arg, but this hook does not accept any args 974 | if ( $is_action ) { 975 | // if accepted args are not provided, it will use the default value, so we need to give an error 976 | // if it's provided psalm will report an InvalidArgument error by default already 977 | if ( $code_location && $accepted_args_provided === false ) { 978 | IssueBuffer::accepts( 979 | new HookInvalidArgs( 980 | 'Hook "' . $hook_name . '" does not accept any args, but the default number of args of add_action is 1. Please pass 0 as 4th argument', 981 | $code_location 982 | ), 983 | $statements_source->getSuppressedIssues() 984 | ); 985 | } 986 | $hook_types = []; 987 | } else { 988 | // should never happen, since we always assume "mixed" as default already and apply_filters must have at least 1 arg which is the value that is filtered 989 | // this might be worth to debug, if anybody ever encounters this 990 | throw new UnexpectedValueException( 'You found a bug for hook "' . $hook_name . '". Please open an issue with a code sample on https://github.com/psalm/psalm-plugin-wordpress', 0 ); 991 | 992 | // alternatively handle it with mixed for add_filter 993 | // $hook_types = [ Type::getMixed() ]; 994 | } 995 | } 996 | } else { 997 | $hook_types = $hook['types']; 998 | } 999 | 1000 | $max_params = count( $hook_types ); 1001 | 1002 | // when not all params should be required, we set all others to optional 1003 | $required_params_count = static::$requireAllParams === true && $is_invoke ? $max_params : $num_args; 1004 | 1005 | // we must slice for add_action/add_filter, to get the number of params matching the number of add_action/filter args, so it will report an error if the number is wrong 1006 | $hook_types = $is_invoke ? $hook_types : array_slice( $hook_types, 0, $required_params_count ); 1007 | 1008 | // if the required args in add_action are higher than what the hook is called with in some cases 1009 | if ( $required_params_count > $hook['minimum_invoke_args'] && ! $is_invoke ) { 1010 | // all args that go above the minimum invoke args must be set to optional, as the filter is called 1011 | // only after we sliced above already (since we need to include all params, just need to change if they're optional or not) 1012 | $required_params_count = $hook['minimum_invoke_args']; 1013 | } 1014 | 1015 | $hook_params = array_map( function ( Union $type ) use ( &$required_params_count ) : FunctionLikeParameter { 1016 | $is_optional = $required_params_count > 0 ? false : true; 1017 | $required_params_count--; 1018 | 1019 | return new FunctionLikeParameter( 'param', false, $type, null, null, null, $is_optional ); 1020 | }, $hook_types ); 1021 | 1022 | // check that the types passed to filter are of the type that is specified in PHPDoc 1023 | if ( $is_invoke ) { 1024 | $return = [ 1025 | // generic non-empty string (don't use $hook_name, as it will report false positives for dynamic hook names that contain a variable) 1026 | new FunctionLikeParameter( 'hook_name', false, Type::getNonEmptyString(), null, null, null, false ), 1027 | ]; 1028 | 1029 | return array_merge( $return, $hook_params ); 1030 | } 1031 | 1032 | // add_action callback return value can be anything, but is discarded anyway, therefore should be void 1033 | $min_args = 0; 1034 | if ( $is_action ) { 1035 | // by setting this to "Type::getVoid()", the callback can return ANY value (e.g. int, string,...) 1036 | // by setting this to "Type::getNull()", the callback must explicitly return null or void 1037 | // using null is too strict, as you would have to create tons of unnecessary wrapper functions then 1038 | $return_type = Type::getVoid(); 1039 | } else { 1040 | // technically 0 is allowed for add_filter, however it doesn't make sense since any previously filtered values would not be used and this is the wrong way to use a filter 1041 | $min_args = 1; 1042 | if ( isset( $hook['types'][0] ) ) { 1043 | // add_filter callback must return the same type as the first documented parameter (2nd arg) 1044 | $return_type = $hook['types'][0]; 1045 | 1046 | // for bool we can use 0, so "__return_true" and "__return_false" can be used without error, as for bool only (!) filters the previous value doesn't matter 1047 | // allow this for "mixed" too, since it could be bool and we don't want to force types on badly/non-documented filters 1048 | // same for single int type, where any previously filtered in values are often irrelevant 1049 | if ( $return_type->isBool() || $return_type->isMixed() || $return_type->isInt() ) { 1050 | $min_args = 0; 1051 | } 1052 | } else { 1053 | // unknown due to lack of PHPDoc - but a filter must always return something - mixed is the most generic case 1054 | $return_type = Type::getMixed(); 1055 | } 1056 | } 1057 | 1058 | $args_type = $max_params === 0 || $min_args >= $max_params ? Type::getInt( false, $max_params ) : new Union([ new TIntRange( $min_args, $max_params )] ); 1059 | 1060 | $return = [ 1061 | // the first argument of each FunctionLikeParameter must match the param name of the function to allow the use of named arguments 1062 | new FunctionLikeParameter( 'hook_name', false, Type::getNonEmptyString(), null, null, null, false ), 1063 | new FunctionLikeParameter( 'callback', false, new Union( [ 1064 | new TCallable( 1065 | 'callable', 1066 | $hook_params, 1067 | $return_type 1068 | ), 1069 | ] ), null, null, null, false ), 1070 | // $is_optional arg in FunctionLikeParameter is true by default, so we can just set type of int directly without null (since it's not nullable anyway) 1071 | new FunctionLikeParameter( 'priority', false, Type::getInt() ), 1072 | new FunctionLikeParameter( 'accepted_args', false, $args_type ), 1073 | ]; 1074 | 1075 | return $return; 1076 | } 1077 | 1078 | public static function getFunctionReturnType( FunctionReturnTypeProviderEvent $event ) : ?Union { 1079 | if ( $event->getFunctionId() === 'wp_parse_url' ) { 1080 | return ParseUrlReturnTypeProvider::getFunctionReturnType( $event ); 1081 | } 1082 | 1083 | if ( in_array( $event->getFunctionId(), [ 'add_action', 'add_filter' ], true ) ) { 1084 | return Type::getTrue(); 1085 | } 1086 | 1087 | if ( in_array( $event->getFunctionId(), [ 'do_action', 'do_action_ref_array', 'do_action_deprecated' ], true ) ) { 1088 | return Type::getVoid(); 1089 | } 1090 | 1091 | // @todo not supported yet below 1092 | if ( in_array( $event->getFunctionId(), [ 'apply_filters_ref_array', 'apply_filters_deprecated' ], true ) ) { 1093 | return null; 1094 | } 1095 | 1096 | // use the stubbed type for those 1097 | if ( 1098 | in_array( 1099 | $event->getFunctionId(), 1100 | array( 1101 | 'did_action', 1102 | 'did_filter', 1103 | 'doing_action', 1104 | 'doing_filter', 1105 | 'has_action', 1106 | 'has_filter', 1107 | 'remove_action', 1108 | 'remove_filter', 1109 | 'remove_all_actions', 1110 | 'remove_all_filters', 1111 | ), 1112 | true 1113 | ) 1114 | ) { 1115 | return null; 1116 | } 1117 | 1118 | static::loadStubbedHooks(); 1119 | 1120 | $call_args = $event->getCallArgs(); 1121 | 1122 | // only apply_filters left to handle 1123 | if ( ! $call_args[0]->value instanceof String_ ) { 1124 | $statements_source = $event->getStatementsSource(); 1125 | try { 1126 | $union = $statements_source->getNodeTypeProvider()->getType( $call_args[0]->value ); 1127 | } catch (UnexpectedValueException $e) { 1128 | $union = null; 1129 | } 1130 | if ( ! $union ) { 1131 | $union = static::getTypeFromArg( $call_args[0]->value, $event->getContext(), $statements_source ); 1132 | } 1133 | 1134 | $potential_hook_name = false; 1135 | if ( $union && $union->isSingleStringLiteral() ) { 1136 | $potential_hook_name = $union->getSingleStringLiteral()->value; 1137 | } 1138 | 1139 | if ( $potential_hook_name && isset( static::$hooks[ $potential_hook_name ] ) ) { 1140 | $hook_name = $potential_hook_name; 1141 | } else { 1142 | $hook_name = static::getDynamicHookName( $call_args[0]->value ); 1143 | if ( is_null( $hook_name ) && ! $potential_hook_name ) { 1144 | return static::getTypeFromArg( $call_args[1]->value, $event->getContext(), $event->getStatementsSource() ); 1145 | } 1146 | 1147 | if ( $potential_hook_name ) { 1148 | if ( is_null( $hook_name ) || ! isset( static::$hooks[ $hook_name ] ) ) { 1149 | $hook_name = $potential_hook_name; 1150 | } 1151 | } 1152 | } 1153 | } else { 1154 | $hook_name = $call_args[0]->value->value; 1155 | } 1156 | 1157 | $hook = static::$hooks[ $hook_name ] ?? null; 1158 | if ( is_null( $hook ) ) { 1159 | $hook = static::getDynamicHookData( $hook_name, false ); 1160 | } 1161 | 1162 | // if it's not a filter 1163 | if ( isset( $hook['hook_type'] ) && ! in_array( $hook['hook_type'], [ 'filter', 'filter_reference', 'filter_deprecated' ], true ) ) { 1164 | // can't happen unless there is a filter and an action with the same name 1165 | // or a dynamic filter name matches an action name 1166 | return Type::getNull(); 1167 | } 1168 | 1169 | if ( isset( $hook['types'][0] ) ) { 1170 | // add_filter callback must return the same type as the first documented parameter (2nd arg) 1171 | return $hook['types'][0]; 1172 | } else { 1173 | // unknown due to lack of PHPDoc 1174 | return static::getTypeFromArg( $call_args[1]->value, $event->getContext(), $event->getStatementsSource() ); 1175 | } 1176 | } 1177 | 1178 | protected static function getTypeFromArg( $parser_param, Context $context, StatementsSource $statements_source ) : ?Union { 1179 | if ( isset( $statements_source->node_data ) ) { 1180 | $mode_type = SimpleTypeInferer::infer( 1181 | $statements_source->getCodebase(), 1182 | $statements_source->node_data, 1183 | $parser_param, 1184 | $statements_source->getAliases(), 1185 | $statements_source, 1186 | ); 1187 | 1188 | if ( ! $mode_type && $parser_param instanceof PhpParser\Node\Expr\ConstFetch ) { 1189 | $mode_type = ConstFetchAnalyzer::getConstType( 1190 | $statements_source, 1191 | $parser_param->name->toString(), 1192 | true, 1193 | $context, 1194 | ); 1195 | } 1196 | 1197 | if ( $mode_type ) { 1198 | return $mode_type; 1199 | } 1200 | } 1201 | 1202 | $extended_var_id = ExpressionIdentifier::getExtendedVarId( 1203 | $parser_param, 1204 | null, 1205 | $statements_source, 1206 | ); 1207 | 1208 | if ( ! $extended_var_id ) { 1209 | return null; 1210 | } 1211 | 1212 | // if it's set return the type of the variable, otherwise set it to null (mixed via fallback) 1213 | return $context->vars_in_scope[ $extended_var_id ] ?? null; 1214 | } 1215 | 1216 | /** 1217 | * @param string $hook_name 1218 | * @param array, Union> $types 1219 | * @return void 1220 | */ 1221 | public static function registerHook( string $hook_name, array $types, string $hook_type, bool $is_deprecated ) { 1222 | // remove empty elements which can happen with invalid phpdoc 1223 | $types = array_filter( $types ); 1224 | $minimum_invoke_args = count( $types ); 1225 | 1226 | // do not assign empty types if we already have this hook registered 1227 | if ( isset( static::$hooks[ $hook_name ] ) && $minimum_invoke_args === 0 ) { 1228 | // allow overriding the deprecated in this case though - e.g. for calls to "_deprecated_hook" 1229 | if ( static::$hooks[ $hook_name ]['deprecated'] === false ) { 1230 | static::$hooks[ $hook_name ]['deprecated'] = $is_deprecated; 1231 | } 1232 | 1233 | // filter must have at least 1 arg to work 1234 | if ( $hook_type !== '' && $hook_type !== 'filter' && static::$hooks[ $hook_name ]['minimum_invoke_args'] !== 0 ) { 1235 | static::$hooks[ $hook_name ]['minimum_invoke_args'] = 0; 1236 | } 1237 | 1238 | return; 1239 | } 1240 | 1241 | // if this hook is registered already 1242 | if ( isset( static::$hooks[ $hook_name ] ) ) { 1243 | $minimum_invoke_args = static::$hooks[ $hook_name ]['hook_type'] === '' ? $minimum_invoke_args : min( static::$hooks[ $hook_name ]['minimum_invoke_args'], $minimum_invoke_args ); 1244 | // if we have more types than already registered, we overwrite existing ones, but keep additional ones (array_merge would combine them which is wrong) 1245 | // except where type is "mixed" and we have a more specific type, we keep the more specific type 1246 | // we do not merge types together, as this would lead to a complete chaos and no PHPDocs matching up whatsoever 1247 | foreach ( $types as $key => $param_type ) { 1248 | if ( ! isset( static::$hooks[ $hook_name ]['types'][ $key ] ) ) { 1249 | // new type has more types than existing 1250 | break; 1251 | } 1252 | 1253 | if ( ! $param_type->isSingle() ) { 1254 | continue; 1255 | } 1256 | 1257 | if ( $param_type->hasMixed() ) { 1258 | $types[ $key ] = static::$hooks[ $hook_name ]['types'][ $key ]; 1259 | } 1260 | } 1261 | $types = $types + static::$hooks[ $hook_name ]['types']; 1262 | 1263 | // if keys are missing in one of the 2 arrays, it can lead to incorrect param order, so we have to sort by key 1264 | ksort( $types ); 1265 | 1266 | // if it's deprecated anywhere, keep it deprecated 1267 | $is_deprecated = static::$hooks[ $hook_name ]['deprecated'] === true ? true : $is_deprecated; 1268 | } 1269 | 1270 | // if there are keys missing, e.g. they were removed from the array_filter due to invalid docblock, we need to set them 1271 | if ( array_values( $types ) !== $types ) { 1272 | for ( $i = 0; $i < count( $types ); $i++ ) { 1273 | if ( isset( $types[ $i ] ) ) { 1274 | continue; 1275 | } 1276 | 1277 | // assign mixed, since we do not have a valid phpdoc for it 1278 | $types[ $i ] = Type::getMixed(); 1279 | } 1280 | 1281 | ksort( $types ); 1282 | } 1283 | 1284 | if ( $minimum_invoke_args === 0 && $hook_type === 'filter' ) { 1285 | $minimum_invoke_args = 1; 1286 | } 1287 | 1288 | static::$hooks[ $hook_name ] = [ 1289 | 'hook_type' => $hook_type, 1290 | 'types' => array_values( $types ), 1291 | 'deprecated' => $is_deprecated, 1292 | 'minimum_invoke_args' => $minimum_invoke_args, 1293 | ]; 1294 | } 1295 | } 1296 | 1297 | class HookNodeVisitor extends PhpParser\NodeVisitorAbstract { 1298 | /** @var ?PhpParser\Comment\Doc */ 1299 | protected $last_doc = null; 1300 | 1301 | /** @var list, deprecated: bool}> */ 1302 | public $hooks = []; 1303 | 1304 | /** 1305 | * @var int 1306 | */ 1307 | private $maxLine = 0; 1308 | 1309 | /** 1310 | * @var string 1311 | */ 1312 | protected $useNamespace = ''; 1313 | 1314 | /** 1315 | * @var array 1316 | */ 1317 | protected $useStatements = []; 1318 | 1319 | /** 1320 | * @var array|false 1321 | */ 1322 | protected $useStatementsNonClass = false; 1323 | 1324 | public function enterNode( PhpParser\Node $node ) { 1325 | $apply_filter_functions = [ 1326 | 'apply_filters', 1327 | 'apply_filters_ref_array', 1328 | 'apply_filters_deprecated', 1329 | ]; 1330 | 1331 | $do_action_functions = [ 1332 | 'do_action', 1333 | 'do_action_ref_array', 1334 | 'do_action_deprecated', 1335 | ]; 1336 | 1337 | $event_functions = [ 1338 | 'wp_schedule_event', 1339 | // WooCommerce action scheduler 1340 | 'as_schedule_recurring_action', 1341 | 'as_schedule_cron_action', 1342 | ]; 1343 | 1344 | $single_event_functions = [ 1345 | 'wp_schedule_single_event', 1346 | // WooCommerce action scheduler 1347 | 'as_schedule_single_action', 1348 | 'as_enqueue_async_action', 1349 | ]; 1350 | 1351 | // see visitor.php for original code 1352 | if ( ( $this->useNamespace !== '' || $this->useStatements !== [] || $this->useStatementsNonClass !== false ) && $this->maxLine > $node->getStartLine() ) { 1353 | $this->useNamespace = ''; 1354 | $this->useStatements = []; 1355 | $this->useStatementsNonClass = false; 1356 | } 1357 | 1358 | $this->maxLine = $node->getStartLine(); 1359 | 1360 | if ( $node instanceof Namespace_ ) { 1361 | // as soon as there is a new namespace, we need to empty the useStatements, as there will be new ones for the given namespace 1362 | $this->useNamespace = $node->name->toString(); 1363 | $this->useStatements = []; 1364 | $this->useStatementsNonClass = false; 1365 | return null; 1366 | } 1367 | 1368 | // normal "use" statements 1369 | if ( $node instanceof PhpParser\Node\Stmt\UseUse || $node instanceof UseItem ) { 1370 | // must implode, before we remove the class name from the array 1371 | $fqcn = $node->name->toString(); 1372 | 1373 | // sometimes happens as an artifact of GroupUse below and can be ignored 1374 | if ( empty( $fqcn ) ) { 1375 | return null; 1376 | } 1377 | 1378 | // some have unnecessary leading \ in "use", we need to remove 1379 | $fqcn = ltrim( $fqcn, '\\' ); 1380 | 1381 | // technically not needed, since the namespace is included in the stubs 1382 | // changing it anyway, to make things easier to follow/read, as the stubs are a massive file usually 1383 | $fqcn = preg_replace( '/^namespace\\\\/', $this->useNamespace . '\\', $fqcn, 1 ); 1384 | 1385 | // if "as" is used, it's set as alias 1386 | if ( ! empty( $node->alias->name ) ) { 1387 | // class names are unique, so we use them as keys of the array 1388 | // however it might already be set by a GroupUse, in which case we don't want to override it, as we would set a wrong value here 1389 | if ( empty( $this->useStatements[ $node->alias->name ] ) ) { 1390 | $this->useStatements[ $node->alias->name ] = $fqcn; 1391 | } 1392 | } else { 1393 | $class_name = $node->name->getLast(); 1394 | 1395 | // might already be set by a GroupUse 1396 | if ( empty( $this->useStatements[ $class_name ] ) ) { 1397 | $this->useStatements[ $class_name ] = $fqcn; 1398 | } 1399 | } 1400 | 1401 | return null; 1402 | } 1403 | 1404 | // like "use superWC\special\{Order, Extra\Refund, User};" 1405 | if ( $node instanceof GroupUse ) { 1406 | // some have unnecessary leading \ in them, we need to remove first 1407 | $fqcn_prefix = ltrim( $node->prefix->toString(), '\\' ); 1408 | 1409 | foreach ( $node->uses as $use_object ) { 1410 | $fqcn = $use_object->name->toString(); 1411 | if ( empty( $fqcn ) ) { 1412 | continue; 1413 | } 1414 | 1415 | // some have invalid leading \ in them, we need to remove first 1416 | $fqcn = $fqcn_prefix . '\\' . ltrim( $fqcn, '\\' ); 1417 | $fqcn = preg_replace( '/^namespace\\\\/', $this->useNamespace . '\\', $fqcn, 1 ); 1418 | 1419 | if ( ! empty( $use_object->alias->name ) ) { 1420 | $this->useStatements[ $use_object->alias->name ] = $fqcn; 1421 | } else { 1422 | $this->useStatements[ $use_object->name->getLast() ] = $fqcn; 1423 | } 1424 | } 1425 | 1426 | return null; 1427 | } 1428 | // end of visitor.php duplicate code 1429 | 1430 | // "return apply_filters" will assign the phpdoc to the return instead of the apply_filters, so we need to store it 1431 | // "$var = apply_filters" directly after a function declaration 1432 | // "echo apply_filters" 1433 | // cannot do this for all cases, as often it will assign completely wrong stuff otherwise 1434 | if ( $node->getDocComment() && ( $node instanceof FuncCall || $node instanceof Return_ || $node instanceof Variable || $node instanceof Echo_ ) ) { 1435 | $this->last_doc = $node->getDocComment(); 1436 | } elseif ( isset( $this->last_doc ) && ! $node instanceof FuncCall ) { 1437 | // if it's set already and this is not a FuncCall, reset it to null, since there's something else and it would be used incorrectly 1438 | $this->last_doc = null; 1439 | } 1440 | 1441 | if ( $node instanceof FuncCall && $node->name instanceof Name ) { 1442 | $hook_index = 0; 1443 | $is_deprecated = false; 1444 | if ( in_array( (string) $node->name, $apply_filter_functions, true ) ) { 1445 | $hook_type = 'filter'; 1446 | } elseif ( in_array( (string) $node->name, $do_action_functions, true ) ) { 1447 | $hook_type = 'action'; 1448 | } elseif ( in_array( (string) $node->name, $event_functions, true ) ) { 1449 | $hook_type = 'cron-action'; 1450 | // the 3rd arg (index key 2) is the hook name 1451 | $hook_index = 2; 1452 | } elseif ( in_array( (string) $node->name, $single_event_functions, true ) ) { 1453 | $hook_type = 'cron-action'; 1454 | $hook_index = 1; 1455 | 1456 | if ( (string) $node->name === 'as_enqueue_async_action' ) { 1457 | $hook_index = 0; 1458 | } 1459 | } elseif ( preg_match( '/_deprecated_hook$/', (string) $node->name ) === 1 ) { 1460 | // ignore dynamic hooks 1461 | if ( $node->args[0]->value instanceof String_ ) { 1462 | $hook_name = $node->args[0]->value->value; 1463 | } else { 1464 | $hook_name = Plugin::getDynamicHookName( $node->args[0]->value ); 1465 | } 1466 | 1467 | if ( ! $hook_name ) { 1468 | $this->last_doc = null; 1469 | return null; 1470 | } 1471 | 1472 | // hook type is irrelevant and won't be used when overriding 1473 | // in case the hook is not registered yet, it will eventually be registered and the hook type and types will be set 1474 | $this->hooks[] = [ 1475 | 'name' => $hook_name, 1476 | 'hook_type' => '', 1477 | 'types' => [], 1478 | 'deprecated' => true, 1479 | ]; 1480 | return null; 1481 | } else { 1482 | return null; 1483 | } 1484 | 1485 | $types = []; 1486 | $override_num_args_from_docblock = false; 1487 | if ( $hook_type === 'cron-action' ) { 1488 | if ( $node->args[ $hook_index ]->value instanceof String_ ) { 1489 | $hook_name = $node->args[ $hook_index ]->value->value; 1490 | } else { 1491 | $hook_name = Plugin::getDynamicHookName( $node->args[ $hook_index ]->value ); 1492 | } 1493 | 1494 | if ( ! $hook_name ) { 1495 | $this->last_doc = null; 1496 | return null; 1497 | } 1498 | 1499 | // if it's not documented (which it honestly never is for these), we need to get the number of args and assign mixed 1500 | // the args are passed as array as element after hook name in all cases 1501 | // args are optional, so by default we will have 0 1502 | $num_args = 0; 1503 | if ( isset( $node->args[ $hook_index + 1 ] ) && $node->args[ $hook_index + 1 ] instanceof Arg && $node->args[ $hook_index + 1 ]->value instanceof Array_ ) { 1504 | $cron_args = $node->args[ $hook_index + 1 ]->value->items; 1505 | $num_args = count( $cron_args ); 1506 | 1507 | // see if we can assign better types, than just mixed for all 1508 | foreach ( $cron_args as $item ) { 1509 | // cron events only use array values, keys are ignored by WP 1510 | if ( $item->value instanceof Variable ) { 1511 | $types[] = Type::getMixed(); 1512 | } elseif ( $item->value instanceof String_ || $item->value instanceof PhpParser\Node\Expr\Cast\String_ ) { 1513 | $types[] = Type::getString(); 1514 | } elseif ( $item->value instanceof Array_ || $item->value instanceof PhpParser\Node\Expr\Cast\Array_ ) { 1515 | $types[] = Type::getArray(); 1516 | } elseif ( $item->value instanceof PhpParser\Node\Scalar\LNumber || $item->value instanceof PhpParser\Node\Scalar\Int_ || $item->value instanceof PhpParser\Node\Expr\Cast\Int_ ) { 1517 | $types[] = Type::getInt(); 1518 | } elseif ( $item->value instanceof PhpParser\Node\Scalar\DNumber || $item->value instanceof PhpParser\Node\Scalar\Float_ || $item->value instanceof PhpParser\Node\Expr\Cast\Double ) { 1519 | $types[] = Type::getFloat(); 1520 | } elseif ( ( $item->value instanceof PhpParser\Node\Expr\ConstFetch && in_array( strtolower( $item->value->name->toString() ), [ 'false', 'true' ], true ) ) || $item->value instanceof PhpParser\Node\Expr\Cast\Bool_ ) { 1521 | $types[] = Type::getBool(); 1522 | } elseif ( $item->value instanceof PhpParser\Node\Expr\Cast\Object_ ) { 1523 | $types[] = Type::getObject(); 1524 | } else { 1525 | $types[] = Type::getMixed(); 1526 | } 1527 | } 1528 | } elseif ( isset( $node->args[ $hook_index + 1 ] ) && $node->args[ $hook_index + 1 ] instanceof Arg && $node->args[ $hook_index + 1 ]->value instanceof Variable ) { 1529 | // there's something there, but we cannot determine the type. Theoretically could be multiple args, but we cannot determine. 1530 | // theoretically possible it's an empty element, in which case this would be wrong, but then no empty variable should be set here in the first place 1531 | $num_args = 1; 1532 | $types = [ Type::getMixed() ]; 1533 | 1534 | // if we have a docblock, override the num_args with the docblock declared params, as this is more correct 1535 | // as otherwise we get lots of optional parameters required in callbacks all the time 1536 | $override_num_args_from_docblock = true; 1537 | } 1538 | 1539 | // since it's just a regular action and we don't need to differentiate anymore now 1540 | $hook_type = 'action'; 1541 | } else { 1542 | if ( $node->args[0]->value instanceof String_ ) { 1543 | $hook_name = $node->args[0]->value->value; 1544 | } else { 1545 | $hook_name = Plugin::getDynamicHookName( $node->args[0]->value ); 1546 | } 1547 | 1548 | if ( ! $hook_name ) { 1549 | $this->last_doc = null; 1550 | return null; 1551 | } 1552 | 1553 | // the first arg is the hook name, which gets skipped, so we need to "-1" 1554 | $num_args = count( $node->args ) - 1; 1555 | 1556 | // cron actions cannot be deprecated, which means we only need to check this here 1557 | if ( in_array( (string) $node->name, ['apply_filters_deprecated', 'do_action_deprecated'], true ) ) { 1558 | $is_deprecated = true; 1559 | } 1560 | } 1561 | 1562 | $has_valid_docblock = true; 1563 | // an undocumented filter or action invoke 1564 | if ( is_null( $this->last_doc ) ) { 1565 | $has_valid_docblock = false; 1566 | } else { 1567 | $doc_comment = $this->last_doc->getText(); 1568 | 1569 | // reset it right away, in case the docblock is invalid and we return early 1570 | $this->last_doc = null; 1571 | 1572 | // quick and dirty 1573 | if ( $is_deprecated === false && preg_match( '/\* *@deprecated/', $doc_comment ) === 1 ) { 1574 | $is_deprecated = true; 1575 | } 1576 | 1577 | $doc_factory = phpDocumentor\Reflection\DocBlockFactory::createInstance(); 1578 | $context = new phpDocumentor\Reflection\Types\Context( $this->useNamespace, $this->useStatements ); 1579 | try { 1580 | $doc_block = $doc_factory->create( $doc_comment, $context ); 1581 | } catch ( RuntimeException $e ) { 1582 | $has_valid_docblock = false; 1583 | } catch ( InvalidArgumentException $e ) { 1584 | $has_valid_docblock = false; 1585 | } 1586 | } 1587 | 1588 | if ( $has_valid_docblock === false ) { 1589 | if ( $num_args > 0 && empty( $types ) ) { 1590 | $types = array_fill( 0, $num_args, Type::getMixed() ); 1591 | } 1592 | 1593 | $this->hooks[] = [ 1594 | 'name' => $hook_name, 1595 | 'hook_type' => $hook_type, 1596 | 'types' => $types, 1597 | 'deprecated' => $is_deprecated, 1598 | ]; 1599 | return null; 1600 | } 1601 | 1602 | /** @var array */ 1603 | $params = $doc_block->getTagsByName( 'param' ); 1604 | if ( $override_num_args_from_docblock === true && count( $params ) > $num_args ) { 1605 | $num_args = count( $params ); 1606 | } 1607 | 1608 | $i = 0; 1609 | $types = []; 1610 | foreach ( $params as $param ) { 1611 | if ( $i >= $num_args ) { 1612 | break; 1613 | } 1614 | 1615 | ++$i; 1616 | 1617 | if( ! ( $param instanceof phpDocumentor\Reflection\DocBlock\Tags\Param ) ) { 1618 | // set to mixed - if we skip it, it will mess up all subsequent args 1619 | $types[] = Type::getMixed(); 1620 | continue; 1621 | } 1622 | $param_type = $param->getType(); 1623 | if ( is_null( $param_type ) ) { 1624 | // set to mixed - if we skip it, it will mess up all subsequent args 1625 | $types[] = Type::getMixed(); 1626 | continue; 1627 | } 1628 | 1629 | $types[] = Type::parseString( $param_type->__toString() ); 1630 | } 1631 | 1632 | if ( count( $types ) < $num_args ) { 1633 | // we have a list, so we can just array merge instead of "+" and ksort 1634 | $fill_types = array_fill( count( $types ), $num_args - count( $types ), Type::getMixed() ); 1635 | $types = array_merge( $types, $fill_types ); 1636 | } 1637 | 1638 | // cannot assign to hooks directly, as this would mean we overwrite if this hook exists multiple times in this file 1639 | // all type handling logic is better handled in a single place later where hooks get registered 1640 | $this->hooks[] = [ 1641 | 'name' => $hook_name, 1642 | 'hook_type' => $hook_type, 1643 | 'types' => $types, 1644 | 'deprecated' => $is_deprecated, 1645 | ]; 1646 | } 1647 | 1648 | return null; 1649 | } 1650 | } 1651 | 1652 | class HookNotFound extends PluginIssue {} 1653 | 1654 | class HookInvalidArgs extends PluginIssue {} 1655 | 1656 | class DeprecatedHook extends PluginIssue {} 1657 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/psalm-plugin-wordpress", 3 | "description": "WordPress stubs and plugin for Psalm static analysis.", 4 | "type": "psalm-plugin", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "kkmuffme", 9 | "role": "Maintainer" 10 | }, 11 | { 12 | "name": "Joe Hoyle", 13 | "role": "Creator" 14 | } 15 | ], 16 | "support": { 17 | "issues": "https://github.com/psalm/psalm-plugin-wordpress/issues", 18 | "source": "https://github.com/psalm/psalm-plugin-wordpress" 19 | }, 20 | "require": { 21 | "ext-simplexml": "*", 22 | "wp-hooks/wordpress-core": "^1.3.0", 23 | "php-stubs/wordpress-stubs": "^6.0", 24 | "php-stubs/wordpress-globals": "^0.2.0", 25 | "php-stubs/wp-cli-stubs": "^2.7", 26 | "vimeo/psalm": "^5 || ^6" 27 | }, 28 | "require-dev": { 29 | "humanmade/coding-standards": "^1.2", 30 | "phpunit/phpunit": "^9.0", 31 | "psalm/plugin-phpunit": "^0.18.4" 32 | }, 33 | "extra": { 34 | "psalm" : { 35 | "pluginClass": "PsalmWordPress\\Plugin" 36 | } 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "PsalmWordPress\\": ["."] 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "PsalmWordPress\\Tests\\": ["tests", "tests/unit"], 46 | "Psalm\\Tests\\": "vendor/vimeo/psalm/tests/" 47 | } 48 | }, 49 | "config": { 50 | "allow-plugins": { 51 | "dealerdirect/phpcodesniffer-composer-installer": true 52 | }, 53 | "preferred-install": { 54 | "vimeo/psalm": "source" 55 | }, 56 | "sort-packages": true 57 | }, 58 | "scripts" : { 59 | "check": [ 60 | "@cs-check", 61 | "@test", 62 | "@analyze" 63 | ], 64 | "analyze": "psalm", 65 | "cs-check": "phpcs -ps --colors", 66 | "cs-fix": "phpcbf", 67 | "test": "codecept run", 68 | "test-coverage": "phpunit --colors=always --coverage-clover clover.xml", 69 | "generate-wordpress-stubs": "cd wordpress ; composer init -n ; composer require " 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /stubs/globals.php: -------------------------------------------------------------------------------- 1 | $empty_trash_days */ $empty_trash_days = 30 ); 11 | 12 | define( 'MINUTE_IN_SECONDS', /** @var 60 $minute_in_seconds */ $minute_in_seconds = 60 ); 13 | define( 'HOUR_IN_SECONDS', /** @var 3600 $hour_in_seconds */ $hour_in_seconds = 60 * MINUTE_IN_SECONDS ); 14 | define( 'DAY_IN_SECONDS', /** @var 86400 $day_in_seconds */ $day_in_seconds = 24 * HOUR_IN_SECONDS ); 15 | define( 'WEEK_IN_SECONDS', /** @var 604800 $week_in_seconds */ $week_in_seconds = 7 * DAY_IN_SECONDS ); 16 | define( 'MONTH_IN_SECONDS', /** @var 2592000 $month_in_seconds */ $month_in_seconds = 30 * DAY_IN_SECONDS ); 17 | define( 'YEAR_IN_SECONDS', /** @var 31536000 $year_in_seconds */ $year_in_seconds = 365 * DAY_IN_SECONDS ); 18 | 19 | define( 'KB_IN_BYTES', /** @var 1024 $kb_in_bytes */ $kb_in_bytes = 1024 ); 20 | define( 'MB_IN_BYTES', /** @var 1048576 $mb_in_bytes */ $mb_in_bytes = 1024 * KB_IN_BYTES ); 21 | define( 'GB_IN_BYTES', /** @var 1073741824 $gb_in_bytes */ $gb_in_bytes = 1024 * MB_IN_BYTES ); 22 | define( 'TB_IN_BYTES', /** @var 1099511627776 $tb_in_bytes */ $tb_in_bytes = 1024 * GB_IN_BYTES ); 23 | 24 | // ./wp-includes/wp-db.php 25 | 26 | define( 'OBJECT', /** @var 'OBJECT' $object */ $object = 'OBJECT' ); 27 | define( 'OBJECT_K', /** @var 'OBJECT_K' $object_k */ $object_k = 'OBJECT_K' ); 28 | define( 'ARRAY_A', /** @var 'ARRAY_A' $array_a */ $array_a = 'ARRAY_A' ); 29 | define( 'ARRAY_N', /** @var 'ARRAY_N' $array_n */ $array_n = 'ARRAY_N' ); 30 | 31 | // ./wp-admin/includes/file.php 32 | 33 | define( 'FS_CONNECT_TIMEOUT', /** @var int<0, max> $fs_connect_timeout */ $fs_connect_timeout = 30 ); 34 | define( 'FS_TIMEOUT', /** @var int<0, max> $fs_timeout */ $fs_timeout = 30 ); 35 | define( 'FS_CHMOD_DIR', /** @var int $fs_chmod_dir */ $fs_chmod_dir = 0755 ); 36 | define( 'FS_CHMOD_FILE', /** @var int $fs_chmod_file */ $fs_chmod_file = 0644 ); 37 | 38 | // ./wp-includes/rewrite.php 39 | 40 | define( 'EP_NONE', /** @var 0 $ep_none */ $ep_none = 0 ); 41 | define( 'EP_PERMALINK', /** @var 1 $ep_permalink */ $ep_permalink = 1 ); 42 | define( 'EP_ATTACHMENT', /** @var 2 $ep_attachment */ $ep_attachment = 2 ); 43 | define( 'EP_DATE', /** @var 4 $ep_date */ $ep_date = 4 ); 44 | define( 'EP_YEAR', /** @var 8 $ep_year */ $ep_year = 8 ); 45 | define( 'EP_MONTH', /** @var 16 $ep_month */ $ep_month = 16 ); 46 | define( 'EP_DAY', /** @var 32 $ep_day */ $ep_day = 32 ); 47 | define( 'EP_ROOT', /** @var 64 $ep_root */ $ep_root = 64 ); 48 | define( 'EP_COMMENTS', /** @var 128 $ep_comments */ $ep_comments = 128 ); 49 | define( 'EP_SEARCH', /** @var 256 $ep_search */ $ep_search = 256 ); 50 | define( 'EP_CATEGORIES', /** @var 512 $ep_categories */ $ep_categories = 512 ); 51 | define( 'EP_TAGS', /** @var 1024 $ep_tags */ $ep_tags = 1024 ); 52 | define( 'EP_AUTHORS', /** @var 2048 $ep_authors */ $ep_authors = 2048 ); 53 | define( 'EP_PAGES', /** @var 4096 $ep_pages */ $ep_pages = 4096 ); 54 | define( 'EP_ALL_ARCHIVES', /** @var int-mask $ep_all_archives */ $ep_all_archives = EP_DATE | EP_YEAR | EP_MONTH | EP_DAY | EP_CATEGORIES | EP_TAGS | EP_AUTHORS ); 55 | define( 'EP_ALL', /** @var int-mask $ep_all */ $ep_all = EP_PERMALINK | EP_ATTACHMENT | EP_ROOT | EP_COMMENTS | EP_SEARCH | EP_PAGES | EP_ALL_ARCHIVES ); 56 | -------------------------------------------------------------------------------- /stubs/overrides.php: -------------------------------------------------------------------------------- 1 | |scalar $args 39 | * @return string|void 40 | */ 41 | public function prepare( $query, $args ) {} 42 | 43 | /** 44 | * @template TObject of ARRAY_A|ARRAY_N|OBJECT|OBJECT_K 45 | * @param string|null $query 46 | * @psalm-param TObject $object 47 | * @return ( 48 | * TObject is OBJECT 49 | * ? list> 50 | * : ( TObject is ARRAY_A 51 | * ? list> 52 | * : ( TObject is ARRAY_N 53 | * ? list> 54 | * : array> 55 | * ) 56 | * ) 57 | * )|null 58 | */ 59 | public function get_results( $query = null, $object = \OBJECT ) {} 60 | } 61 | 62 | /** 63 | * @return array{ 64 | * path: string, 65 | * url: string, 66 | * subdir: string, 67 | * basedir: string, 68 | * baseurl: string, 69 | * error: string|false, 70 | * } 71 | */ 72 | function wp_get_upload_dir() {} 73 | 74 | /** 75 | * @template TFilterValue 76 | * @param string $hook_name 77 | * @psalm-param TFilterValue $value 78 | * @return TFilterValue 79 | */ 80 | function apply_filters( string $hook_name, $value, ...$args ) {} 81 | 82 | /** 83 | * | Component | | 84 | * | ---------------- | - | 85 | * | PHP_URL_SCHEME | 0 | 86 | * | PHP_URL_HOST | 1 | 87 | * | PHP_URL_PORT | 2 | 88 | * | PHP_URL_USER | 3 | 89 | * | PHP_URL_PASS | 4 | 90 | * | PHP_URL_PATH | 5 | 91 | * | PHP_URL_QUERY | 6 | 92 | * | PHP_URL_FRAGMENT | 7 | 93 | * 94 | * @template TComponent of (-1|PHP_URL_*) 95 | * @param string $url 96 | * @param TComponent $component 97 | * @return ( 98 | * TComponent is -1 99 | * ? array{ 100 | * scheme?: string, 101 | * host?: string, 102 | * port?: int, 103 | * user?: string, 104 | * pass?: string, 105 | * path?: string, 106 | * query?: string, 107 | * fragment?: string, 108 | * } 109 | * : ( 110 | * TComponent is 2 111 | * ? int|null 112 | * : string|null 113 | * ) 114 | * )|false 115 | */ 116 | function wp_parse_url( string $url, int $component = -1 ) {} 117 | 118 | /** 119 | * @param string $option 120 | * @param mixed $default 121 | * @return mixed 122 | */ 123 | function get_option( string $option, $default = null ) {} 124 | 125 | /** 126 | * @return array[] { 127 | * Array of settings error arrays. 128 | * 129 | * @type array ...$0 { 130 | * Associative array of setting error data. 131 | * 132 | * @type string $setting Slug title of the setting to which this error applies. 133 | * @type string $code Slug-name to identify the error. Used as part of 'id' attribute in HTML output. 134 | * @type string $message The formatted message text to display to the user (will be shown inside styled 135 | * `
` and `

` tags). 136 | * @type string $type Optional. Message type, controls HTML class. Possible values include 'error', 137 | * 'success', 'warning', 'info'. Default 'error'. 138 | * } 139 | * } 140 | * @psalm-return array 146 | */ 147 | function get_settings_errors( $setting = '', $sanitize = false ) : array {} 148 | 149 | /** 150 | * @param string $path 151 | * @param 'https'|'http'|'relative'|'rest' $scheme 152 | * @return string 153 | */ 154 | function home_url( string $path = '', $scheme = null ) : string {} 155 | 156 | /** 157 | * @template TArgs of array 158 | * @template TDefaults of array 159 | * @psalm-param TArgs $args 160 | * @psalm-param TDefaults $defaults 161 | * @psalm-return TDefaults&TArgs 162 | */ 163 | function wp_parse_args( $args, $defaults ) {} 164 | 165 | /** 166 | * @param WP_Error|mixed $error 167 | * @psalm-assert-if-true WP_Error $error 168 | */ 169 | function is_wp_error( $error ) : bool {} 170 | 171 | /** 172 | * @template T 173 | * @template K 174 | * @param array> $list 175 | * @param K $column 176 | * @return list 177 | */ 178 | function wp_list_pluck( array $list, string $column, string $index_key = null ) : array {} 179 | -------------------------------------------------------------------------------- /test.php: -------------------------------------------------------------------------------- 1 |