diff --git a/README.md b/README.md index 102f6af..454489a 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,26 @@ The `stubs/wordpress-override.php` file provides corrected type definitions for stubFiles: - vendor/wpsyntex/polylang-phpstan/stubs/wordpress-override.php ``` + +## Language switcher typing (Polylang 3.9+) + +Polylang 3.9 replaced `PLL_Switcher::the_languages()` with `pll_the_languages()` and the internal `WP_Syntex\Polylang\Switcher\Switcher` class. + +This extension provides dynamic return types for `pll_the_languages()`: + +| `$args` | Return type | +|---|---| +| `raw => true` | `array` | +| `echo => false` | `string` | +| default (`echo => true`) | `void` | + +Direct usage of `WP_Syntex\Polylang\Switcher\Switcher` is typed from Polylang stubs or from Polylang source when you analyze the plugin itself. + +### Deprecated `PLL_Switcher` + +`PLL_Switcher` was removed in Polylang 3.9. When this extension is enabled, PHPStan reports: + +- `polylang.deprecatedSwitcher` on `new PLL_Switcher()` +- `polylang.deprecatedSwitcherMethod` on `PLL_Switcher::the_languages()` + +Migrate to `pll_the_languages()` or to `WP_Syntex\Polylang\Switcher\Switcher`. diff --git a/composer.json b/composer.json index 4de0ef7..4ab4794 100644 --- a/composer.json +++ b/composer.json @@ -6,9 +6,11 @@ "type": "library", "require": { "php": "^8.0", + "phpstan/phpstan": "^2.0", "szepeviktor/phpstan-wordpress": "^2.0" }, "require-dev": { + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^9", "wpsyntex/polylang-stubs": "dev-master" }, @@ -18,6 +20,7 @@ } }, "scripts": { - "test":"vendor/bin/phpunit -d memory_limit=1G" + "test": "vendor/bin/phpunit -d memory_limit=1G", + "stan": "vendor/bin/phpstan analyse src/GuessTypeFromSwitcherArgs.php src/TheLanguagesFunctionReturnTypeExtension.php --memory-limit=512M" } } diff --git a/extension.neon b/extension.neon index 76f4fcc..09c70c7 100644 --- a/extension.neon +++ b/extension.neon @@ -3,10 +3,6 @@ services: class: WPSyntex\Polylang\PHPStan\LanguageReturnTypeExtension tags: - phpstan.broker.dynamicFunctionReturnTypeExtension - - - class: WPSyntex\Polylang\PHPStan\SwitcherClassReturnTypeExtension - tags: - - phpstan.broker.dynamicMethodReturnTypeExtension - class: WPSyntex\Polylang\PHPStan\TheLanguagesFunctionReturnTypeExtension tags: @@ -23,6 +19,10 @@ services: class: WPSyntex\Polylang\PHPStan\OptionsGetDynamicMethodReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\Rules\DeprecatedPllSwitcherRule + tags: + - phpstan.rules.rule includes: - ../../szepeviktor/phpstan-wordpress/extension.neon parameters: @@ -30,6 +30,8 @@ parameters: - bootstrap.php stubFiles: - %rootDir%/../../php-stubs/wordpress-stubs/wordpress-stubs.php + scanFiles: + - stubs/pll-switcher-deprecated.php dynamicConstantNames: - POLYLANG_VERSION - PLL_ADMIN diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..a11b80b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,4 @@ +parameters: + level: 9 + paths: + - src/ diff --git a/src/GuessTypeFromSwitcherArgs.php b/src/GuessTypeFromSwitcherArgs.php new file mode 100644 index 0000000..1668755 --- /dev/null +++ b/src/GuessTypeFromSwitcherArgs.php @@ -0,0 +1,127 @@ +guessBooleanArg( $arg, $scope, 'raw', TrinaryLogic::createNo() ); + + if ( $isRaw->yes() ) { + return $this->getRawReturnType(); + } + + if ( $isRaw->maybe() ) { + return TypeCombinator::union( + $this->getRawReturnType(), + $this->guessNonRawReturnType( $arg, $scope ) + ); + } + + return $this->guessNonRawReturnType( $arg, $scope ); + } + + private function guessNonRawReturnType( Arg $arg, Scope $scope ): Type { + // `echo` defaults to true: the markup is printed and nothing is returned. + $isEcho = $this->guessBooleanArg( $arg, $scope, 'echo', TrinaryLogic::createYes() ); + + if ( $isEcho->yes() ) { + return new VoidType(); + } + + if ( $isEcho->no() ) { + return new StringType(); + } + + return TypeCombinator::union( new VoidType(), new StringType() ); + } + + private function getRawReturnType(): Type { + return new ArrayType( new StringType(), new MixedType() ); + } + + private function guessBooleanArg( Arg $arg, Scope $scope, string $key, TrinaryLogic $whenAbsent ): TrinaryLogic { + $argsType = $scope->getType( $arg->value ); + $keyType = new ConstantStringType( $key ); + + $hasKey = $argsType->hasOffsetValueType( $keyType ); + + if ( $hasKey->yes() ) { + return $argsType->getOffsetValueType( $keyType )->toBoolean()->isTrue(); + } + + if ( $hasKey->no() ) { + return $whenAbsent; + } + + return $this->guessBooleanArgFromConstantArrays( $argsType, $key ); + } + + private function guessBooleanArgFromConstantArrays( Type $argsType, string $key ): TrinaryLogic { + $constantArrays = $argsType->getConstantArrays(); + + if ( [] === $constantArrays ) { + return TrinaryLogic::createMaybe(); + } + + $knownResult = null; + + foreach ( $constantArrays as $constantArray ) { + $result = $this->guessBooleanArgFromConstantArray( $constantArray, $key ); + + if ( $result->maybe() ) { + continue; + } + + if ( null === $knownResult ) { + $knownResult = $result; + continue; + } + + if ( $knownResult->yes() !== $result->yes() ) { + return TrinaryLogic::createMaybe(); + } + } + + return $knownResult ?? TrinaryLogic::createMaybe(); + } + + private function guessBooleanArgFromConstantArray( ConstantArrayType $constantArray, string $key ): TrinaryLogic { + foreach ( $constantArray->getKeyTypes() as $index => $argKey ) { + if ( ! $this->isConstantStringKey( $argKey, $key ) ) { + continue; + } + + return $constantArray->getValueTypes()[ $index ]->toBoolean()->isTrue(); + } + + return TrinaryLogic::createMaybe(); + } + + private function isConstantStringKey( Type $keyType, string $key ): bool { + foreach ( $keyType->getConstantStrings() as $constantString ) { + if ( $constantString->getValue() === $key ) { + return true; + } + } + + return false; + } +} diff --git a/src/GuessTypeFromSwitcherAttributes.php b/src/GuessTypeFromSwitcherAttributes.php deleted file mode 100644 index 25411ba..0000000 --- a/src/GuessTypeFromSwitcherAttributes.php +++ /dev/null @@ -1,76 +0,0 @@ -value; - - $isRaw = TrinaryLogic::createMaybe(); - - if ($args instanceof Expr) { - $argsType = $scope->getType($args); - $argsKeys = []; - $argsValues = []; - - if ($argsType->isArray()->yes()) { - $argsKeys = $argsType->getKeysArray(); - $argsValues = $argsType->getValuesArray(); - } - - if ($argsType instanceof IntersectionType && $argsType->isIterable()) { - // Let's look into each types to see if it contains 'raw' key. - $types = $argsType->getTypes(); - foreach($types as $type) { - $rawKey = new ConstantStringType('raw'); - if ($type->hasOffsetValueType($rawKey)->yes()) { - $isRaw = $type->getOffsetValueType($rawKey)->toBoolean()->isTrue(); - } - } - } - - if ($argsKeys->isConstantArray()->yes()) { - foreach ($argsKeys->getValueTypes() as $index => $key) { - if ($key->getValue() !== 'raw') { - // Current argument is not 'raw' parameter. - continue; - } - if ($argsValues->getValueTypes()[$index]->getValue()) { - // Current argument set 'raw' to 'true'. - $isRaw = TrinaryLogic::createYes(); - break; - } - } - // If none 'raw' parameter set to 'true' is found, consider it's 'false'. - $isRaw = $isRaw->yes() ? $isRaw : TrinaryLogic::createNo(); - } - } - - if ($isRaw->maybe()) { - // Can't guess type precisely, return 'array|string'. - return TypeCombinator::union(new ArrayType(new StringType(), new MixedType()), new StringType()); - } - - if ($isRaw->yes()) { - // Switcher output is raw, return 'array'. - return new ArrayType(new StringType(), new MixedType()); - } - - // Raw is considered false, return 'string'. - return new StringType(); - } -} diff --git a/src/Rules/DeprecatedPllSwitcherRule.php b/src/Rules/DeprecatedPllSwitcherRule.php new file mode 100644 index 0000000..6f5a4fd --- /dev/null +++ b/src/Rules/DeprecatedPllSwitcherRule.php @@ -0,0 +1,93 @@ + + */ +class DeprecatedPllSwitcherRule implements Rule { + private const CLASS_NAME = 'PLL_Switcher'; + + private const CLASS_MESSAGE = 'Class PLL_Switcher was removed in Polylang 3.9. Use pll_the_languages() or WP_Syntex\Polylang\Switcher\Switcher instead.'; + + private const METHOD_MESSAGE = 'Method PLL_Switcher::the_languages() was removed in Polylang 3.9. Use pll_the_languages() instead.'; + + public function getNodeType(): string { + return Expr::class; + } + + public function processNode( Node $node, Scope $scope ): array { + if ( ! $node instanceof Expr ) { + return []; + } + + if ( $node instanceof New_ ) { + return $this->processInstantiation( $node ); + } + + if ( $node instanceof MethodCall ) { + return $this->processMethodCall( $node, $scope ); + } + + return []; + } + + /** + * @return list + */ + private function processInstantiation( New_ $node ): array { + if ( ! $node->class instanceof Name ) { + return []; + } + + if ( self::CLASS_NAME !== $node->class->toString() ) { + return []; + } + + return [ + RuleErrorBuilder::message( self::CLASS_MESSAGE ) + ->line( $node->getStartLine() ) + ->identifier( 'polylang.deprecatedSwitcher' ) + ->build(), + ]; + } + + /** + * @return list + */ + private function processMethodCall( MethodCall $node, Scope $scope ): array { + if ( ! $node->name instanceof Node\Identifier ) { + return []; + } + + if ( 'the_languages' !== $node->name->toString() ) { + return []; + } + + $calledOnType = $scope->getType( $node->var ); + + if ( ! ( new ObjectType( self::CLASS_NAME ) )->isSuperTypeOf( $calledOnType )->yes() ) { + return []; + } + + return [ + RuleErrorBuilder::message( self::METHOD_MESSAGE ) + ->line( $node->getStartLine() ) + ->identifier( 'polylang.deprecatedSwitcherMethod' ) + ->build(), + ]; + } +} diff --git a/src/SwitcherClassReturnTypeExtension.php b/src/SwitcherClassReturnTypeExtension.php deleted file mode 100644 index 10fbc2d..0000000 --- a/src/SwitcherClassReturnTypeExtension.php +++ /dev/null @@ -1,46 +0,0 @@ -getName() === 'the_languages'; - } - - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type - { - $args = $methodCall->getArgs(); - - if (count($args) === 0) { - return ParametersAcceptorSelector::selectFromArgs( - $scope, - $methodCall->getArgs(), - $methodReflection->getVariants() - )->getReturnType(); - } - - if(isset($args[1])) { - return $this->guessType($args[1], $scope); - } - - // No attributes provided to the switcher, default type 'string'. - return new StringType(); - } -} diff --git a/src/TheLanguagesFunctionReturnTypeExtension.php b/src/TheLanguagesFunctionReturnTypeExtension.php index e4ee74a..2cafe45 100644 --- a/src/TheLanguagesFunctionReturnTypeExtension.php +++ b/src/TheLanguagesFunctionReturnTypeExtension.php @@ -2,34 +2,27 @@ namespace WPSyntex\Polylang\PHPStan; -use WPSyntex\Polylang\PHPStan\GuessTypeFromSwitcherAttributes; use PhpParser\Node\Expr\FuncCall; +use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\DynamicFunctionReturnTypeExtension; -use PHPStan\Reflection\ParametersAcceptorSelector; -use PHPStan\Analyser\Scope; use PHPStan\Type\Type; -use PHPStan\Type\StringType; +use PHPStan\Type\VoidType; class TheLanguagesFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - use GuessTypeFromSwitcherAttributes; + use GuessTypeFromSwitcherArgs; - public function isFunctionSupported(FunctionReflection $functionReflection): bool - { - return $functionReflection->getName() === 'pll_the_languages'; + public function isFunctionSupported( FunctionReflection $functionReflection ): bool { + return 'pll_the_languages' === $functionReflection->getName(); } - public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): Type - { + public function getTypeFromFunctionCall( FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope ): Type { $args = $funcCall->getArgs(); - if (count($args) === 0) { - // No attributes provided to the switcher, default type 'string'. - return new StringType(); + if ( 0 === count( $args ) ) { + return new VoidType(); } - $switcherAttributes = reset($args); - - return $this->guessType($switcherAttributes, $scope); + return $this->guessPllTheLanguagesReturnType( reset( $args ), $scope ); } } diff --git a/stubs/pll-switcher-deprecated.php b/stubs/pll-switcher-deprecated.php new file mode 100644 index 0000000..500b766 --- /dev/null +++ b/stubs/pll-switcher-deprecated.php @@ -0,0 +1,22 @@ + + */ + public function the_languages( $links, $args = array() ) { + } +} diff --git a/tests/DeprecatedApiTest.php b/tests/DeprecatedApiTest.php new file mode 100644 index 0000000..c1ff9d3 --- /dev/null +++ b/tests/DeprecatedApiTest.php @@ -0,0 +1,37 @@ +analyse( + [ __DIR__ . '/data/deprecated_pll_switcher.php' ], + [ + [ + 'Class PLL_Switcher was removed in Polylang 3.9. Use pll_the_languages() or WP_Syntex\Polylang\Switcher\Switcher instead.', + 11, + ], + [ + 'Method PLL_Switcher::the_languages() was removed in Polylang 3.9. Use pll_the_languages() instead.', + 13, + ], + ] + ); + } + + public static function getAdditionalConfigFiles(): array { + return [ + __DIR__ . '/phpstan.neon', + __DIR__ . '/test-extension.neon', + ]; + } +} diff --git a/tests/DynamicReturnTypeExtensionTest.php b/tests/DynamicReturnTypeExtensionTest.php index 6984e8c..d266550 100644 --- a/tests/DynamicReturnTypeExtensionTest.php +++ b/tests/DynamicReturnTypeExtensionTest.php @@ -4,35 +4,29 @@ namespace WPSyntex\Polylang\PHPStan\Tests; -class DynamicReturnTypeExtensionTest extends \PHPStan\Testing\TypeInferenceTestCase -{ +class DynamicReturnTypeExtensionTest extends \PHPStan\Testing\TypeInferenceTestCase { /** * @return iterable */ - public function dataFileAsserts(): iterable - { - // Path to a file with actual asserts of expected types: - yield from $this->gatherAssertTypes(__DIR__ . '/data/the_languages.php'); - yield from $this->gatherAssertTypes(__DIR__ . '/data/pll_the_languages.php'); - yield from $this->gatherAssertTypes(__DIR__ . '/data/get_languages_list.php'); - yield from $this->gatherAssertTypes(__DIR__ . '/data/options_get.php'); + public function dataFileAsserts(): iterable { + yield from $this->gatherAssertTypes( __DIR__ . '/data/pll_the_languages.php' ); + yield from $this->gatherAssertTypes( __DIR__ . '/data/switcher.php' ); + yield from $this->gatherAssertTypes( __DIR__ . '/data/get_languages_list.php' ); + yield from $this->gatherAssertTypes( __DIR__ . '/data/options_get.php' ); } /** * @dataProvider dataFileAsserts * @param array ...$args */ - public function testFileAsserts(string $assertType, string $file, ...$args): void - { - $this->assertFileAsserts($assertType, $file, ...$args); + public function testFileAsserts( string $assertType, string $file, ...$args ): void { + $this->assertFileAsserts( $assertType, $file, ...$args ); } - public static function getAdditionalConfigFiles(): array - { - // phpstan.neon or extension.neon use relative paths, so we need to use a fake vendor directory regarding szepeviktor/phpstan-wordpress dependency. + public static function getAdditionalConfigFiles(): array { return [ - dirname(__DIR__) . '/vendor/wpsyntex/polylang-phpstan/extension.neon', - dirname(__DIR__) . '/vendor/wpsyntex/polylang-phpstan/test-extension.neon', + __DIR__ . '/phpstan.neon', + __DIR__ . '/test-extension.neon', ]; } } diff --git a/tests/data/deprecated_pll_switcher.php b/tests/data/deprecated_pll_switcher.php new file mode 100644 index 0000000..08f0249 --- /dev/null +++ b/tests/data/deprecated_pll_switcher.php @@ -0,0 +1,13 @@ +the_languages( $links, [ 'raw' => true ] ); diff --git a/tests/data/pll_the_languages.php b/tests/data/pll_the_languages.php index 1876615..aa6053b 100644 --- a/tests/data/pll_the_languages.php +++ b/tests/data/pll_the_languages.php @@ -6,46 +6,45 @@ use function PHPStan\Testing\assertType; -/** @var \PLL_Links */ -$link = $link; - /** @var array */ $array = $array; -$attributes = ['foo' => 'bar']; - -// Raw attribute set to true. -assertType('array', pll_the_languages(['raw' => true])); - -// Raw attribute set to true with array_merge. -assertType('array', pll_the_languages(array_merge($attributes, ['raw' => true]))); +/** @var array */ +$options = $options; -// Raw attribute set to false. -assertType('string', pll_the_languages(['raw' => false])); +$attributes = [ 'foo' => 'bar' ]; -// Raw attribute set to false with array_merge and variable with known values. -assertType('string', pll_the_languages(array_merge($attributes, ['raw' => false]))); +// raw => true +assertType( 'array', pll_the_languages( [ 'raw' => true ] ) ); +assertType( 'array', pll_the_languages( array_merge( $attributes, [ 'raw' => true ] ) ) ); -// Without raw set. -assertType('string', pll_the_languages($attributes)); +// echo => false +assertType( 'string', pll_the_languages( [ 'echo' => false ] ) ); +assertType( 'string', pll_the_languages( [ 'raw' => false, 'echo' => false ] ) ); +assertType( 'string', pll_the_languages( array_merge( $attributes, [ 'echo' => false ] ) ) ); -// With empty array. -assertType('string', pll_the_languages([])); - -// Default attributes. -assertType('string', pll_the_languages()); +// Default: echo is true, nothing is returned. +assertType( 'void', pll_the_languages() ); +assertType( 'void', pll_the_languages( [] ) ); +assertType( 'void', pll_the_languages( $attributes ) ); +assertType( 'void', pll_the_languages( [ 'raw' => false ] ) ); +assertType( 'void', pll_the_languages( array_merge( $attributes, [ 'raw' => false ] ) ) ); // Unknown attributes. -assertType('array|string', pll_the_languages($array)); +assertType( 'array|string|void', pll_the_languages( $array ) ); // With unknown variable merged. $args = array_merge( [ 'raw' => 1 ], $options ); -assertType('array|string', pll_the_languages($args)); +assertType( 'array|string|void', pll_the_languages( $args ) ); // With raw attribute set to true outside. $array['raw'] = 1; -assertType('array', pll_the_languages($array)); +assertType( 'array', pll_the_languages( $array ) ); -// With raw attribute set to false outside. +// With raw attribute set to false outside a previously unknown array. $array['raw'] = false; -assertType('string', pll_the_languages($array)); +assertType( 'string|void', pll_the_languages( $array ) ); + +// With echo attribute set to false outside. +$array['echo'] = false; +assertType( 'string', pll_the_languages( $array ) ); diff --git a/tests/data/switcher.php b/tests/data/switcher.php new file mode 100644 index 0000000..b4c4e88 --- /dev/null +++ b/tests/data/switcher.php @@ -0,0 +1,20 @@ +get() ); +assertType( 'array', $switcher->get_elements() ); diff --git a/tests/data/the_languages.php b/tests/data/the_languages.php deleted file mode 100644 index 5fde6af..0000000 --- a/tests/data/the_languages.php +++ /dev/null @@ -1,66 +0,0 @@ - 'bar']; - -// Raw attribute set to true. -assertType('array', $switcher->the_languages($link, ['raw' => true])); - -// Raw attribute set tot true with array_merge. -assertType('array', $switcher->the_languages($link, array_merge($attributes, ['raw' => true]))); - -// Raw attribute set to false. -assertType('string', $switcher->the_languages($link, ['raw' => false])); - -// Raw attribute set to false with array_merge. -assertType('string', $switcher->the_languages($link, array_merge($attributes, ['raw' => false]))); - -// Without raw set. -assertType('string', $switcher->the_languages($link, $attributes)); - -// With empty array. -assertType('string', $switcher->the_languages($link, [])); - -// Default attributes. -assertType('string', $switcher->the_languages($link)); - -// Unknown attributes. -assertType('array|string', $switcher->the_languages($link, $array)); - -// With raw attribute set to true and merged into an array. -$args = array_merge( $array, [ 'raw' => 1 ] ); -assertType('array', $switcher->the_languages($link, $args)); - -// With raw attribute set to true and merged with an array. -$args = array_merge( [ 'raw' => true ], $array ); -assertType('array|string', $switcher->the_languages($link, $args)); - -// With raw attribute set to true outside. -$array['raw'] = 1; -assertType('array', $switcher->the_languages($link, $array)); - -// With raw attribute set to false outside. -$array['raw'] = false; -assertType('string', $switcher->the_languages($link, $array)); - -// With raw attribute set to false and merged in an array. -$args = array_merge( $array, [ 'raw' => false ] ); -assertType('string', $switcher->the_languages($link, $array)); - -// With raw attribute set to false and merged with an array. -$args = array_merge( [ 'raw' => false ], $array ); -assertType('string', $switcher->the_languages($link, $array)); diff --git a/tests/phpstan.neon b/tests/phpstan.neon new file mode 100644 index 0000000..3a45a48 --- /dev/null +++ b/tests/phpstan.neon @@ -0,0 +1,40 @@ +includes: + - ../vendor/szepeviktor/phpstan-wordpress/extension.neon + +services: + - + class: WPSyntex\Polylang\PHPStan\LanguageReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\TheLanguagesFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\PLLModelGetLanguagesListDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\ModelLanguagesGetListDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\OptionsGetDynamicMethodReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\Rules\DeprecatedPllSwitcherRule + tags: + - phpstan.rules.rule + +parameters: + bootstrapFiles: + - ../bootstrap.php + stubFiles: + - ../vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + scanFiles: + - ../stubs/pll-switcher-deprecated.php + dynamicConstantNames: + - POLYLANG_VERSION + - PLL_ADMIN + - PLL_SETTINGS