|
36 | 36 | use PHPStan\Type\Type; |
37 | 37 | use PHPStan\Type\UnionType; |
38 | 38 | use PHPStan\Type\VerbosityLevel; |
| 39 | +use function array_keys; |
| 40 | +use function array_map; |
39 | 41 | use function array_merge; |
40 | 42 | use function is_bool; |
41 | 43 | use function is_callable; |
@@ -255,6 +257,218 @@ public function testUnusualAutoIndexStateIsNotRetainedInAConflictingShape(): voi |
255 | 257 | } |
256 | 258 | } |
257 | 259 |
|
| 260 | + public function testMultipleTrailingShapesRetainKnownOffsetsAtBuilderBoundary(): void |
| 261 | + { |
| 262 | + $firstShapeKeyTypes = []; |
| 263 | + $firstShapeValueTypes = []; |
| 264 | + |
| 265 | + for ($i = 0; $i < 256; $i++) { |
| 266 | + $firstShapeKeyTypes[] = new ConstantStringType('first' . $i); |
| 267 | + $firstShapeValueTypes[] = new ConstantIntegerType($i); |
| 268 | + } |
| 269 | + |
| 270 | + $result = (new ArrayMergeType([ |
| 271 | + new ArrayType(new StringType(), new IntegerType()), |
| 272 | + new ConstantArrayType($firstShapeKeyTypes, $firstShapeValueTypes), |
| 273 | + new ConstantArrayType( |
| 274 | + [new ConstantStringType('first0'), new ConstantStringType('last')], |
| 275 | + [new ConstantStringType('overwritten'), new ConstantStringType('tail')], |
| 276 | + ), |
| 277 | + ]))->resolve(); |
| 278 | + |
| 279 | + if (!self::supportsUnsealedShapes()) { |
| 280 | + $this->assertSame([], $result->getConstantArrays()); |
| 281 | + return; |
| 282 | + } |
| 283 | + |
| 284 | + $constantArrays = $result->getConstantArrays(); |
| 285 | + $this->assertCount( |
| 286 | + 1, |
| 287 | + $constantArrays, |
| 288 | + sprintf( |
| 289 | + 'All eligible trailing-shape offsets must remain known when their combined size crosses 256; got %s.', |
| 290 | + $result->describe(VerbosityLevel::precise()), |
| 291 | + ), |
| 292 | + ); |
| 293 | + $this->assertCount(257, $constantArrays[0]->getKeyTypes()); |
| 294 | + $this->assertSame('first0', $constantArrays[0]->getKeyTypes()[0]->getValue()); |
| 295 | + $this->assertSame('last', $constantArrays[0]->getKeyTypes()[256]->getValue()); |
| 296 | + $this->assertTrue($constantArrays[0]->hasOffsetValueType(new ConstantStringType('first0'))->yes()); |
| 297 | + $this->assertTrue((new ConstantStringType('overwritten'))->equals( |
| 298 | + $constantArrays[0]->getOffsetValueType(new ConstantStringType('first0')), |
| 299 | + )); |
| 300 | + $this->assertTrue($constantArrays[0]->hasOffsetValueType(new ConstantStringType('first255'))->yes()); |
| 301 | + $this->assertTrue($constantArrays[0]->hasOffsetValueType(new ConstantStringType('last'))->yes()); |
| 302 | + } |
| 303 | + |
| 304 | + public function testMultipleTrailingShapesMatchNativeOverwriteAndInsertionOrder(): void |
| 305 | + { |
| 306 | + $runtimeShapes = [ |
| 307 | + ['first' => 'left', '08' => 'leading zero', 'shared' => 'first value', 'middle' => 1], |
| 308 | + ['+8' => true, 'second' => true, 'shared' => 'second value', '08' => 'overwritten leading zero'], |
| 309 | + ['middle' => 'last middle', '-0' => false, 'third' => 3, '+8' => false, 'shared' => false], |
| 310 | + ]; |
| 311 | + $shapeTypes = array_map(self::constantArrayFromRuntime(...), $runtimeShapes); |
| 312 | + |
| 313 | + $result = (new ArrayMergeType([ |
| 314 | + new ArrayType(new StringType(), new IntegerType()), |
| 315 | + ...$shapeTypes, |
| 316 | + ]))->resolve(); |
| 317 | + $runtimeOutcome = self::constantArrayFromRuntime(array_merge( |
| 318 | + ['dynamic' => 7], |
| 319 | + ...$runtimeShapes, |
| 320 | + )); |
| 321 | + |
| 322 | + $this->assertTrue( |
| 323 | + $result->isSuperTypeOf($runtimeOutcome)->yes(), |
| 324 | + sprintf( |
| 325 | + 'Inferred %s must contain representative native result %s.', |
| 326 | + $result->describe(VerbosityLevel::precise()), |
| 327 | + $runtimeOutcome->describe(VerbosityLevel::precise()), |
| 328 | + ), |
| 329 | + ); |
| 330 | + |
| 331 | + if (!self::supportsUnsealedShapes()) { |
| 332 | + $this->assertSame([], $result->getConstantArrays()); |
| 333 | + return; |
| 334 | + } |
| 335 | + |
| 336 | + $constantArrays = $result->getConstantArrays(); |
| 337 | + $this->assertCount(1, $constantArrays); |
| 338 | + $actualKnownKeys = array_map( |
| 339 | + static fn(ConstantIntegerType|ConstantStringType $keyType): int|string => $keyType->getValue(), |
| 340 | + $constantArrays[0]->getKeyTypes(), |
| 341 | + ); |
| 342 | + $nativeKnownKeys = array_keys(array_merge(...$runtimeShapes)); |
| 343 | + $this->assertSame($nativeKnownKeys, $actualKnownKeys); |
| 344 | + |
| 345 | + $expectedKnownOffsets = self::constantArrayFromRuntime(array_merge(...$runtimeShapes)); |
| 346 | + $expectedConstantArrays = $expectedKnownOffsets->getConstantArrays(); |
| 347 | + $this->assertCount(1, $expectedConstantArrays); |
| 348 | + |
| 349 | + foreach ($expectedConstantArrays[0]->getKeyTypes() as $keyType) { |
| 350 | + $this->assertTrue( |
| 351 | + $expectedConstantArrays[0]->getOffsetValueType($keyType)->equals( |
| 352 | + $constantArrays[0]->getOffsetValueType($keyType), |
| 353 | + ), |
| 354 | + sprintf('Known offset %s must have the native last-writer value type.', $keyType->getValue()), |
| 355 | + ); |
| 356 | + } |
| 357 | + |
| 358 | + $this->assertTrue((new IntegerType())->equals( |
| 359 | + $constantArrays[0]->getOffsetValueType(new ConstantStringType('dynamic')), |
| 360 | + )); |
| 361 | + } |
| 362 | + |
| 363 | + public function testDisqualifiedThirdOperandForcesConservativeFallback(): void |
| 364 | + { |
| 365 | + $generic = new ArrayType(new StringType(), new IntegerType()); |
| 366 | + $validShape = new ConstantArrayType( |
| 367 | + [new ConstantStringType('fixed')], |
| 368 | + [new StringType()], |
| 369 | + ); |
| 370 | + $optionalShape = new ConstantArrayType( |
| 371 | + [new ConstantStringType('fixed')], |
| 372 | + [new BooleanType()], |
| 373 | + [0], |
| 374 | + [0], |
| 375 | + ); |
| 376 | + $integerShape = new ConstantArrayType( |
| 377 | + [new ConstantIntegerType(7)], |
| 378 | + [new BooleanType()], |
| 379 | + [8], |
| 380 | + ); |
| 381 | + $canonicalIntegerStringShape = new ConstantArrayType( |
| 382 | + [new ConstantStringType('7')], |
| 383 | + [new BooleanType()], |
| 384 | + ); |
| 385 | + $otherShape = new ConstantArrayType( |
| 386 | + [new ConstantStringType('other')], |
| 387 | + [new BooleanType()], |
| 388 | + ); |
| 389 | + $unusualAutoIndexShape = new ConstantArrayType( |
| 390 | + [new ConstantStringType('other')], |
| 391 | + [new BooleanType()], |
| 392 | + [7], |
| 393 | + ); |
| 394 | + |
| 395 | + /** |
| 396 | + * @var array<string, array{ |
| 397 | + * non-empty-list<Type>, |
| 398 | + * non-empty-list<array<int|string, bool|int|string>> |
| 399 | + * }> $scenarios |
| 400 | + */ |
| 401 | + $scenarios = [ |
| 402 | + 'optional keys' => [ |
| 403 | + [$generic, $validShape, $optionalShape], |
| 404 | + [['dynamic' => 1], ['fixed' => 'shape'], []], |
| 405 | + ], |
| 406 | + 'integer keys' => [ |
| 407 | + [$generic, $validShape, $integerShape], |
| 408 | + [['dynamic' => 1], ['fixed' => 'shape'], [7 => true]], |
| 409 | + ], |
| 410 | + 'canonical integer string keys' => [ |
| 411 | + [$generic, $validShape, $canonicalIntegerStringShape], |
| 412 | + [['dynamic' => 1], ['fixed' => 'shape'], [7 => true]], |
| 413 | + ], |
| 414 | + 'shape unions' => [ |
| 415 | + [$generic, $validShape, new UnionType([$validShape, $otherShape])], |
| 416 | + [['dynamic' => 1], ['fixed' => 'shape'], ['other' => true]], |
| 417 | + ], |
| 418 | + 'shape intersections' => [ |
| 419 | + [$generic, $validShape, new IntersectionType([$otherShape, new NonEmptyArrayType()])], |
| 420 | + [['dynamic' => 1], ['fixed' => 'shape'], ['other' => true]], |
| 421 | + ], |
| 422 | + 'generic operands after shapes' => [ |
| 423 | + [$generic, $validShape, new ArrayType(new StringType(), new BooleanType())], |
| 424 | + [['dynamic' => 1], ['fixed' => 'shape'], ['fixed' => true]], |
| 425 | + ], |
| 426 | + 'empty shapes' => [ |
| 427 | + [$generic, $validShape, ConstantArrayTypeBuilder::createEmpty()->getArray()], |
| 428 | + [['dynamic' => 1], ['fixed' => 'shape'], []], |
| 429 | + ], |
| 430 | + 'unusual auto-index metadata' => [ |
| 431 | + [$generic, $validShape, $unusualAutoIndexShape], |
| 432 | + [['dynamic' => 1], ['fixed' => 'shape'], ['other' => true]], |
| 433 | + ], |
| 434 | + ]; |
| 435 | + |
| 436 | + if (self::supportsUnsealedShapes()) { |
| 437 | + $unsealedShape = new ConstantArrayType( |
| 438 | + [new ConstantStringType('other')], |
| 439 | + [new BooleanType()], |
| 440 | + [0], |
| 441 | + [], |
| 442 | + TrinaryLogic::createNo(), |
| 443 | + [new StringType(), new BooleanType()], |
| 444 | + ); |
| 445 | + $scenarios['unsealed shapes'] = [ |
| 446 | + [$generic, $validShape, $unsealedShape], |
| 447 | + [['dynamic' => 1], ['fixed' => 'shape'], ['other' => true, 'extra' => false]], |
| 448 | + ]; |
| 449 | + } |
| 450 | + |
| 451 | + foreach ($scenarios as $name => [$declaredTypes, $runtimeOperands]) { |
| 452 | + $result = (new ArrayMergeType($declaredTypes))->resolve(); |
| 453 | + $this->assertSame( |
| 454 | + [], |
| 455 | + $result->getConstantArrays(), |
| 456 | + sprintf('Scenario %s must retain the prior conservative fallback.', $name), |
| 457 | + ); |
| 458 | + |
| 459 | + $runtimeOutcome = self::constantArrayFromRuntime(array_merge(...$runtimeOperands)); |
| 460 | + $this->assertTrue( |
| 461 | + $result->isSuperTypeOf($runtimeOutcome)->yes(), |
| 462 | + sprintf( |
| 463 | + 'Scenario %s inferred %s, which excludes native result %s.', |
| 464 | + $name, |
| 465 | + $result->describe(VerbosityLevel::precise()), |
| 466 | + $runtimeOutcome->describe(VerbosityLevel::precise()), |
| 467 | + ), |
| 468 | + ); |
| 469 | + } |
| 470 | + } |
| 471 | + |
258 | 472 | private static function supportsUnsealedShapes(): bool |
259 | 473 | { |
260 | 474 | return self::hasOptionalMethod(ConstantArrayTypeBuilder::createEmpty(), 'makeUnsealed'); |
|
0 commit comments