diff --git a/.gitignore b/.gitignore index c928539..2c8c0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Directories /.vscode /vendor +/tests/tmp # Files .DS_Store diff --git a/README.md b/README.md index 454489a..9f4d940 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,30 @@ Direct usage of `WP_Syntex\Polylang\Switcher\Switcher` is typed from Polylang st - `polylang.deprecatedSwitcherMethod` on `PLL_Switcher::the_languages()` Migrate to `pll_the_languages()` or to `WP_Syntex\Polylang\Switcher\Switcher`. + +## Options typing (Polylang 3.7+) + +Polylang 3.7+ stores settings in `WP_Syntex\Polylang\Options\Options`, which implements `ArrayAccess`. Without this extension, `$options['key']` and `$options->get( 'key' )` stay `mixed` even for registered schema keys. + +This extension infers per-key value types when the key is a constant string, for both bracket access and `get()` / `offsetGet()`: + +```php +$version = $options['version']; // string +$domains = $options->get( 'domains' ); // array +``` + +Unknown keys infer `null` (not `mixed`). + +### Polylang Pro options + +Pro-only keys (`media`, `machine_translation_enabled`, `machine_translation_service`, `machine_translation_services`) are typed only when `POLYLANG_PRO_PHPSTAN` is `true`. Define it in your PHPStan bootstrap file (as in Polylang Pro’s `tests/phpstan/phpstan-bootstrap.php`) and list it under `parameters.dynamicConstantNames` in `extension.neon` (already included when you use this package’s extension). + +```php +define( 'POLYLANG_PRO_PHPSTAN', true ); +``` + +### Known limitations + +- Unregistered or raw database keys under Polylang’s option storage are not typed. +- `array_combine( $options['post_types'], $options['post_types'] )` is inferred as `array` by PHPStan 2 when both operands share the same list type; older PHPStan versions may still need a `@phpstan-var` on the result. +- `foreach ( $options['domains'] as $lang => $domain )` infers map key and value types when iterating the option array directly (without casting to `(array)`). diff --git a/extension.neon b/extension.neon index 09c70c7..afe800e 100644 --- a/extension.neon +++ b/extension.neon @@ -15,10 +15,20 @@ services: class: WPSyntex\Polylang\PHPStan\ModelLanguagesGetListDynamicMethodReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\OptionTypes - class: WPSyntex\Polylang\PHPStan\OptionsGetDynamicMethodReturnTypeExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\OptionsOffsetExpressionTypeResolverExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes + tags: + - phpstan.broker.expressionTypeResolverExtension - class: WPSyntex\Polylang\PHPStan\Rules\DeprecatedPllSwitcherRule tags: @@ -36,3 +46,4 @@ parameters: - POLYLANG_VERSION - PLL_ADMIN - PLL_SETTINGS + - POLYLANG_PRO_PHPSTAN diff --git a/src/OptionTypes.php b/src/OptionTypes.php new file mode 100644 index 0000000..78b5ef9 --- /dev/null +++ b/src/OptionTypes.php @@ -0,0 +1,149 @@ +proEnabled = $proEnabled; + } + + private function isProEnabled(): bool { + return $this->proEnabled || ( defined( 'POLYLANG_PRO_PHPSTAN' ) && POLYLANG_PRO_PHPSTAN ); + } + + /** + * Returns the PHPStan type for a known option key, or null for unknown keys. + */ + public function getTypeForKey( string $key ): ?Type { + switch ( $key ) { + case 'browser': + case 'hide_default': + case 'media_support': + case 'redirect_lang': + case 'rewrite': + return new BooleanType(); + + case 'default_lang': + case 'previous_version': + case 'version': + return new StringType(); + + case 'domains': + return new ArrayType( + $this->getNonFalsyStringType(), + new StringType() + ); + + case 'language_taxonomies': + case 'post_types': + case 'sync': + case 'taxonomies': + return new ArrayType( + new IntegerType(), + $this->getNonFalsyStringType() + ); + + case 'nav_menus': + return new ArrayType( + $this->getNonFalsyStringType(), + new ArrayType( + $this->getNonFalsyStringType(), + new ArrayType( + $this->getNonFalsyStringType(), + IntegerRangeType::fromInterval( 0, \PHP_INT_MAX ) + ) + ) + ); + + case 'first_activation': + return IntegerRangeType::fromInterval( 0, \PHP_INT_MAX ); + + case 'force_lang': + return IntegerRangeType::fromInterval( 0, 3 ); + + default: + return $this->getProTypeForKey( $key ); + } + } + + /** + * Returns the PHPStan type for an option key (known keys or null for unknown). + */ + public function getTypeForKeyOrNull( string $key ): Type { + return $this->getTypeForKey( $key ) ?? new NullType(); + } + + private function getProTypeForKey( string $key ): ?Type { + if ( ! $this->isProEnabled() ) { + return null; + } + + switch ( $key ) { + case 'media': + return new ArrayType( + $this->getNonFalsyStringType(), + new BooleanType() + ); + + case 'machine_translation_enabled': + return new BooleanType(); + + case 'machine_translation_service': + return $this->getNonFalsyStringType(); + + case 'machine_translation_services': + return new IntersectionType( + [ + new ArrayType( + $this->getNonFalsyStringType(), + new ArrayType( + $this->getNonFalsyStringType(), + new StringType() + ) + ), + new NonEmptyArrayType(), + ] + ); + + default: + return null; + } + } + + private function getNonFalsyStringType(): Type { + return new IntersectionType( + [ + new StringType(), + new AccessoryNonFalsyStringType(), + ] + ); + } +} diff --git a/src/OptionsGetDynamicMethodReturnTypeExtension.php b/src/OptionsGetDynamicMethodReturnTypeExtension.php index 1b5995c..7b99d6c 100644 --- a/src/OptionsGetDynamicMethodReturnTypeExtension.php +++ b/src/OptionsGetDynamicMethodReturnTypeExtension.php @@ -10,21 +10,17 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; -use PHPStan\Type\Accessory\AccessoryArrayListType; -use PHPStan\Type\Accessory\NonEmptyArrayType; -use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\ArrayType; -use PHPStan\Type\BooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\IntegerRangeType; -use PHPStan\Type\IntegerType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\NullType; -use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; class OptionsGetDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension { + + public function __construct( + private OptionTypes $optionTypes, + ) { + } + public function getClass(): string { return \WP_Syntex\Polylang\Options\Options::class; } @@ -38,129 +34,20 @@ public function getTypeFromMethodCall( MethodReflection $methodReflection, Metho return null; } - $argumentType = $scope->getType( $methodCall->getArgs()[0]->value ); + $constantStrings = $scope->getType( $methodCall->getArgs()[0]->value )->getConstantStrings(); // When called with a type that isn't a constant string, return default return type. - if ( count( $argumentType->getConstantStrings() ) === 0 ) { + if ( count( $constantStrings ) === 0 ) { return null; } // Called with a constant string type. $returnType = []; - foreach ( $argumentType->getConstantStrings() as $constantString ) { - switch ( $constantString->getValue() ) { - case 'browser': - case 'hide_default': - case 'media_support': - case 'redirect_lang': - case 'rewrite': - $returnType[] = new BooleanType(); - break; - - case 'default_lang': - case 'previous_version': - case 'version': - $returnType[] = new StringType(); - break; - - case 'domains': - $returnType[] = new ArrayType( - $this->getNonFalsyStringType(), - new StringType() - ); - break; - - case 'language_taxonomies': - case 'post_types': - case 'sync': - case 'taxonomies': - $returnType[] = new ArrayType( - new IntegerType(), - $this->getNonFalsyStringType() - ); - break; - - case 'nav_menus': - $returnType[] = new ArrayType( - $this->getNonFalsyStringType(), - new ArrayType( - $this->getNonFalsyStringType(), - new ArrayType( - $this->getNonFalsyStringType(), - IntegerRangeType::fromInterval( 0, \PHP_INT_MAX ) - ) - ) - ); - break; - - case 'first_activation': - $returnType[] = IntegerRangeType::fromInterval( 0, \PHP_INT_MAX ); - break; - - case 'force_lang': - $returnType[] = IntegerRangeType::fromInterval( 0, 3 ); - break; - - default: - $returnType[] = $this->getDefaultReturnType( $constantString->getValue() ); - } + foreach ( $constantStrings as $constantString ) { + $returnType[] = $this->optionTypes->getTypeForKeyOrNull( $constantString->getValue() ); } return TypeCombinator::union( ...$returnType ); } - - protected function getNonFalsyStringType(): Type { - return new IntersectionType( - [ - new StringType(), - new AccessoryNonFalsyStringType(), - ] - ); - } - - /** - * Returns the type to return (!) when the option name is unknown. - * Currently handles Polylang Pro's options. - * Can be overwritten to handle more option names. - * - * @param string $option_name Option name. - * @return Type - */ - protected function getDefaultReturnType( string $option_name ): Type { - if ( ! defined( 'POLYLANG_PRO_PHPSTAN' ) || ! POLYLANG_PRO_PHPSTAN ) { // Constant specific to PHPStan in Polylang Pro. - return new NullType(); - } - - switch ( $option_name ) { - case 'media': - return new ArrayType( - $this->getNonFalsyStringType(), - new BooleanType() - ); - - case 'machine_translation_enabled': - return new BooleanType(); - - case 'machine_translation_service': - return $this->getNonFalsyStringType(); - - case 'machine_translation_services': - return new IntersectionType( - [ - new ArrayType( - $this->getNonFalsyStringType(), - new ArrayType( - $this->getNonFalsyStringType(), - new StringType() - ) - ), - new NonEmptyArrayType(), - ] - ); - - default: - return new NullType(); - } - } } diff --git a/src/OptionsOffsetExpressionTypeResolverExtension.php b/src/OptionsOffsetExpressionTypeResolverExtension.php new file mode 100644 index 0000000..289efac --- /dev/null +++ b/src/OptionsOffsetExpressionTypeResolverExtension.php @@ -0,0 +1,49 @@ +dim === null ) { + return null; + } + + $varType = $scope->getType( $expr->var ); + if ( ! ( new ObjectType( Options::class ) )->isSuperTypeOf( $varType )->yes() ) { + return null; + } + + $constantStrings = $scope->getType( $expr->dim )->getConstantStrings(); + if ( count( $constantStrings ) === 0 ) { + return null; + } + + $returnTypes = []; + + foreach ( $constantStrings as $constantString ) { + $returnTypes[] = $this->optionTypes->getTypeForKeyOrNull( $constantString->getValue() ); + } + + return TypeCombinator::union( ...$returnTypes ); + } +} diff --git a/tests/DynamicReturnTypeExtensionTest.php b/tests/DynamicReturnTypeExtensionTest.php index d266550..7483375 100644 --- a/tests/DynamicReturnTypeExtensionTest.php +++ b/tests/DynamicReturnTypeExtensionTest.php @@ -12,7 +12,6 @@ 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' ); } /** diff --git a/tests/OptionTypesRegistrySyncTest.php b/tests/OptionTypesRegistrySyncTest.php new file mode 100644 index 0000000..3e8df8e --- /dev/null +++ b/tests/OptionTypesRegistrySyncTest.php @@ -0,0 +1,107 @@ + + */ + private const FREE_REGISTRY_KEYS = [ + 'force_lang', + 'domains', + 'hide_default', + 'rewrite', + 'redirect_lang', + 'browser', + 'media_support', + 'post_types', + 'taxonomies', + 'sync', + 'default_lang', + 'nav_menus', + 'first_activation', + 'previous_version', + 'version', + ]; + + /** + * Keys from polylang-pro/src/Options/Registry::OPTIONS (via Business\*::key()). + * + * @var list + */ + private const PRO_REGISTRY_KEYS = [ + 'media', + 'machine_translation_enabled', + 'machine_translation_service', + 'machine_translation_services', + ]; + + /** + * Legacy DB option kept for backward compatibility; not in Registry::OPTIONS. + * + * @var list + */ + private const LEGACY_KEYS = [ + 'language_taxonomies', + ]; + + public function testFreeRegistryKeysAreTyped(): void { + $optionTypes = new OptionTypes( false ); + + foreach ( self::FREE_REGISTRY_KEYS as $key ) { + $this->assertNotNull( $optionTypes->getTypeForKey( $key ), "Missing type for free registry key '{$key}'." ); + } + } + + public function testLegacyKeysAreTyped(): void { + $optionTypes = new OptionTypes( false ); + + foreach ( self::LEGACY_KEYS as $key ) { + $this->assertNotNull( $optionTypes->getTypeForKey( $key ), "Missing type for legacy key '{$key}'." ); + } + } + + public function testProRegistryKeysAreTypedOnlyWhenProEnabled(): void { + $proOptionTypes = new OptionTypes( true ); + + foreach ( self::PRO_REGISTRY_KEYS as $key ) { + $this->assertNotNull( $proOptionTypes->getTypeForKey( $key ), "Missing type for pro registry key '{$key}'." ); + } + + if ( defined( 'POLYLANG_PRO_PHPSTAN' ) && POLYLANG_PRO_PHPSTAN ) { + $this->markTestSkipped( 'POLYLANG_PRO_PHPSTAN is already defined in this PHP process.' ); + } + + $freeOptionTypes = new OptionTypes( false ); + + foreach ( self::PRO_REGISTRY_KEYS as $key ) { + $this->assertNull( $freeOptionTypes->getTypeForKey( $key ), "Pro key '{$key}' should be null in free mode." ); + } + } + + public function testUnknownKeysInferNull(): void { + $optionTypes = new OptionTypes( true ); + + $this->assertInstanceOf( NullType::class, $optionTypes->getTypeForKeyOrNull( 'unknown_option_key' ) ); + } + + /** + * @runInSeparateProcess + */ + public function testPolylangProPhpstanConstantEnablesProKeys(): void { + require __DIR__ . '/bootstrap-pro-phpstan.php'; + + $optionTypes = new OptionTypes( false ); + + foreach ( self::PRO_REGISTRY_KEYS as $key ) { + $this->assertNotNull( $optionTypes->getTypeForKey( $key ), "POLYLANG_PRO_PHPSTAN should enable type for '{$key}'." ); + } + } +} diff --git a/tests/OptionsProReturnTypeExtensionTest.php b/tests/OptionsProReturnTypeExtensionTest.php new file mode 100644 index 0000000..30b72ff --- /dev/null +++ b/tests/OptionsProReturnTypeExtensionTest.php @@ -0,0 +1,28 @@ + + */ + public function dataFileAsserts(): iterable { + yield from $this->gatherAssertTypes( __DIR__ . '/data/options_pro.php' ); + } + + /** + * @dataProvider dataFileAsserts + * @param array ...$args + */ + public function testFileAsserts( string $assertType, string $file, ...$args ): void { + $this->assertFileAsserts( $assertType, $file, ...$args ); + } + + public static function getAdditionalConfigFiles(): array { + return [ + __DIR__ . '/options-pro-phpstan.neon', + ]; + } +} diff --git a/tests/OptionsReturnTypeExtensionTest.php b/tests/OptionsReturnTypeExtensionTest.php new file mode 100644 index 0000000..9ea9bad --- /dev/null +++ b/tests/OptionsReturnTypeExtensionTest.php @@ -0,0 +1,30 @@ + + */ + public function dataFileAsserts(): iterable { + yield from $this->gatherAssertTypes( __DIR__ . '/data/options_get.php' ); + yield from $this->gatherAssertTypes( __DIR__ . '/data/options_offset.php' ); + yield from $this->gatherAssertTypes( __DIR__ . '/data/options_edge_cases.php' ); + } + + /** + * @dataProvider dataFileAsserts + * @param array ...$args + */ + public function testFileAsserts( string $assertType, string $file, ...$args ): void { + $this->assertFileAsserts( $assertType, $file, ...$args ); + } + + public static function getAdditionalConfigFiles(): array { + return [ + __DIR__ . '/options-phpstan.neon', + ]; + } +} diff --git a/tests/bootstrap-pro-phpstan.php b/tests/bootstrap-pro-phpstan.php new file mode 100644 index 0000000..04f6563 --- /dev/null +++ b/tests/bootstrap-pro-phpstan.php @@ -0,0 +1,7 @@ +', $postTypes); + +$combined = array_combine( $postTypes, $postTypes ); +assertType('array', $combined); + +foreach ( $options['domains'] as $lang => $domain ) { + assertType('non-falsy-string', $lang); + assertType('string', $domain); +} diff --git a/tests/data/options_offset.php b/tests/data/options_offset.php new file mode 100644 index 0000000..868a45d --- /dev/null +++ b/tests/data/options_offset.php @@ -0,0 +1,47 @@ +', $options['domains']); + +// With a list type option. +foreach ( [ 'language_taxonomies', 'post_types', 'sync', 'taxonomies' ] as $option_name ) { + assertType('array', $options[$option_name]); +} + +// With the nav menus option. +assertType('array>>>', $options['nav_menus']); + +// With the first activation option. +assertType('int<0, 9223372036854775807>', $options['first_activation']); + +// With the force lang option. +assertType('int<0, 3>', $options['force_lang']); + +// Bracket and method access parity. +assertType('string', $options['version']); +assertType('string', $options->get('version')); diff --git a/tests/data/options_pro.php b/tests/data/options_pro.php new file mode 100644 index 0000000..e572b61 --- /dev/null +++ b/tests/data/options_pro.php @@ -0,0 +1,21 @@ +', $options['media']); +assertType('bool', $options['machine_translation_enabled']); +assertType('non-falsy-string', $options['machine_translation_service']); +assertType('non-empty-array>', $options['machine_translation_services']); + +// Bracket and method access parity. +assertType('array', $options->get('media')); +assertType('bool', $options->get('machine_translation_enabled')); +assertType('non-falsy-string', $options->get('machine_translation_service')); +assertType('non-empty-array>', $options->get('machine_translation_services')); diff --git a/tests/options-phpstan.neon b/tests/options-phpstan.neon new file mode 100644 index 0000000..cb7d7ca --- /dev/null +++ b/tests/options-phpstan.neon @@ -0,0 +1,27 @@ +includes: + - ../vendor/szepeviktor/phpstan-wordpress/extension.neon + +services: + - + class: WPSyntex\Polylang\PHPStan\OptionTypes + - + class: WPSyntex\Polylang\PHPStan\OptionsGetDynamicMethodReturnTypeExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\OptionsOffsetExpressionTypeResolverExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes + tags: + - phpstan.broker.expressionTypeResolverExtension + +parameters: + tmpDir: %currentWorkingDirectory%/tests/tmp/phpstan-options + bootstrapFiles: + - ../bootstrap.php + stubFiles: + - ../vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + scanDirectories: + - ../../polylang/src/Options diff --git a/tests/options-pro-phpstan.neon b/tests/options-pro-phpstan.neon new file mode 100644 index 0000000..37762bf --- /dev/null +++ b/tests/options-pro-phpstan.neon @@ -0,0 +1,29 @@ +includes: + - ../vendor/szepeviktor/phpstan-wordpress/extension.neon + +services: + - + class: WPSyntex\Polylang\PHPStan\OptionTypes + arguments: + proEnabled: true + - + class: WPSyntex\Polylang\PHPStan\OptionsGetDynamicMethodReturnTypeExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\OptionsOffsetExpressionTypeResolverExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes + tags: + - phpstan.broker.expressionTypeResolverExtension + +parameters: + tmpDir: %currentWorkingDirectory%/tests/tmp/phpstan-options-pro + bootstrapFiles: + - ../bootstrap.php + stubFiles: + - ../vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + scanDirectories: + - ../../polylang/src/Options diff --git a/tests/phpstan.neon b/tests/phpstan.neon index 3a45a48..776c9b2 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -18,10 +18,20 @@ services: class: WPSyntex\Polylang\PHPStan\ModelLanguagesGetListDynamicMethodReturnTypeExtension tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\OptionTypes - class: WPSyntex\Polylang\PHPStan\OptionsGetDynamicMethodReturnTypeExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes tags: - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: WPSyntex\Polylang\PHPStan\OptionsOffsetExpressionTypeResolverExtension + arguments: + optionTypes: @WPSyntex\Polylang\PHPStan\OptionTypes + tags: + - phpstan.broker.expressionTypeResolverExtension - class: WPSyntex\Polylang\PHPStan\Rules\DeprecatedPllSwitcherRule tags: @@ -38,3 +48,4 @@ parameters: - POLYLANG_VERSION - PLL_ADMIN - PLL_SETTINGS + - POLYLANG_PRO_PHPSTAN