Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Directories
/.vscode
/vendor
/tests/tmp

# Files
.DS_Store
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<non-falsy-string, mixed>`. 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<non-falsy-string, string>
```

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<non-falsy-string, non-falsy-string>` 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)`).
11 changes: 11 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -36,3 +46,4 @@ parameters:
- POLYLANG_VERSION
- PLL_ADMIN
- PLL_SETTINGS
- POLYLANG_PRO_PHPSTAN
149 changes: 149 additions & 0 deletions src/OptionTypes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php
/**
* PHPStan types for Polylang Options keys.
*
* 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);

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;
use PHPStan\Type\IntegerType;
use PHPStan\Type\IntersectionType;
use PHPStan\Type\NullType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;

class OptionTypes {

private bool $proEnabled;

public function __construct( bool $proEnabled = false ) {
$this->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(),
]
);
}
}
133 changes: 10 additions & 123 deletions src/OptionsGetDynamicMethodReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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();
}
}
}
Loading
Loading