diff --git a/src/DependencyInjection/BleedingEdgeToggle.php b/src/DependencyInjection/BleedingEdgeToggle.php index 29170f7fe9a..7868c34900e 100644 --- a/src/DependencyInjection/BleedingEdgeToggle.php +++ b/src/DependencyInjection/BleedingEdgeToggle.php @@ -2,6 +2,9 @@ namespace PHPStan\DependencyInjection; +use Generator; +use PHPStan\ShouldNotHappenException; + final class BleedingEdgeToggle { @@ -17,4 +20,32 @@ public static function setBleedingEdge(bool $bleedingEdge): void self::$bleedingEdge = $bleedingEdge; } + /** + * Runs the callback with the toggle set to $bleedingEdge and restores the previous + * value before returning, so the global toggle is never observable as mutated outside + * this call. When used from a data provider, the data sets must be produced by the + * callback so that the contained objects are constructed while the toggle is set - + * holding the toggle across a `yield` would otherwise leak it into unrelated tests. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public static function withBleedingEdge(bool $bleedingEdge, callable $callback) + { + $backup = self::$bleedingEdge; + self::$bleedingEdge = $bleedingEdge; + try { + $result = $callback(); + + if ($result instanceof Generator) { + throw new ShouldNotHappenException('callback is not allowed to yield, to prevent leaking the toggle into unrelated tests.'); + } + + return $result; + } finally { + self::$bleedingEdge = $backup; + } + } + } diff --git a/tests/PHPStan/DependencyInjection/BleedingEdgeToggleTest.php b/tests/PHPStan/DependencyInjection/BleedingEdgeToggleTest.php new file mode 100644 index 00000000000..81f9485fe46 --- /dev/null +++ b/tests/PHPStan/DependencyInjection/BleedingEdgeToggleTest.php @@ -0,0 +1,107 @@ +backup = BleedingEdgeToggle::isBleedingEdge(); + } + + #[Override] + protected function tearDown(): void + { + BleedingEdgeToggle::setBleedingEdge($this->backup); + } + + public function testTogglesDuringCallbackAndRestoresAfterwards(): void + { + BleedingEdgeToggle::setBleedingEdge(false); + + $observed = BleedingEdgeToggle::withBleedingEdge(true, static fn (): bool => BleedingEdgeToggle::isBleedingEdge()); + + $this->assertTrue($observed); + $this->assertFalse(BleedingEdgeToggle::isBleedingEdge()); + } + + public function testRestoresPreviousValueWhenAlreadyEnabled(): void + { + BleedingEdgeToggle::setBleedingEdge(true); + + $observed = BleedingEdgeToggle::withBleedingEdge(false, static fn (): bool => BleedingEdgeToggle::isBleedingEdge()); + + $this->assertFalse($observed); + $this->assertTrue(BleedingEdgeToggle::isBleedingEdge()); + } + + public function testReturnsCallbackResult(): void + { + $result = BleedingEdgeToggle::withBleedingEdge(true, fn (): string => $this->makeValue()); + + $this->assertSame('value', $result); + } + + public function testRestoresPreviousValueWhenCallbackThrows(): void + { + BleedingEdgeToggle::setBleedingEdge(false); + + $thrown = false; + try { + BleedingEdgeToggle::withBleedingEdge(true, static function (): void { + throw new RuntimeException('boom'); + }); + } catch (Throwable $e) { + $thrown = $e instanceof RuntimeException && $e->getMessage() === 'boom'; + } + + $this->assertTrue($thrown); + $this->assertFalse(BleedingEdgeToggle::isBleedingEdge()); + } + + public function testThrowsAndRestoresWhenCallbackYields(): void + { + BleedingEdgeToggle::setBleedingEdge(false); + + $thrown = false; + try { + BleedingEdgeToggle::withBleedingEdge(true, static function () { + yield 1; + }); + } catch (ShouldNotHappenException) { + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertFalse(BleedingEdgeToggle::isBleedingEdge()); + } + + public function testProducesDataSetsWhileToggleIsSet(): void + { + BleedingEdgeToggle::setBleedingEdge(false); + + $dataSets = BleedingEdgeToggle::withBleedingEdge(true, static fn (): array => [ + BleedingEdgeToggle::isBleedingEdge(), + BleedingEdgeToggle::isBleedingEdge(), + ]); + + $this->assertSame([true, true], $dataSets); + $this->assertFalse(BleedingEdgeToggle::isBleedingEdge()); + } + + private function makeValue(): string + { + return 'value'; + } + +} diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php index bd97c440358..277ca18ef40 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php @@ -10,6 +10,7 @@ use PHPStan\Type\IntegerType; use PHPStan\Type\NullType; use PHPStan\Type\StringType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; use function sprintf; @@ -325,16 +326,9 @@ public function testGetArrayEmptyWithUnknownSealednessStaysConstantArrayType(): public function testGetArraySealedEmptyStaysConstantArrayType(): void { - $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); - BleedingEdgeToggle::setBleedingEdge(true); - try { - $builder = ConstantArrayTypeBuilder::createEmpty(); - $array = $builder->getArray(); - $this->assertInstanceOf(ConstantArrayType::class, $array); - $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); - } finally { - BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); - } + $array = BleedingEdgeToggle::withBleedingEdge(true, static fn (): Type => ConstantArrayTypeBuilder::createEmpty()->getArray()); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); } public function testGetArrayEmptyWithRealUnsealedCollapsesToArrayType(): void diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index c35d916315e..996beeeed75 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -3226,18 +3226,18 @@ public function testUnion( string $expectedTypeDescription, ): void { - $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); - foreach ($types as $i => $type) { - BleedingEdgeToggle::setBleedingEdge(true); - if (!is_string($type)) { - continue; - } + $types = BleedingEdgeToggle::withBleedingEdge(true, static function () use ($types, $typeStringResolver): array { + foreach ($types as $i => $type) { + if (!is_string($type)) { + continue; + } - $types[$i] = $typeStringResolver->resolve($type, null); - } + $types[$i] = $typeStringResolver->resolve($type, null); + } - BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + return $types; + }); $actualType = TypeCombinator::union(...$types); $this->assertSame( $expectedTypeDescription, @@ -3276,18 +3276,18 @@ public function testUnionInversed( ): void { $types = array_reverse($types); - $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); - foreach ($types as $i => $type) { - BleedingEdgeToggle::setBleedingEdge(true); - if (!is_string($type)) { - continue; - } + $types = BleedingEdgeToggle::withBleedingEdge(true, static function () use ($types, $typeStringResolver): array { + foreach ($types as $i => $type) { + if (!is_string($type)) { + continue; + } - $types[$i] = $typeStringResolver->resolve($type, null); - } + $types[$i] = $typeStringResolver->resolve($type, null); + } - BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + return $types; + }); $actualType = TypeCombinator::union(...$types); $this->assertSame( @@ -5640,18 +5640,18 @@ public function testIntersect( string $expectedTypeDescription, ): void { - $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); - foreach ($types as $i => $type) { - BleedingEdgeToggle::setBleedingEdge(true); - if (!is_string($type)) { - continue; - } + $types = BleedingEdgeToggle::withBleedingEdge(true, static function () use ($types, $typeStringResolver): array { + foreach ($types as $i => $type) { + if (!is_string($type)) { + continue; + } - $types[$i] = $typeStringResolver->resolve($type, null); - } + $types[$i] = $typeStringResolver->resolve($type, null); + } - BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + return $types; + }); $actualType = TypeCombinator::intersect(...$types); $this->assertSame( @@ -5676,18 +5676,18 @@ public function testIntersectInversed( string $expectedTypeDescription, ): void { - $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); - foreach ($types as $i => $type) { - BleedingEdgeToggle::setBleedingEdge(true); - if (!is_string($type)) { - continue; - } + $types = BleedingEdgeToggle::withBleedingEdge(true, static function () use ($types, $typeStringResolver): array { + foreach ($types as $i => $type) { + if (!is_string($type)) { + continue; + } - $types[$i] = $typeStringResolver->resolve($type, null); - } + $types[$i] = $typeStringResolver->resolve($type, null); + } - BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + return $types; + }); $actualType = TypeCombinator::intersect(...array_reverse($types)); $this->assertSame(