From 8178cc85ebb5b7dc19ccd8c64b0bfc9ef88c98ea Mon Sep 17 00:00:00 2001 From: Hug0-Drelon Date: Fri, 9 Oct 2026 16:45:15 +0200 Subject: [PATCH 1/5] feat(options): infer bracket access types for Options offsets Unify option key typing in OptionTypes and add an expression resolver so $options['key'] matches $options->get('key') when the offset is a constant. --- extension.neon | 11 ++ src/OptionTypes.php | 132 ++++++++++++++++++ ...onsGetDynamicMethodReturnTypeExtension.php | 127 +---------------- ...sOffsetExpressionTypeResolverExtension.php | 49 +++++++ tests/DynamicReturnTypeExtensionTest.php | 1 - tests/OptionsProReturnTypeExtensionTest.php | 28 ++++ tests/OptionsReturnTypeExtensionTest.php | 29 ++++ tests/data/options_offset.php | 47 +++++++ tests/data/options_pro.php | 14 ++ tests/options-phpstan.neon | 26 ++++ tests/options-pro-phpstan.neon | 28 ++++ tests/phpstan.neon | 11 ++ 12 files changed, 382 insertions(+), 121 deletions(-) create mode 100644 src/OptionTypes.php create mode 100644 src/OptionsOffsetExpressionTypeResolverExtension.php create mode 100644 tests/OptionsProReturnTypeExtensionTest.php create mode 100644 tests/OptionsReturnTypeExtensionTest.php create mode 100644 tests/data/options_offset.php create mode 100644 tests/data/options_pro.php create mode 100644 tests/options-phpstan.neon create mode 100644 tests/options-pro-phpstan.neon 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..c21569f --- /dev/null +++ b/src/OptionTypes.php @@ -0,0 +1,132 @@ +proEnabled = $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->proEnabled ) { + 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 ArrayType( + $this->getNonFalsyStringType(), + new ArrayType( + $this->getNonFalsyStringType(), + new StringType() + ) + ); + + 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..501db29 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; } @@ -49,118 +45,9 @@ public function getTypeFromMethodCall( MethodReflection $methodReflection, Metho $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() ); - } + $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..3067b05 --- /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; + } + + $dimType = $scope->getType( $expr->dim ); + if ( count( $dimType->getConstantStrings() ) === 0 ) { + return null; + } + + $returnTypes = []; + + foreach ( $dimType->getConstantStrings() 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/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..aef15ef --- /dev/null +++ b/tests/OptionsReturnTypeExtensionTest.php @@ -0,0 +1,29 @@ + + */ + public function dataFileAsserts(): iterable { + yield from $this->gatherAssertTypes( __DIR__ . '/data/options_get.php' ); + yield from $this->gatherAssertTypes( __DIR__ . '/data/options_offset.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/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..c9209f4 --- /dev/null +++ b/tests/data/options_pro.php @@ -0,0 +1,14 @@ +', $options['media']); +assertType('bool', $options['machine_translation_enabled']); +assertType('array>', $options['machine_translation_services']); diff --git a/tests/options-phpstan.neon b/tests/options-phpstan.neon new file mode 100644 index 0000000..1991704 --- /dev/null +++ b/tests/options-phpstan.neon @@ -0,0 +1,26 @@ +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: + 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..033e9a8 --- /dev/null +++ b/tests/options-pro-phpstan.neon @@ -0,0 +1,28 @@ +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: + 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 From 5475ccbb12f2690400e8ac724a0b28dc969d8060 Mon Sep 17 00:00:00 2001 From: Hug0-Drelon Date: Fri, 9 Oct 2026 16:52:02 +0200 Subject: [PATCH 2/5] fix(options): resolve Pro option types when bootstrap loads late --- src/OptionTypes.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/OptionTypes.php b/src/OptionTypes.php index c21569f..3a0e8b8 100644 --- a/src/OptionTypes.php +++ b/src/OptionTypes.php @@ -24,7 +24,11 @@ class OptionTypes { private bool $proEnabled; public function __construct( bool $proEnabled = false ) { - $this->proEnabled = $proEnabled || ( defined( 'POLYLANG_PRO_PHPSTAN' ) && POLYLANG_PRO_PHPSTAN ); + $this->proEnabled = $proEnabled; + } + + private function isProEnabled(): bool { + return $this->proEnabled || ( defined( 'POLYLANG_PRO_PHPSTAN' ) && POLYLANG_PRO_PHPSTAN ); } /** @@ -90,7 +94,7 @@ public function getTypeForKeyOrNull( string $key ): Type { } private function getProTypeForKey( string $key ): ?Type { - if ( ! $this->proEnabled ) { + if ( ! $this->isProEnabled() ) { return null; } From 45162ee984c475e3a571ee02d51f7f537b2b9d55 Mon Sep 17 00:00:00 2001 From: Hug0-Drelon Date: Fri, 9 Oct 2026 17:25:54 +0200 Subject: [PATCH 3/5] test(options): expand Pro coverage and document Options typing Add registry sync tests, POLYLANG_PRO_PHPSTAN constant coverage, edge-case assertions, README guidance, and fix the polylang scan path for local dev. --- .gitignore | 1 + README.md | 27 ++++++ src/OptionTypes.php | 9 +- tests/OptionTypesRegistrySyncTest.php | 107 +++++++++++++++++++++++ tests/OptionsReturnTypeExtensionTest.php | 1 + tests/bootstrap-pro-phpstan.php | 7 ++ tests/data/options_edge_cases.php | 21 +++++ tests/data/options_pro.php | 7 ++ tests/options-phpstan.neon | 3 +- tests/options-pro-phpstan.neon | 3 +- 10 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 tests/OptionTypesRegistrySyncTest.php create mode 100644 tests/bootstrap-pro-phpstan.php create mode 100644 tests/data/options_edge_cases.php 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/src/OptionTypes.php b/src/OptionTypes.php index 3a0e8b8..e1dcd1c 100644 --- a/src/OptionTypes.php +++ b/src/OptionTypes.php @@ -2,7 +2,14 @@ /** * PHPStan types for Polylang Options keys. * - * Mirrors polylang/src/Options/Registry.php (and Pro registry when Pro mode is enabled). + * Keep in sync with: + * - polylang/src/Options/Registry::OPTIONS (free keys) + * - polylang-pro/src/Options/Registry::OPTIONS (pro keys, when POLYLANG_PRO_PHPSTAN is true) + * + * Value shapes follow each Business option class (primitives and @phpstan-type aliases). + * Unknown keys resolve to null (not mixed). Legacy `language_taxonomies` is typed but not in Registry. + * + * @see \WPSyntex\Polylang\PHPStan\Tests\OptionTypesRegistrySyncTest */ declare(strict_types=1); 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/OptionsReturnTypeExtensionTest.php b/tests/OptionsReturnTypeExtensionTest.php index aef15ef..9ea9bad 100644 --- a/tests/OptionsReturnTypeExtensionTest.php +++ b/tests/OptionsReturnTypeExtensionTest.php @@ -11,6 +11,7 @@ class OptionsReturnTypeExtensionTest extends \PHPStan\Testing\TypeInferenceTestC 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' ); } /** 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_pro.php b/tests/data/options_pro.php index c9209f4..c38a46f 100644 --- a/tests/data/options_pro.php +++ b/tests/data/options_pro.php @@ -11,4 +11,11 @@ assertType('array', $options['media']); assertType('bool', $options['machine_translation_enabled']); +assertType('non-falsy-string', $options['machine_translation_service']); assertType('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('array>', $options->get('machine_translation_services')); diff --git a/tests/options-phpstan.neon b/tests/options-phpstan.neon index 1991704..cb7d7ca 100644 --- a/tests/options-phpstan.neon +++ b/tests/options-phpstan.neon @@ -18,9 +18,10 @@ services: - 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 + - ../../polylang/src/Options diff --git a/tests/options-pro-phpstan.neon b/tests/options-pro-phpstan.neon index 033e9a8..37762bf 100644 --- a/tests/options-pro-phpstan.neon +++ b/tests/options-pro-phpstan.neon @@ -20,9 +20,10 @@ services: - 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 + - ../../polylang/src/Options From ba0b3fbf1e1d3e1191b1463b6789d105f6874433 Mon Sep 17 00:00:00 2001 From: Hug0-Drelon Date: Fri, 16 Oct 2026 16:04:36 +0200 Subject: [PATCH 4/5] fix(options): restore NonEmptyArrayType for machine_translation_services The OptionTypes refactor dropped the non-empty-array intersection that existed before; restore it so the inferred type matches the real shape. --- src/OptionTypes.php | 18 ++++++++++++------ tests/data/options_pro.php | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/OptionTypes.php b/src/OptionTypes.php index e1dcd1c..78b5ef9 100644 --- a/src/OptionTypes.php +++ b/src/OptionTypes.php @@ -17,6 +17,7 @@ namespace WPSyntex\Polylang\PHPStan; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\IntegerRangeType; @@ -119,12 +120,17 @@ private function getProTypeForKey( string $key ): ?Type { return $this->getNonFalsyStringType(); case 'machine_translation_services': - return new ArrayType( - $this->getNonFalsyStringType(), - new ArrayType( - $this->getNonFalsyStringType(), - new StringType() - ) + return new IntersectionType( + [ + new ArrayType( + $this->getNonFalsyStringType(), + new ArrayType( + $this->getNonFalsyStringType(), + new StringType() + ) + ), + new NonEmptyArrayType(), + ] ); default: diff --git a/tests/data/options_pro.php b/tests/data/options_pro.php index c38a46f..e572b61 100644 --- a/tests/data/options_pro.php +++ b/tests/data/options_pro.php @@ -12,10 +12,10 @@ assertType('array', $options['media']); assertType('bool', $options['machine_translation_enabled']); assertType('non-falsy-string', $options['machine_translation_service']); -assertType('array>', $options['machine_translation_services']); +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('array>', $options->get('machine_translation_services')); +assertType('non-empty-array>', $options->get('machine_translation_services')); From e024b766a28425e28643fd7679ee2ea3355d41d1 Mon Sep 17 00:00:00 2001 From: Hug0-Drelon Date: Fri, 16 Oct 2026 16:04:38 +0200 Subject: [PATCH 5/5] refactor(options): call getConstantStrings() once per resolution Avoid resolving constant string types twice when inferring Options offset and method return types. --- src/OptionsGetDynamicMethodReturnTypeExtension.php | 6 +++--- src/OptionsOffsetExpressionTypeResolverExtension.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/OptionsGetDynamicMethodReturnTypeExtension.php b/src/OptionsGetDynamicMethodReturnTypeExtension.php index 501db29..7b99d6c 100644 --- a/src/OptionsGetDynamicMethodReturnTypeExtension.php +++ b/src/OptionsGetDynamicMethodReturnTypeExtension.php @@ -34,17 +34,17 @@ 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 ) { + foreach ( $constantStrings as $constantString ) { $returnType[] = $this->optionTypes->getTypeForKeyOrNull( $constantString->getValue() ); } diff --git a/src/OptionsOffsetExpressionTypeResolverExtension.php b/src/OptionsOffsetExpressionTypeResolverExtension.php index 3067b05..289efac 100644 --- a/src/OptionsOffsetExpressionTypeResolverExtension.php +++ b/src/OptionsOffsetExpressionTypeResolverExtension.php @@ -33,14 +33,14 @@ public function getType( Expr $expr, Scope $scope ): ?Type { return null; } - $dimType = $scope->getType( $expr->dim ); - if ( count( $dimType->getConstantStrings() ) === 0 ) { + $constantStrings = $scope->getType( $expr->dim )->getConstantStrings(); + if ( count( $constantStrings ) === 0 ) { return null; } $returnTypes = []; - foreach ( $dimType->getConstantStrings() as $constantString ) { + foreach ( $constantStrings as $constantString ) { $returnTypes[] = $this->optionTypes->getTypeForKeyOrNull( $constantString->getValue() ); }