Skip to content

Commit da3218d

Browse files
committed
Revamp extensions
1 parent fa41230 commit da3218d

File tree

7 files changed

+321
-51
lines changed

7 files changed

+321
-51
lines changed

extension.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ services:
77
class: WP_CLI\Tests\PHPStan\GetFlagValueFunctionDynamicReturnTypeExtension
88
tags:
99
- phpstan.broker.dynamicFunctionReturnTypeExtension
10+
-
11+
class: WP_CLI\Tests\PHPStan\WPCliRuncommandDynamicReturnTypeExtension
12+
tags:
13+
- phpstan.broker.dynamicStaticMethodReturnTypeExtension

phpcs.xml.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
<exclude-pattern>tests/data/*</exclude-pattern>
7272
</rule>
7373

74+
<rule ref="Squiz.PHP.CommentedOutCode.Found">
75+
<exclude-pattern>src/PHPStan/*</exclude-pattern>
76+
</rule>
77+
7478
<!-- This is a procedural stand-alone file that is never loaded in a WordPress context,
7579
so this file does not have to comply with WP naming conventions. -->
7680
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">

src/PHPStan/GetFlagValueFunctionDynamicReturnTypeExtension.php

Lines changed: 52 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
use PHPStan\Analyser\Scope;
1313
use PHPStan\Reflection\FunctionReflection;
1414
use PHPStan\Type\Constant\ConstantStringType;
15-
use PHPStan\Type\Constant\ConstantArrayType;
1615
use PHPStan\Type\Type;
16+
use PHPStan\Type\MixedType;
17+
use PHPStan\Type\NullType;
1718
use PHPStan\Type\TypeCombinator;
1819

1920
use function count;
@@ -31,82 +32,83 @@ public function getTypeFromFunctionCall(
3132
): Type {
3233
$args = $functionCall->getArgs();
3334

34-
// Ensure we have at least two arguments: $assoc_args and $flag
3535
if ( count( $args ) < 2 ) {
36-
// Not enough arguments, fall back to the function's declared return type or mixed
36+
// Not enough arguments, fall back to the function's declared return type.
3737
return $functionReflection->getVariants()[0]->getReturnType();
3838
}
3939

4040
$assocArgsType = $scope->getType( $args[0]->value );
4141
$flagArgType = $scope->getType( $args[1]->value );
4242

43-
// Determine the default type
44-
$defaultType = isset( $args[2] ) ? $scope->getType( $args[2]->value ) : new \PHPStan\Type\NullType();
45-
46-
// We can only be precise if $flag is a constant string
47-
if ( ! $flagArgType->isConstantValue()->yes() || ( ! $flagArgType->toInteger() instanceof ConstantIntegerType && ! $flagArgType->toString() instanceof ConstantStringType ) ) {
48-
// If $flag is not a constant string, we cannot know which key to check.
49-
// The return type will be a union of the array's possible value types and the default type.
50-
if ( $assocArgsType instanceof ConstantArrayType ) {
51-
$valueTypes = [];
52-
foreach ( $assocArgsType->getValueTypes() as $valueType ) {
53-
$valueTypes[] = $valueType;
54-
}
55-
if ( count( $valueTypes ) > 0 ) {
56-
return TypeCombinator::union( ...$valueTypes );
57-
}
58-
return $defaultType; // Array is empty or has no predictable value types
59-
} elseif ( $assocArgsType instanceof \PHPStan\Type\ArrayType ) {
60-
return TypeCombinator::union( $assocArgsType->getItemType(), $defaultType );
61-
}
62-
// Fallback if $assocArgsType isn't a well-defined array type
63-
return new MixedType();
43+
// 2. Determine the default type
44+
$defaultType = isset( $args[2] ) ? $scope->getType( $args[2]->value ) : new NullType();
45+
46+
$flagConstantStrings = $flagArgType->getConstantStrings();
47+
48+
if ( count( $flagConstantStrings ) !== 1 ) {
49+
// Flag name is dynamic or not a string.
50+
// Return type is a union of all possible values in $assoc_args + default type.
51+
return $this->getDynamicFlagFallbackType( $assocArgsType, $defaultType );
6452
}
6553

66-
$flagValue = $flagArgType->getValue();
54+
// 4. Flag is a single constant string.
55+
$flagValue = $flagConstantStrings[0]->getValue();
6756

68-
// If $assoc_args is a constant array, we can check if the key exists
69-
if ( $assocArgsType->isConstantValue()->yes() && $assocArgsType->toArray() instanceof ConstantArrayType ) {
70-
$keyTypes = $assocArgsType->getKeyTypes();
71-
$valueTypes = $assocArgsType->getValueTypes();
72-
$resolvedValueType = null;
57+
// 4.a. If $assoc_args is a single ConstantArray:
58+
$assocConstantArrays = $assocArgsType->getConstantArrays();
59+
if ( count( $assocConstantArrays ) === 1 ) {
60+
$assocArgsConstantArray = $assocConstantArrays[0];
61+
$keyTypes = $assocArgsConstantArray->getKeyTypes();
62+
$valueTypes = $assocArgsConstantArray->getValueTypes();
63+
$resolvedValueType = null;
7364

7465
foreach ( $keyTypes as $index => $keyType ) {
75-
if ( $keyType->isConstantValue()->yes() && $keyType->toString() instanceof ConstantStringType && $keyType->getValue() === $flagValue ) {
66+
$keyConstantStrings = $keyType->getConstantStrings();
67+
if ( count( $keyConstantStrings ) === 1 && $keyConstantStrings[0]->getValue() === $flagValue ) {
7668
$resolvedValueType = $valueTypes[ $index ];
7769
break;
7870
}
7971
}
8072

8173
if ( null !== $resolvedValueType ) {
82-
// Key definitely exists, return its type
74+
// Key definitely exists and has a resolved type.
8375
return $resolvedValueType;
8476
} else {
85-
// Key definitely does not exist, return default type
77+
// Key definitely does not exist in this constant array.
8678
return $defaultType;
8779
}
8880
}
8981

90-
// If $assocArgsType is a general ArrayType, we can't be sure if the specific flag exists.
91-
// The function's logic is: isset( $assoc_args[ $flag ] ) ? $assoc_args[ $flag ] : $default;
92-
// So, it's a union of the potential value type from the array and the default type.
93-
if ( $assocArgsType->isArray()->yes() ) {
94-
// We don't know IF the key $flagValue exists.
95-
// PHPStan's ArrayType has an itemType which represents the type of values in the array.
96-
// This is the best we can do for a generic array.
97-
return TypeCombinator::union( $assocArgsType->getItemType(), $defaultType );
82+
// 4.b. $assoc_args is not a single ConstantArray (but $flagValue is known):
83+
// Use getOffsetValueType for other array-like types.
84+
$valueForKeyType = $assocArgsType->getOffsetValueType( new ConstantStringType( $flagValue ) );
85+
86+
// The key might exist, or its presence is unknown.
87+
// The function returns $assoc_args[$flag] if set, otherwise $default.
88+
return TypeCombinator::union( $valueForKeyType, $defaultType );
89+
}
90+
91+
/**
92+
* Handles the case where the flag name is not a single known constant string.
93+
* The return type is a union of all possible values in $assocArgsType and $defaultType.
94+
*/
95+
private function getDynamicFlagFallbackType( Type $assocArgsType, Type $defaultType ): Type {
96+
$possibleValueTypes = [];
97+
98+
$assocConstantArrays = $assocArgsType->getConstantArrays();
99+
if ( count( $assocConstantArrays ) === 1 ) { // It's one specific constant array
100+
$constantArray = $assocConstantArrays[0];
101+
if ( count( $constantArray->getValueTypes() ) > 0 ) {
102+
$possibleValueTypes = $constantArray->getValueTypes();
103+
}
104+
} else {
105+
$possibleValueTypes[] = new MixedType();
98106
}
99107

100-
// Fallback for other types of $assocArgsType or if we can't determine.
101-
// This should ideally be the union of what the array could contain for that key and the default.
102-
// For simplicity, if not a ConstantArrayType or ArrayType, return mixed or a broad union.
103-
// In a real-world scenario with more complex types, you might query $assocArgsType->getOffsetValueType(new ConstantStringType($flagValue))
104-
// and then union with $defaultType.
105-
$offsetValueType = $assocArgsType->getOffsetValueType( new ConstantStringType( $flagValue ) );
106-
if ( ! $offsetValueType instanceof MixedType || $offsetValueType->isExplicitMixed() ) {
107-
return TypeCombinator::union( $offsetValueType, $defaultType );
108+
if ( empty( $possibleValueTypes ) ) {
109+
return $defaultType;
108110
}
109111

110-
return new MixedType(); // Default fallback
112+
return TypeCombinator::union( $defaultType, ...$possibleValueTypes );
111113
}
112114
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WP_CLI\Tests\PHPStan;
6+
7+
use PhpParser\Node\Expr\StaticCall;
8+
use PhpParser\Node\Arg;
9+
use PHPStan\Analyser\Scope;
10+
use PHPStan\Reflection\MethodReflection;
11+
use PHPStan\Type\Type;
12+
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
13+
use PHPStan\Type\NullType;
14+
use PHPStan\Type\StringType;
15+
use PHPStan\Type\IntegerType;
16+
use PHPStan\Type\MixedType;
17+
use PHPStan\Type\ArrayType;
18+
use PHPStan\Type\ObjectWithoutClassType;
19+
use PHPStan\Type\Constant\ConstantStringType;
20+
use PHPStan\Type\Constant\ConstantBooleanType;
21+
use PHPStan\Type\TypeCombinator;
22+
use PHPStan\Type\ObjectShapeType;
23+
use PHPStan\Type\NeverType;
24+
25+
class WPCliRuncommandDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension {
26+
27+
public function getClass(): string {
28+
return 'WP_CLI';
29+
}
30+
31+
public function isStaticMethodSupported( MethodReflection $methodReflection ): bool {
32+
return $methodReflection->getName() === 'runcommand';
33+
}
34+
35+
public function getTypeFromStaticMethodCall(
36+
MethodReflection $methodReflection,
37+
StaticCall $methodCall,
38+
Scope $scope
39+
): Type {
40+
$args = $methodCall->getArgs();
41+
42+
/** @var ConstantBooleanType|ConstantStringType $returnOption */
43+
$returnOption = new ConstantBooleanType( true );
44+
/** @var ConstantBooleanType|ConstantStringType $parseOption */
45+
$parseOption = new ConstantBooleanType( false );
46+
/** @var ConstantBooleanType $exitOnErrorOption */
47+
$exitOnErrorOption = new ConstantBooleanType( true );
48+
49+
$optionsAreStaticallyKnown = true;
50+
51+
if ( isset( $args[1] ) && $args[1] instanceof Arg ) {
52+
$optionsNode = $args[1]->value;
53+
$optionsType = $scope->getType( $optionsNode );
54+
55+
if ( $optionsType->isConstantArray()->yes() ) {
56+
$constantArrayTypes = $optionsType->getConstantArrays();
57+
if ( count( $constantArrayTypes ) === 1 ) {
58+
$constantArrayType = $constantArrayTypes[0];
59+
$keyTypes = $constantArrayType->getKeyTypes();
60+
$valueTypes = $constantArrayType->getValueTypes();
61+
62+
foreach ( $keyTypes as $i => $keyType ) {
63+
$keyConstantStrings = $keyType->getConstantStrings();
64+
if ( count( $keyConstantStrings ) !== 1 ) {
65+
$optionsAreStaticallyKnown = false;
66+
break;
67+
}
68+
$keyName = $keyConstantStrings[0]->getValue();
69+
$currentOptionValueType = $valueTypes[ $i ];
70+
71+
switch ( $keyName ) {
72+
case 'return':
73+
$valueConstantStrings = $currentOptionValueType->getConstantStrings();
74+
if ( count( $valueConstantStrings ) === 1 && $currentOptionValueType->isScalar()->yes() ) {
75+
$returnOption = $valueConstantStrings[0];
76+
} elseif ( $currentOptionValueType->isTrue()->yes() ) {
77+
$returnOption = new ConstantBooleanType( true );
78+
} elseif ( $currentOptionValueType->isFalse()->yes() ) {
79+
$returnOption = new ConstantBooleanType( false );
80+
} else {
81+
$optionsAreStaticallyKnown = false;
82+
}
83+
break;
84+
case 'parse':
85+
$valueConstantStrings = $currentOptionValueType->getConstantStrings();
86+
$isExactlyJsonString = ( count( $valueConstantStrings ) === 1 && $valueConstantStrings[0]->getValue() === 'json' && $currentOptionValueType->isScalar()->yes() );
87+
88+
if ( $isExactlyJsonString ) {
89+
$parseOption = $valueConstantStrings[0];
90+
} elseif ( $$currentOptionValueType->isFalse()->yes() ) {
91+
$parseOption = new ConstantBooleanType( false );
92+
} else {
93+
// Not a single, clear constant we handle for a "known" path
94+
$parseOption = new ConstantBooleanType( false ); // Default effect
95+
$optionsAreStaticallyKnown = false;
96+
}
97+
break;
98+
case 'exit_error':
99+
if ( $currentOptionValueType->isTrue()->yes() ) {
100+
$exitOnErrorOption = new ConstantBooleanType( true );
101+
} elseif ( $currentOptionValueType->isFalse()->yes() ) {
102+
$exitOnErrorOption = new ConstantBooleanType( false );
103+
} else {
104+
$optionsAreStaticallyKnown = false;
105+
}
106+
break;
107+
}
108+
if ( ! $optionsAreStaticallyKnown ) {
109+
break;
110+
}
111+
}
112+
} else {
113+
$optionsAreStaticallyKnown = false;
114+
}
115+
} else {
116+
$optionsAreStaticallyKnown = false;
117+
}
118+
}
119+
120+
if ( ! $optionsAreStaticallyKnown ) {
121+
return TypeCombinator::union( $this->getFallbackUnionTypeWithoutNever(), new NeverType() );
122+
}
123+
124+
$normalReturnType = $this->determineNormalReturnType( $returnOption, $parseOption );
125+
126+
if ( $exitOnErrorOption->getValue() === true ) {
127+
if ( $normalReturnType instanceof NeverType ) {
128+
return $normalReturnType;
129+
}
130+
return TypeCombinator::union( $normalReturnType, new NeverType() );
131+
}
132+
133+
return $normalReturnType;
134+
}
135+
136+
/**
137+
* @param ConstantBooleanType|ConstantStringType $returnOptionValue
138+
* @param ConstantBooleanType|ConstantStringType $parseOptionValue
139+
*/
140+
private function determineNormalReturnType( Type $returnOptionValue, Type $parseOptionValue ): Type {
141+
$returnConstantStrings = $returnOptionValue->getConstantStrings();
142+
$return_val = count( $returnConstantStrings ) === 1 ? $returnConstantStrings[0]->getValue() : null;
143+
144+
$parseConstantStrings = $parseOptionValue->getConstantStrings();
145+
$parseIsJson = count( $parseConstantStrings ) === 1 && $parseConstantStrings[0]->getValue() === 'json';
146+
147+
if ( 'all' === $return_val ) {
148+
return $this->createAllObjectType();
149+
}
150+
if ( 'return_code' === $return_val ) {
151+
return new IntegerType();
152+
}
153+
if ( 'stderr' === $return_val ) {
154+
return new StringType();
155+
}
156+
if ( $returnOptionValue->isTrue()->yes() || 'stdout' === $return_val ) {
157+
if ( $parseIsJson ) {
158+
return TypeCombinator::union(
159+
new ArrayType( new MixedType(), new MixedType() ),
160+
new NullType()
161+
);
162+
}
163+
return new StringType();
164+
}
165+
if ( $returnOptionValue->isFalse()->yes() ) {
166+
return new NullType();
167+
}
168+
169+
return new MixedType( true );
170+
}
171+
172+
private function createAllObjectType(): Type {
173+
$propertyTypes = [
174+
'stdout' => new StringType(),
175+
'stderr' => new StringType(),
176+
'return_code' => new IntegerType(),
177+
];
178+
$optionalProperties = [];
179+
return new ObjectShapeType( $propertyTypes, $optionalProperties );
180+
}
181+
182+
private function getFallbackUnionTypeWithoutNever(): Type {
183+
return TypeCombinator::union(
184+
new StringType(),
185+
new IntegerType(),
186+
$this->createAllObjectType(),
187+
new ArrayType( new MixedType(), new MixedType() ),
188+
new ObjectWithoutClassType(),
189+
new NullType()
190+
);
191+
}
192+
}

tests/data/get_flag_value.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,4 @@
5959
assertType( 'true', $value );
6060

6161
$value = get_flag_value( $assoc_args, $key2, 123 );
62-
assertType( "'bar'|true", $value );
62+
assertType( "123|'bar'|true", $value );

0 commit comments

Comments
 (0)