diff --git a/composer.json b/composer.json index 59df0a7af3770..aee7a09524994 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,11 @@ }, "lock": false }, + "autoload-dev": { + "psr-4": { + "WordPress\\PHPStan\\": "tests/phpstan/" + } + }, "scripts": { "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G", "compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source", diff --git a/tests/phpstan/GlobalDocBlockVisitor.php b/tests/phpstan/GlobalDocBlockVisitor.php new file mode 100644 index 0000000000000..c5fbabfd336ee --- /dev/null +++ b/tests/phpstan/GlobalDocBlockVisitor.php @@ -0,0 +1,156 @@ +> + */ + private array $stack = array(); + + /** + * Resets state at the start of each parser traversal. + * + * @param array $nodes Top-level nodes about to be traversed. + * @return array|null + */ + public function beforeTraverse( array $nodes ): ?array { + $this->stack = array(); + return null; + } + + /** + * Pushes a frame when entering a function/method, and injects synthetic + * `@var` doc comments on `global` statements that match a documented tag. + * + * @param Node $node The node being entered. + * @return null + */ + public function enterNode( Node $node ): ?Node { + if ( $node instanceof Node\FunctionLike ) { + $doc = $node->getDocComment(); + $this->stack[] = $doc !== null ? $this->parse_global_tags( $doc->getText() ) : array(); + return null; + } + + if ( ! ( $node instanceof Node\Stmt\Global_ ) || $this->stack === array() ) { + return null; + } + + $map = $this->stack[ count( $this->stack ) - 1 ]; + if ( $map === array() ) { + return null; + } + + /* + * Collect variable names that already have a handwritten `@var` on this + * statement so we can leave them alone but still inject `@var` lines for + * the remaining variables in a multi-variable `global $a, $b;` statement. + */ + $existing = $node->getDocComment(); + $existing_text = $existing !== null ? $existing->getText() : ''; + $already_typed = array(); + if ( $existing_text !== '' && preg_match_all( '/@(?:phpstan-)?var\s+[^\n]*?\$(\w+)/', $existing_text, $existing_matches ) > 0 ) { + $already_typed = array_flip( $existing_matches[1] ); + } + + $lines = array(); + foreach ( $node->vars as $var ) { + if ( ! $var instanceof Node\Expr\Variable || ! is_string( $var->name ) ) { + continue; + } + if ( isset( $already_typed[ $var->name ] ) || ! isset( $map[ $var->name ] ) ) { + continue; + } + $lines[] = sprintf( ' * @var %s $%s', $map[ $var->name ], $var->name ); + } + + if ( $lines === array() ) { + return null; + } + + if ( $existing_text === '' ) { + $node->setDocComment( new Doc( "/**\n" . implode( "\n", $lines ) . "\n */" ) ); + } else { + // Insert the new `@var` lines just before the closing `*/`. + $merged = preg_replace( '#\s*\*/\s*$#', "\n" . implode( "\n", $lines ) . "\n */", $existing_text, 1 ); + $node->setDocComment( new Doc( (string) $merged ) ); + } + + return null; + } + + /** + * Pops the function-like frame on the way back up. + * + * @param Node $node The node being left. + * @return null + */ + public function leaveNode( Node $node ): ?Node { + if ( $node instanceof Node\FunctionLike ) { + array_pop( $this->stack ); + } + return null; + } + + /** + * Extracts `@global Type $varname` tags from a docblock. + * + * Handles union types (`A|B`) and namespaced/array forms (`A\B`, `A[]`). + * Whitespace inside the type is collapsed. + * + * @param string $docblock Raw docblock text including `/**` markers. + * @return array Map of variable name (no `$`) to type. + */ + private function parse_global_tags( string $docblock ): array { + $map = array(); + if ( preg_match_all( '/@global\s+(?P\S.*?)\s+\$(?P\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) { + foreach ( $matches as $match ) { + $type = preg_replace( '/\s+/', '', $match['type'] ); + assert( is_string( $type ) && '' !== $type ); + $map[ $match['variable'] ] = $type; + } + } + return $map; + } +} diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 318b9969a928d..b790d318e110c 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -4,6 +4,14 @@ # # https://phpstan.org/config-reference +services: + # Bridges WordPress core's `@global Type $varname` function docblock convention to + # PHPStan's variable type resolution. See tests/phpstan/GlobalDocBlockVisitor.php. + - + class: WordPress\PHPStan\GlobalDocBlockVisitor + tags: + - phpstan.parser.richParserNodeVisitor + parameters: # Cache is stored locally, so it's available for CI. tmpDir: ../../.cache @@ -90,6 +98,7 @@ parameters: - ../../src/wp-signup.php - ../../src/wp-trackback.php - ../../src/xmlrpc.php + - GlobalDocBlockVisitor.php bootstrapFiles: - bootstrap.php scanFiles: diff --git a/tests/phpstan/bootstrap.php b/tests/phpstan/bootstrap.php index c87a26babf83d..6eedeec93c4a7 100644 --- a/tests/phpstan/bootstrap.php +++ b/tests/phpstan/bootstrap.php @@ -1,8 +1,10 @@