1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Type\Constant;
4:
5: use Nette\Utils\Strings;
6: use PHPStan\Analyser\OutOfClassScope;
7: use PHPStan\DependencyInjection\BleedingEdgeToggle;
8: use PHPStan\Php\PhpVersion;
9: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode;
10: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
11: use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
12: use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
13: use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode;
14: use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
15: use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
16: use PHPStan\PhpDocParser\Ast\Type\TypeNode;
17: use PHPStan\Reflection\Callables\FunctionCallableVariant;
18: use PHPStan\Reflection\ClassMemberAccessAnswerer;
19: use PHPStan\Reflection\InaccessibleMethod;
20: use PHPStan\Reflection\InitializerExprTypeResolver;
21: use PHPStan\Reflection\PhpVersionStaticAccessor;
22: use PHPStan\Reflection\TrivialParametersAcceptor;
23: use PHPStan\Rules\Arrays\AllowedArrayKeysTypes;
24: use PHPStan\ShouldNotHappenException;
25: use PHPStan\TrinaryLogic;
26: use PHPStan\Turbo\ShadowedByTurboExtension;
27: use PHPStan\Type\AcceptsResult;
28: use PHPStan\Type\Accessory\AccessoryArrayListType;
29: use PHPStan\Type\Accessory\AccessoryLowercaseStringType;
30: use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
31: use PHPStan\Type\Accessory\AccessoryNonFalsyStringType;
32: use PHPStan\Type\Accessory\AccessoryNumericStringType;
33: use PHPStan\Type\Accessory\AccessoryUppercaseStringType;
34: use PHPStan\Type\Accessory\HasOffsetType;
35: use PHPStan\Type\Accessory\HasOffsetValueType;
36: use PHPStan\Type\Accessory\NonEmptyArrayType;
37: use PHPStan\Type\ArrayType;
38: use PHPStan\Type\BenevolentUnionType;
39: use PHPStan\Type\BooleanType;
40: use PHPStan\Type\ClassStringType;
41: use PHPStan\Type\CompoundType;
42: use PHPStan\Type\ConstantScalarType;
43: use PHPStan\Type\ErrorType;
44: use PHPStan\Type\GeneralizePrecision;
45: use PHPStan\Type\Generic\TemplateMixedType;
46: use PHPStan\Type\Generic\TemplateStrictMixedType;
47: use PHPStan\Type\Generic\TemplateType;
48: use PHPStan\Type\Generic\TemplateTypeMap;
49: use PHPStan\Type\Generic\TemplateTypeVariance;
50: use PHPStan\Type\InstanceofDeprecated;
51: use PHPStan\Type\IntegerRangeType;
52: use PHPStan\Type\IntegerType;
53: use PHPStan\Type\IntersectionType;
54: use PHPStan\Type\IsSuperTypeOfResult;
55: use PHPStan\Type\MixedType;
56: use PHPStan\Type\NeverType;
57: use PHPStan\Type\NullType;
58: use PHPStan\Type\ObjectWithoutClassType;
59: use PHPStan\Type\RecursionGuard;
60: use PHPStan\Type\StaticTypeFactory;
61: use PHPStan\Type\StrictMixedType;
62: use PHPStan\Type\StringType;
63: use PHPStan\Type\Traits\ArrayTypeTrait;
64: use PHPStan\Type\Traits\NonObjectTypeTrait;
65: use PHPStan\Type\Traits\UndecidedComparisonTypeTrait;
66: use PHPStan\Type\Traverser\UnsafeArrayStringKeyCastingTraverser;
67: use PHPStan\Type\Type;
68: use PHPStan\Type\TypeCombinator;
69: use PHPStan\Type\UnionType;
70: use PHPStan\Type\VerbosityLevel;
71: use function array_flip;
72: use function array_key_exists;
73: use function array_keys;
74: use function array_map;
75: use function array_merge;
76: use function array_pop;
77: use function array_push;
78: use function array_slice;
79: use function array_unique;
80: use function array_values;
81: use function assert;
82: use function count;
83: use function implode;
84: use function in_array;
85: use function is_int;
86: use function is_string;
87: use function max;
88: use function min;
89: use function pow;
90: use function range;
91: use function sort;
92: use function spl_object_id;
93: use function sprintf;
94: use function str_contains;
95: use function strtolower;
96: use function strtoupper;
97: use function usort;
98: use const CASE_LOWER;
99: use const CASE_UPPER;
100:
101: /**
102: * @api
103: */
104: #[InstanceofDeprecated(insteadUse: 'Type::getConstantArrays()')]
105: #[ShadowedByTurboExtension(implementation: __DIR__ . '/../../../turbo-ext/src/ConstantArrayType.cpp')]
106: class ConstantArrayType implements Type
107: {
108:
109: use ArrayTypeTrait {
110: chunkArray as traitChunkArray;
111: }
112: use NonObjectTypeTrait;
113: use UndecidedComparisonTypeTrait;
114:
115: private const DESCRIBE_LIMIT = 8;
116: private const CHUNK_FINITE_TYPES_LIMIT = 5;
117: private const UNSEALED_ARRAY_SHAPES_LINK = 'https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys';
118:
119: private TrinaryLogic $isList;
120:
121: /** @var array{Type, Type}|null */
122: private ?array $unsealed; // phpcs:ignore
123:
124: /** @var self[]|null */
125: private ?array $allArrays = null;
126:
127: private ?Type $iterableKeyType = null;
128:
129: private ?Type $iterableValueType = null;
130:
131: private ?Type $keyTypesUnion = null;
132:
133: /** @var array<int|string, int>|null */
134: private ?array $keyIndexMap = null;
135:
136: /** @var array<int, int>|null */
137: private ?array $optionalKeySet = null;
138:
139: /**
140: * @api
141: * @param list<ConstantIntegerType|ConstantStringType> $keyTypes
142: * @param array<int, Type> $valueTypes
143: * @param list<int> $nextAutoIndexes
144: * @param int[] $optionalKeys
145: * @param array{Type, Type}|null $unsealed
146: */
147: public function __construct(
148: private array $keyTypes,
149: private array $valueTypes,
150: private array $nextAutoIndexes = [0],
151: private array $optionalKeys = [],
152: ?TrinaryLogic $isList = null,
153: ?array $unsealed = null,
154: )
155: {
156: assert(count($keyTypes) === count($valueTypes));
157:
158: // Fill in `$isList` from the shape when the caller didn't pass one.
159: // For empty CATs the answer derives from the unsealed key type
160: // (no explicit keys to inspect); for non-empty ones the default
161: // is `No` and the caller is expected to assert list-ness via
162: // `makeList()` if appropriate.
163: if ($isList === null) {
164: if (count($this->keyTypes) === 0) {
165: if ($unsealed === null) {
166: $isList = TrinaryLogic::createYes();
167: } else {
168: [$unsealedKeyType] = $unsealed;
169: if ($unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit()) {
170: $isList = TrinaryLogic::createYes();
171: } elseif ($unsealedKeyType->isInteger()->yes()) {
172: $isList = TrinaryLogic::createMaybe();
173: } else {
174: $isList = TrinaryLogic::createNo();
175: }
176: }
177: } else {
178: $isList = TrinaryLogic::createNo();
179: }
180: }
181: $this->isList = $isList;
182:
183: if ($unsealed !== null) {
184: // Only a BenevolentUnionType describes with the surrounding parentheses of
185: // '(int|string)' / '(int|non-decimal-int-string)', so skip the describe() call
186: // for every other key type.
187: if ($unsealed[0] instanceof BenevolentUnionType && in_array($unsealed[0]->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) {
188: $unsealed[0] = new MixedType();
189: }
190: if ($unsealed[0] instanceof StrictMixedType && !$unsealed[0] instanceof TemplateStrictMixedType) {
191: $unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey();
192: }
193: if ($unsealed[0] instanceof NeverType && $unsealed[0]->isExplicit()) {
194: $unsealed[1] = new NeverType(true);
195: }
196: } elseif (BleedingEdgeToggle::isBleedingEdge()) {
197: $never = new NeverType(true);
198: $unsealed = [$never, $never];
199: }
200: $this->unsealed = $unsealed;
201: }
202:
203: public function isSealed(): TrinaryLogic
204: {
205: return $this->isUnsealed()->negate();
206: }
207:
208: public function isUnsealed(): TrinaryLogic
209: {
210: $unsealed = $this->unsealed;
211: if ($unsealed === null) {
212: return TrinaryLogic::createMaybe();
213: }
214:
215: [$keyType] = $unsealed;
216:
217: return TrinaryLogic::createFromBoolean(!$keyType instanceof NeverType || !$keyType->isExplicit());
218: }
219:
220: /**
221: * @phpstan-pure
222: * @return array{Type, Type}|null
223: */
224: public function getUnsealedTypes(): ?array
225: {
226: return $this->unsealed;
227: }
228:
229: /**
230: * @internal
231: */
232: public function dropUnsealedTypes(): self
233: {
234: return $this->recreate(
235: $this->keyTypes,
236: $this->valueTypes,
237: $this->nextAutoIndexes,
238: $this->optionalKeys,
239: $this->isList,
240: null,
241: );
242: }
243:
244: /**
245: * @param list<ConstantIntegerType|ConstantStringType> $keyTypes
246: * @param array<int, Type> $valueTypes
247: * @param list<int> $nextAutoIndexes
248: * @param int[] $optionalKeys
249: * @param array{Type, Type}|null $unsealed
250: */
251: protected function recreate(
252: array $keyTypes,
253: array $valueTypes,
254: array $nextAutoIndexes,
255: array $optionalKeys,
256: ?TrinaryLogic $isList,
257: ?array $unsealed,
258: ): self
259: {
260: return new self($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $unsealed);
261: }
262:
263: public function getConstantArrays(): array
264: {
265: return [$this];
266: }
267:
268: public function getReferencedClasses(): array
269: {
270: $referencedClasses = [];
271: foreach ($this->getKeyTypes() as $keyType) {
272: foreach ($keyType->getReferencedClasses() as $referencedClass) {
273: $referencedClasses[] = $referencedClass;
274: }
275: }
276:
277: foreach ($this->getValueTypes() as $valueType) {
278: foreach ($valueType->getReferencedClasses() as $referencedClass) {
279: $referencedClasses[] = $referencedClass;
280: }
281: }
282:
283: if ($this->unsealed !== null) {
284: [$unsealedKeyType, $unsealedValueType] = $this->unsealed;
285: foreach ($unsealedKeyType->getReferencedClasses() as $referencedClass) {
286: $referencedClasses[] = $referencedClass;
287: }
288: foreach ($unsealedValueType->getReferencedClasses() as $referencedClass) {
289: $referencedClasses[] = $referencedClass;
290: }
291: }
292:
293: return $referencedClasses;
294: }
295:
296: public function getIterableKeyType(): Type
297: {
298: if ($this->iterableKeyType !== null) {
299: return $this->iterableKeyType;
300: }
301:
302: $keyTypesCount = count($this->keyTypes);
303: if ($keyTypesCount === 0) {
304: $keyType = new NeverType(true);
305: } elseif ($keyTypesCount === 1) {
306: $keyType = $this->keyTypes[0];
307: } else {
308: $keyType = new UnionType($this->keyTypes);
309: }
310:
311: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
312: $unsealedKeyType = $this->unsealed[0];
313: if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) {
314: $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
315: } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) {
316: $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
317: }
318: $keyType = TypeCombinator::union($keyType, $unsealedKeyType);
319: }
320:
321: return $this->iterableKeyType = UnsafeArrayStringKeyCastingTraverser::castKeyType($keyType);
322: }
323:
324: public function getIterableValueType(): Type
325: {
326: if ($this->iterableValueType !== null) {
327: return $this->iterableValueType;
328: }
329:
330: $valueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true);
331: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
332: $valueType = TypeCombinator::union($valueType, $this->unsealed[1]);
333: }
334:
335: return $this->iterableValueType = $valueType;
336: }
337:
338: private function getKeyTypesUnion(): Type
339: {
340: return $this->keyTypesUnion ??= count($this->keyTypes) > 0
341: ? TypeCombinator::union(...$this->keyTypes)
342: : new NeverType();
343: }
344:
345: public function getKeyType(): Type
346: {
347: return $this->getIterableKeyType();
348: }
349:
350: public function getItemType(): Type
351: {
352: return $this->getIterableValueType();
353: }
354:
355: public function isConstantValue(): TrinaryLogic
356: {
357: if ($this->isUnsealed()->yes()) {
358: return TrinaryLogic::createNo();
359: }
360:
361: return TrinaryLogic::createYes();
362: }
363:
364: /**
365: * @return list<int>
366: */
367: public function getNextAutoIndexes(): array
368: {
369: return $this->nextAutoIndexes;
370: }
371:
372: /**
373: * @return int[]
374: */
375: public function getOptionalKeys(): array
376: {
377: return $this->optionalKeys;
378: }
379:
380: /**
381: * @return self[]
382: */
383: public function getAllArrays(): array
384: {
385: if ($this->allArrays !== null) {
386: return $this->allArrays;
387: }
388:
389: if (count($this->optionalKeys) <= 10) {
390: $optionalKeysCombinations = $this->powerSet($this->optionalKeys);
391: } else {
392: $optionalKeysCombinations = [
393: [],
394: array_slice($this->optionalKeys, 0, 1, true),
395: array_slice($this->optionalKeys, -1, 1, true),
396: $this->optionalKeys,
397: ];
398: }
399:
400: $requiredKeys = [];
401: foreach (array_keys($this->keyTypes) as $i) {
402: if (in_array($i, $this->optionalKeys, true)) {
403: continue;
404: }
405: $requiredKeys[] = $i;
406: }
407:
408: $arrays = [];
409: foreach ($optionalKeysCombinations as $combination) {
410: $keys = array_merge($requiredKeys, $combination);
411: sort($keys);
412:
413: if ($this->isList->yes() && array_keys($keys) !== $keys) {
414: continue;
415: }
416:
417: if (count($keys) === 0 && $this->isUnsealed()->yes() && $this->unsealed !== null) {
418: // Variant with no explicit keys but real unsealed extras: the
419: // builder's getArray() would degrade this to a general
420: // ArrayType. Construct the CAT directly so the variant keeps
421: // its extras for downstream consumers (e.g. flattenTypes).
422: $arrays[] = new ConstantArrayType([], [], unsealed: $this->unsealed);
423: continue;
424: }
425:
426: $builder = ConstantArrayTypeBuilder::createEmpty();
427: $builder->disableArrayDegradation();
428: foreach ($keys as $i) {
429: $builder->setOffsetValueType($this->keyTypes[$i], $this->valueTypes[$i]);
430: }
431: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
432: $builder->makeUnsealed($this->unsealed[0], $this->unsealed[1]);
433: }
434:
435: $array = $builder->getArray();
436: if (!$array instanceof self) {
437: throw new ShouldNotHappenException();
438: }
439:
440: $arrays[] = $array;
441: }
442:
443: return $this->allArrays = $arrays;
444: }
445:
446: /**
447: * @template T
448: * @param T[] $in
449: * @return T[][]
450: */
451: private function powerSet(array $in): array
452: {
453: $count = count($in);
454: $members = pow(2, $count);
455: $return = [];
456: for ($i = 0; $i < $members; $i++) {
457: $b = sprintf('%0' . $count . 'b', $i);
458: $out = [];
459: for ($j = 0; $j < $count; $j++) {
460: if ($b[$j] !== '1') {
461: continue;
462: }
463:
464: $out[] = $in[$j];
465: }
466: $return[] = $out;
467: }
468:
469: return $return;
470: }
471:
472: /**
473: * @return list<ConstantIntegerType|ConstantStringType>
474: */
475: public function getKeyTypes(): array
476: {
477: return $this->keyTypes;
478: }
479:
480: /**
481: * @return array<int, Type>
482: */
483: public function getValueTypes(): array
484: {
485: return $this->valueTypes;
486: }
487:
488: public function isOptionalKey(int $i): bool
489: {
490: $this->optionalKeySet ??= array_flip($this->optionalKeys);
491:
492: return isset($this->optionalKeySet[$i]);
493: }
494:
495: public function sortKeys(): self
496: {
497: $indices = array_keys($this->keyTypes);
498: usort($indices, fn (int $a, int $b): int => $this->keyTypes[$a]->getValue() <=> $this->keyTypes[$b]->getValue());
499:
500: $newKeyTypes = [];
501: $newValueTypes = [];
502: $indexMap = [];
503: foreach ($indices as $newIdx => $oldIdx) {
504: $newKeyTypes[] = $this->keyTypes[$oldIdx];
505: $newValueTypes[] = $this->valueTypes[$oldIdx];
506: $indexMap[$oldIdx] = $newIdx;
507: }
508:
509: $newOptionalKeys = [];
510: foreach ($this->optionalKeys as $oldIdx) {
511: $newOptionalKeys[] = $indexMap[$oldIdx];
512: }
513: sort($newOptionalKeys);
514:
515: return $this->recreate(
516: $newKeyTypes,
517: $newValueTypes,
518: $this->nextAutoIndexes,
519: $newOptionalKeys,
520: $this->isList,
521: $this->unsealed,
522: );
523: }
524:
525: public function accepts(Type $type, bool $strictTypes): AcceptsResult
526: {
527: if ($type instanceof CompoundType && !$type instanceof IntersectionType) {
528: return $type->isAcceptedBy($this, $strictTypes);
529: }
530:
531: $isUnsealed = $this->isUnsealed();
532: if (!$isUnsealed->yes()) {
533: if ($type instanceof self && count($this->keyTypes) === 0) {
534: return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0);
535: }
536: }
537:
538: $result = $this->checkOurKeys($type, $strictTypes)->and(new AcceptsResult($type->isArray(), []));
539: if ($this->unsealed === null) {
540: if ($type->isOversizedArray()->yes()) {
541: if (!$result->no()) {
542: return AcceptsResult::createYes();
543: }
544: }
545:
546: return $result;
547: }
548:
549: if ($result->no()) {
550: return $result;
551: }
552:
553: [$unsealedKeyType, $unsealedValueType] = $this->unsealed;
554:
555: if ($isUnsealed->no()) {
556: if (!$type->isConstantArray()->yes()) {
557: return $result->and(AcceptsResult::createNo([
558: 'Sealed array shape can only accept a constant array. Extra keys are not allowed.',
559: ]));
560: }
561:
562: $constantArrays = $type->getConstantArrays();
563: if (count($constantArrays) !== 1) {
564: throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.');
565: }
566:
567: $keys = [];
568: foreach ($constantArrays[0]->getKeyTypes() as $otherKeyType) {
569: $keys[$otherKeyType->getValue()] = $otherKeyType;
570: }
571:
572: foreach ($this->keyTypes as $keyType) {
573: unset($keys[$keyType->getValue()]);
574: }
575:
576: foreach ($keys as $extraKey) {
577: $result = $result->and(AcceptsResult::createNo([
578: sprintf('Sealed array shape does not accept array with extra key %s.', $extraKey->describe(VerbosityLevel::precise())),
579: ]));
580: }
581:
582: if (!$constantArrays[0]->isUnsealed()->no()) {
583: $result = $result->and(AcceptsResult::createNo([
584: 'Sealed array shape does not accept unsealed array shape.',
585: ]));
586: }
587:
588: return $result;
589: }
590:
591: if (!$type->isConstantArray()->yes()) {
592: return $result->and($unsealedKeyType->accepts($type->getIterableKeyType(), $strictTypes))
593: ->and($unsealedValueType->accepts($type->getIterableValueType(), $strictTypes));
594: }
595:
596: $constantArrays = $type->getConstantArrays();
597: if (count($constantArrays) !== 1) {
598: throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.');
599: }
600:
601: $keys = [];
602: $constantArray = $constantArrays[0];
603: foreach ($constantArray->getKeyTypes() as $i => $otherKeyType) {
604: $keys[$otherKeyType->getValue()] = [$i, $otherKeyType];
605: }
606:
607: foreach ($this->keyTypes as $keyType) {
608: unset($keys[$keyType->getValue()]);
609: }
610:
611: foreach ($keys as [$i, $extraKeyType]) {
612: $acceptsKey = $unsealedKeyType->accepts($extraKeyType, $strictTypes)->decorateReasons(
613: static fn (string $reason) => sprintf(
614: 'Unsealed array key type %s does not accept extra key type %s: %s',
615: $unsealedKeyType->describe(VerbosityLevel::value()),
616: $extraKeyType->describe(VerbosityLevel::value()),
617: $reason,
618: ),
619: );
620: if (!$acceptsKey->yes() && count($acceptsKey->reasons) === 0) {
621: $acceptsKey = new AcceptsResult($acceptsKey->result, [
622: sprintf(
623: 'Unsealed array key type %s does not accept extra key type %s.',
624: $unsealedKeyType->describe(VerbosityLevel::value()),
625: $extraKeyType->describe(VerbosityLevel::value()),
626: ),
627: ]);
628: }
629: $result = $result->and($acceptsKey);
630:
631: $extraValueType = $constantArray->getValueTypes()[$i];
632: $acceptsValue = $unsealedValueType->accepts($extraValueType, $strictTypes)->decorateReasons(
633: static fn (string $reason) => sprintf(
634: 'Unsealed array value type %s does not accept extra offset %s with value type %s: %s',
635: $unsealedValueType->describe(VerbosityLevel::value()),
636: $extraKeyType->describe(VerbosityLevel::value()),
637: $extraValueType->describe(VerbosityLevel::value()),
638: $reason,
639: ),
640: );
641: if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0) {
642: $acceptsValue = new AcceptsResult($acceptsValue->result, [
643: sprintf(
644: 'Unsealed array value type %s does not accept extra offset %s with value type %s.',
645: $unsealedValueType->describe(VerbosityLevel::value()),
646: $extraKeyType->describe(VerbosityLevel::value()),
647: $extraValueType->describe(VerbosityLevel::value()),
648: ),
649: ]);
650: }
651: $result = $result->and($acceptsValue);
652: }
653:
654: $otherUnsealed = $constantArray->unsealed;
655: if ($otherUnsealed !== null && !$constantArray->isUnsealed()->no()) {
656: [$otherUnsealedKeyType, $otherUnsealedValueType] = $otherUnsealed;
657:
658: $acceptsUnsealedKey = $unsealedKeyType->accepts($otherUnsealedKeyType, $strictTypes)->decorateReasons(
659: static fn (string $reason) => sprintf(
660: 'Unsealed array key type %s does not accept unsealed array key type %s: %s',
661: $unsealedKeyType->describe(VerbosityLevel::value()),
662: $otherUnsealedKeyType->describe(VerbosityLevel::value()),
663: $reason,
664: ),
665: );
666: if (!$acceptsUnsealedKey->yes() && count($acceptsUnsealedKey->reasons) === 0) {
667: $acceptsUnsealedKey = new AcceptsResult($acceptsUnsealedKey->result, [
668: sprintf(
669: 'Unsealed array key type %s does not accept unsealed array key type %s.',
670: $unsealedKeyType->describe(VerbosityLevel::value()),
671: $otherUnsealedKeyType->describe(VerbosityLevel::value()),
672: ),
673: ]);
674: }
675: $result = $result->and($acceptsUnsealedKey);
676:
677: $acceptsUnsealedValue = $unsealedValueType->accepts($otherUnsealedValueType, $strictTypes)->decorateReasons(
678: static fn (string $reason) => sprintf(
679: 'Unsealed array value type %s does not accept unsealed array value type %s: %s',
680: $unsealedValueType->describe(VerbosityLevel::value()),
681: $otherUnsealedValueType->describe(VerbosityLevel::value()),
682: $reason,
683: ),
684: );
685: if (!$acceptsUnsealedValue->yes() && count($acceptsUnsealedValue->reasons) === 0) {
686: $acceptsUnsealedValue = new AcceptsResult($acceptsUnsealedValue->result, [
687: sprintf(
688: 'Unsealed array value type %s does not accept unsealed array value type %s.',
689: $unsealedValueType->describe(VerbosityLevel::value()),
690: $otherUnsealedValueType->describe(VerbosityLevel::value()),
691: ),
692: ]);
693: }
694: $result = $result->and($acceptsUnsealedValue);
695: }
696:
697: return $result;
698: }
699:
700: private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult
701: {
702: $result = AcceptsResult::createYes();
703:
704: // offsets sharing the same value types (e.g. a type alias used for many keys) are compared once
705: /** @var array<string, array{Type, AcceptsResult}> $acceptsValueCache */
706: $acceptsValueCache = [];
707: foreach ($this->keyTypes as $i => $keyType) {
708: $valueType = $this->valueTypes[$i];
709: $hasOffsetValueType = $type->hasOffsetValueType($keyType);
710: $hasOffset = new AcceptsResult(
711: $hasOffsetValueType,
712: $hasOffsetValueType->yes() || !$type->isConstantArray()->yes() ? [] : [sprintf('Array %s have offset %s.', $hasOffsetValueType->no() ? 'does not' : 'might not', $keyType->describe(VerbosityLevel::value()))],
713: );
714: if ($hasOffset->no()) {
715: if ($this->isOptionalKey($i)) {
716: continue;
717: }
718: return $hasOffset;
719: }
720: if ($hasOffset->maybe() && $this->isOptionalKey($i)) {
721: $hasOffset = AcceptsResult::createYes();
722: }
723:
724: $result = $result->and($hasOffset);
725: $otherValueType = $type->getOffsetValueType($keyType);
726: $acceptsValueCacheKey = spl_object_id($valueType) . '-' . spl_object_id($otherValueType);
727: if (isset($acceptsValueCache[$acceptsValueCacheKey])) {
728: $acceptsValue = $acceptsValueCache[$acceptsValueCacheKey][1];
729: } else {
730: $acceptsValue = $valueType->accepts($otherValueType, $strictTypes);
731: // $otherValueType is kept alive so that its id is not reused
732: $acceptsValueCache[$acceptsValueCacheKey] = [$otherValueType, $acceptsValue];
733: }
734: if ($acceptsValue->yes() && count($acceptsValue->reasons) === 0) {
735: continue;
736: }
737:
738: $verbosity = VerbosityLevel::getRecommendedLevelByType($valueType, $otherValueType);
739: $acceptsValue = $acceptsValue->decorateReasons(
740: static fn (string $reason) => sprintf(
741: 'Offset %s (%s) does not accept type %s: %s',
742: $keyType->describe(VerbosityLevel::precise()),
743: $valueType->describe($verbosity),
744: $otherValueType->describe($verbosity),
745: $reason,
746: ),
747: );
748: if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0 && $type->isConstantArray()->yes()) {
749: $acceptsValue = new AcceptsResult($acceptsValue->result, [
750: sprintf(
751: 'Offset %s (%s) does not accept type %s.',
752: $keyType->describe(VerbosityLevel::precise()),
753: $valueType->describe($verbosity),
754: $otherValueType->describe($verbosity),
755: ),
756: ]);
757: }
758: if ($acceptsValue->no()) {
759: return $acceptsValue;
760: }
761: $result = $result->and($acceptsValue);
762: }
763:
764: return $result;
765: }
766:
767: public function isSuperTypeOf(Type $type): IsSuperTypeOfResult
768: {
769: if ($type instanceof self) {
770: $thisUnsealedness = $this->isUnsealed();
771: $typeUnsealedness = $type->isUnsealed();
772: $bothDefinite = $this->unsealed !== null && $type->unsealed !== null;
773:
774: if (count($this->keyTypes) === 0) {
775: if (!$bothDefinite) {
776: return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []);
777: }
778: if ($thisUnsealedness->no()) {
779: return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []);
780: }
781: // $this is unsealed with no known keys — fall through to extras/unsealed-part checks below
782: }
783:
784: $results = [];
785: foreach ($this->keyTypes as $i => $keyType) {
786: $hasOffset = $type->hasOffsetValueType($keyType);
787: if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) {
788: [$typeUnsealedKey] = $type->unsealed;
789: if (!$typeUnsealedKey->isSuperTypeOf($keyType)->no()) {
790: $hasOffset = TrinaryLogic::createMaybe();
791: }
792: }
793: if ($hasOffset->no()) {
794: if (!$this->isOptionalKey($i)) {
795: if ($thisUnsealedness->no() && $typeUnsealedness->no()) {
796: return IsSuperTypeOfResult::createNo(lazyReasons: [fn (): string => $this->sealedArrayShapesCannotBeIntersectedReason($type)]);
797: }
798: return IsSuperTypeOfResult::createNo();
799: }
800:
801: $results[] = IsSuperTypeOfResult::createYes();
802: continue;
803: } elseif ($hasOffset->maybe() && !$this->isOptionalKey($i)) {
804: $results[] = IsSuperTypeOfResult::createMaybe();
805: }
806:
807: $otherValueType = $type->getOffsetValueType($keyType);
808: if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) {
809: [, $typeUnsealedValue] = $type->unsealed;
810: $otherValueType = $typeUnsealedValue;
811: }
812: $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType);
813: if ($isValueSuperType->no()) {
814: return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason));
815: }
816: $results[] = $isValueSuperType;
817: }
818:
819: if ($bothDefinite) {
820: $thisKeyValues = [];
821: foreach ($this->keyTypes as $thisKeyType) {
822: $thisKeyValues[$thisKeyType->getValue()] = true;
823: }
824:
825: foreach ($type->getKeyTypes() as $i => $typeKey) {
826: if (array_key_exists($typeKey->getValue(), $thisKeyValues)) {
827: continue;
828: }
829:
830: if ($thisUnsealedness->no()) {
831: if (!$type->isOptionalKey($i)) {
832: if ($typeUnsealedness->no()) {
833: return IsSuperTypeOfResult::createNo(lazyReasons: [fn (): string => $this->sealedArrayShapesCannotBeIntersectedReason($type)]);
834: }
835: return IsSuperTypeOfResult::createNo();
836: }
837: $results[] = IsSuperTypeOfResult::createMaybe();
838: continue;
839: }
840:
841: [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed;
842: $keyCheck = $thisUnsealedKey->isSuperTypeOf($typeKey);
843: if ($keyCheck->no()) {
844: if ($type->isOptionalKey($i)) {
845: $results[] = IsSuperTypeOfResult::createMaybe();
846: continue;
847: }
848: return IsSuperTypeOfResult::createNo();
849: }
850: $valueCheck = $thisUnsealedValue->isSuperTypeOf($type->getValueTypes()[$i]);
851: if ($valueCheck->no()) {
852: if ($type->isOptionalKey($i)) {
853: $results[] = IsSuperTypeOfResult::createMaybe();
854: continue;
855: }
856: return IsSuperTypeOfResult::createNo();
857: }
858: $results[] = $keyCheck->and($valueCheck);
859: }
860:
861: if ($typeUnsealedness->yes()) {
862: if ($thisUnsealedness->no()) {
863: $results[] = IsSuperTypeOfResult::createMaybe();
864: } else {
865: [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed;
866: [$typeUnsealedKey, $typeUnsealedValue] = $type->unsealed;
867: $results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey);
868: $results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue);
869: }
870: }
871: }
872:
873: return IsSuperTypeOfResult::createYes()->and(...$results);
874: }
875:
876: if ($type instanceof ArrayType) {
877: $result = IsSuperTypeOfResult::createMaybe();
878: if (count($this->keyTypes) === 0) {
879: return $result;
880: }
881:
882: $isKeySuperType = $this->getKeyType()->isSuperTypeOf($type->getKeyType());
883: if ($isKeySuperType->no()) {
884: return $isKeySuperType;
885: }
886:
887: return $result->and($isKeySuperType, $this->getItemType()->isSuperTypeOf($type->getItemType()));
888: }
889:
890: if ($type instanceof CompoundType) {
891: return $type->isSubTypeOf($this);
892: }
893:
894: return IsSuperTypeOfResult::createNo();
895: }
896:
897: /**
898: * Passed as a lazy reason to IsSuperTypeOfResult so the expensive describe() calls only
899: * run when the reason is actually rendered, never during the hot isSuperTypeOf()
900: * comparisons whose reasons are discarded.
901: */
902: private function sealedArrayShapesCannotBeIntersectedReason(self $type): string
903: {
904: return sprintf(
905: 'Sealed array shapes %s and %s cannot be intersected. Unseal at least one of them with ... syntax. Learn more: %s',
906: $this->describe(VerbosityLevel::value()),
907: $type->describe(VerbosityLevel::value()),
908: self::UNSEALED_ARRAY_SHAPES_LINK,
909: );
910: }
911:
912: public function looseCompare(Type $type, PhpVersion $phpVersion): BooleanType
913: {
914: if ($type->isInteger()->yes()) {
915: return new ConstantBooleanType(false);
916: }
917:
918: if ($this->isIterableAtLeastOnce()->no()) {
919: if ($type->isIterableAtLeastOnce()->yes()) {
920: return new ConstantBooleanType(false);
921: }
922:
923: $constantScalarValues = $type->getConstantScalarValues();
924: if (count($constantScalarValues) > 0) {
925: $results = [];
926: foreach ($constantScalarValues as $constantScalarValue) {
927: // @phpstan-ignore equal.invalid, equal.notAllowed
928: $results[] = TrinaryLogic::createFromBoolean($constantScalarValue == []); // phpcs:ignore
929: }
930:
931: return TrinaryLogic::extremeIdentity(...$results)->toBooleanType();
932: }
933: }
934:
935: return new BooleanType();
936: }
937:
938: public function equals(Type $type): bool
939: {
940: if (!$type instanceof self) {
941: return false;
942: }
943:
944: if (count($this->keyTypes) !== count($type->keyTypes)) {
945: return false;
946: }
947:
948: foreach ($this->keyTypes as $i => $keyType) {
949: $valueType = $this->valueTypes[$i];
950: if (!$valueType->equals($type->valueTypes[$i])) {
951: return false;
952: }
953: if (!$keyType->equals($type->keyTypes[$i])) {
954: return false;
955: }
956: }
957:
958: if ($this->optionalKeys !== $type->optionalKeys) {
959: return false;
960: }
961:
962: // Both `unsealed === null` (legacy / pre-bleeding-edge, where
963: // `isUnsealed()` answers `Maybe`) and `unsealed === [explicitNever,
964: // explicitNever]` (the fresh bleeding-edge sealed marker, where
965: // `isUnsealed()` answers `No`) mean "no real extras". Treat them as
966: // equivalent here — use `!isUnsealed()->yes()` rather than
967: // `isUnsealed()->no()`, otherwise a legacy-null shape and a
968: // marker-sealed shape compare unequal. Only compare the actual
969: // extras when both sides genuinely have them.
970: $thisHasExtras = $this->isUnsealed()->yes();
971: $otherHasExtras = $type->isUnsealed()->yes();
972: if ($thisHasExtras !== $otherHasExtras) {
973: return false;
974: }
975:
976: if ($thisHasExtras && $this->unsealed !== null && $type->unsealed !== null) {
977: if (!$this->unsealed[0]->equals($type->unsealed[0])) {
978: return false;
979: }
980: if (!$this->unsealed[1]->equals($type->unsealed[1])) {
981: return false;
982: }
983: }
984:
985: return true;
986: }
987:
988: public function isCallable(): TrinaryLogic
989: {
990: $result = RecursionGuard::run($this, function (): TrinaryLogic {
991: $hasNonExistentMethod = false;
992: $typeAndMethods = $this->doFindTypeAndMethodNames($hasNonExistentMethod);
993: if ($typeAndMethods === []) {
994: return TrinaryLogic::createNo();
995: }
996:
997: $results = array_map(
998: static fn (ConstantArrayTypeAndMethod $typeAndMethod): TrinaryLogic => $typeAndMethod->getCertainty(),
999: $typeAndMethods,
1000: );
1001:
1002: $result = TrinaryLogic::createYes()->and(...$results);
1003:
1004: if ($hasNonExistentMethod) {
1005: $result = $result->and(TrinaryLogic::createMaybe());
1006: }
1007:
1008: return $result;
1009: });
1010:
1011: if ($result instanceof ErrorType) {
1012: return TrinaryLogic::createNo();
1013: }
1014:
1015: return $result;
1016: }
1017:
1018: public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array
1019: {
1020: $typeAndMethodNames = $this->findTypeAndMethodNames();
1021: if ($typeAndMethodNames === []) {
1022: throw new ShouldNotHappenException();
1023: }
1024:
1025: $acceptors = [];
1026: foreach ($typeAndMethodNames as $typeAndMethodName) {
1027: if ($typeAndMethodName->isUnknown() || !$typeAndMethodName->getCertainty()->yes()) {
1028: $acceptors[] = new TrivialParametersAcceptor();
1029: continue;
1030: }
1031:
1032: $method = $typeAndMethodName->getType()
1033: ->getMethod($typeAndMethodName->getMethod(), $scope);
1034:
1035: if (!$scope->canCallMethod($method)) {
1036: $acceptors[] = new InaccessibleMethod($method);
1037: continue;
1038: }
1039:
1040: array_push($acceptors, ...FunctionCallableVariant::createFromVariants($method, $method->getVariants()));
1041: }
1042:
1043: return $acceptors;
1044: }
1045:
1046: /** @return ConstantArrayTypeAndMethod[] */
1047: public function findTypeAndMethodNames(): array
1048: {
1049: return $this->doFindTypeAndMethodNames();
1050: }
1051:
1052: /** @return ConstantArrayTypeAndMethod[] */
1053: private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): array
1054: {
1055: $isUnsealed = $this->isUnsealed()->yes();
1056:
1057: // Sealed: must have exactly the two callable slots, no more, no less.
1058: // Unsealed: explicit keys may cover 0, 1, both, or neither — but any
1059: // explicit key outside {0, 1} immediately disqualifies, because the
1060: // callable shape `[classOrObject, method]` has no room for other
1061: // keys.
1062: if (!$isUnsealed && count($this->keyTypes) !== 2) {
1063: return [];
1064: }
1065: if (count($this->keyTypes) > 2) {
1066: return [];
1067: }
1068:
1069: $classOrObject = null;
1070: $method = null;
1071: foreach ($this->keyTypes as $i => $keyType) {
1072: if ($keyType->isSuperTypeOf(new ConstantIntegerType(0))->yes()) {
1073: $classOrObject = $this->valueTypes[$i];
1074: continue;
1075: }
1076:
1077: if ($keyType->isSuperTypeOf(new ConstantIntegerType(1))->yes()) {
1078: $method = $this->valueTypes[$i];
1079: continue;
1080: }
1081:
1082: // Explicit key is something other than 0 or 1 — not callable.
1083: return [];
1084: }
1085:
1086: // Try to fill missing callable slots from the unsealed extras: an
1087: // unsealed array `array{0: object, ...<int, string>}` *might* turn
1088: // into a callable if the actual value carries a `1 => 'method'`
1089: // extra. Require that the unsealed key range covers the missing
1090: // slot and that the unsealed value type can overlap with the
1091: // type required for that slot (object|class-string for key 0,
1092: // non-falsy-string for key 1) — otherwise no concrete value of
1093: // this CAT can ever be callable.
1094: if ($isUnsealed && $this->unsealed !== null) {
1095: [$unsealedKey, $unsealedValue] = $this->unsealed;
1096:
1097: if ($classOrObject === null) {
1098: if ($unsealedKey->isSuperTypeOf(new ConstantIntegerType(0))->no()) {
1099: return [];
1100: }
1101: $expected = TypeCombinator::union(new ObjectWithoutClassType(), new ClassStringType());
1102: if ($expected->isSuperTypeOf($unsealedValue)->no()) {
1103: return [];
1104: }
1105: $classOrObject = $unsealedValue;
1106: }
1107:
1108: if ($method === null) {
1109: if ($unsealedKey->isSuperTypeOf(new ConstantIntegerType(1))->no()) {
1110: return [];
1111: }
1112: $expected = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType());
1113: if ($expected->isSuperTypeOf($unsealedValue)->no()) {
1114: return [];
1115: }
1116: $method = $unsealedValue;
1117: }
1118: }
1119:
1120: if ($classOrObject === null || $method === null) {
1121: return [];
1122: }
1123:
1124: $callableArray = [$classOrObject, $method];
1125:
1126: [$classOrObject, $methods] = $callableArray;
1127: if (count($methods->getConstantStrings()) === 0) {
1128: return [ConstantArrayTypeAndMethod::createUnknown()];
1129: }
1130:
1131: $type = $classOrObject->getObjectTypeOrClassStringObjectType();
1132: if (!$type->isObject()->yes()) {
1133: return [ConstantArrayTypeAndMethod::createUnknown()];
1134: }
1135:
1136: $typeAndMethods = [];
1137: $phpVersion = PhpVersionStaticAccessor::getInstance();
1138: foreach ($methods->getConstantStrings() as $methodName) {
1139: $has = $type->hasMethod($methodName->getValue());
1140: if ($has->no()) {
1141: $hasNonExistentMethod = true;
1142: continue;
1143: }
1144:
1145: if (
1146: $has->yes()
1147: && !$phpVersion->supportsCallableInstanceMethods()
1148: ) {
1149: $isString = $classOrObject->isString();
1150: if ($isString->yes()) {
1151: $methodReflection = $type->getMethod($methodName->getValue(), new OutOfClassScope());
1152:
1153: if (!$methodReflection->isStatic()) {
1154: continue;
1155: }
1156: } elseif ($isString->maybe()) {
1157: $has = $has->and(TrinaryLogic::createMaybe());
1158: }
1159: }
1160:
1161: if ($this->isOptionalKey(0) || $this->isOptionalKey(1)) {
1162: $has = $has->and(TrinaryLogic::createMaybe());
1163: }
1164:
1165: // Unsealed: the actual value may carry extras beyond keys 0/1,
1166: // which would void the callable shape. The CAT itself describes
1167: // "zero or more extras", so callable-ness is uncertain.
1168: if ($isUnsealed) {
1169: $has = $has->and(TrinaryLogic::createMaybe());
1170: }
1171:
1172: $typeAndMethods[] = ConstantArrayTypeAndMethod::createConcrete($type, $methodName->getValue(), $has);
1173: }
1174:
1175: return $typeAndMethods;
1176: }
1177:
1178: public function hasOffsetValueType(Type $offsetType): TrinaryLogic
1179: {
1180: $offsetArrayKeyType = $offsetType->toArrayKey();
1181: if ($offsetArrayKeyType instanceof ErrorType) {
1182: $allowedArrayKeys = AllowedArrayKeysTypes::getType();
1183: $offsetArrayKeyType = TypeCombinator::intersect($allowedArrayKeys, $offsetType)->toArrayKey();
1184: if ($offsetArrayKeyType instanceof NeverType) {
1185: return TrinaryLogic::createNo();
1186: }
1187: }
1188:
1189: return $this->recursiveHasOffsetValueType($offsetArrayKeyType);
1190: }
1191:
1192: private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic
1193: {
1194: if ($offsetType instanceof UnionType) {
1195: $results = [];
1196: foreach ($offsetType->getTypes() as $innerType) {
1197: $results[] = $this->recursiveHasOffsetValueType($innerType);
1198: }
1199:
1200: return TrinaryLogic::extremeIdentity(...$results);
1201: }
1202: if ($offsetType instanceof IntegerRangeType) {
1203: $finiteTypes = $offsetType->getFiniteTypes();
1204: if ($finiteTypes !== []) {
1205: $results = [];
1206: foreach ($finiteTypes as $innerType) {
1207: $results[] = $this->recursiveHasOffsetValueType($innerType);
1208: }
1209:
1210: return TrinaryLogic::extremeIdentity(...$results);
1211: }
1212: }
1213:
1214: // Constant offsets against constant keys resolve by value: a hit at the
1215: // indexed slot is verified before use, a miss falls through to the scan
1216: // (which also covers the unsealed extras below).
1217: $index = $this->findVerifiedKeyIndex($offsetType);
1218: if ($index !== null) {
1219: if ($this->isOptionalKey($index)) {
1220: return TrinaryLogic::createMaybe();
1221: }
1222: return TrinaryLogic::createYes();
1223: }
1224:
1225: $result = TrinaryLogic::createNo();
1226: foreach ($this->keyTypes as $i => $keyType) {
1227: // PHP coerces decimal-integer strings to int when used as array
1228: // keys ("123" → 123), so a non-constant string offset *could* hit
1229: // a constant-integer slot. Skip the upgrade when the offset is
1230: // definitely a non-decimal-integer string — those stay as strings
1231: // and can never collide with an int key.
1232: if (
1233: $keyType instanceof ConstantIntegerType
1234: && !$offsetType->isString()->no()
1235: && $offsetType->isConstantScalarValue()->no()
1236: && !$offsetType->isDecimalIntegerString()->no()
1237: ) {
1238: return TrinaryLogic::createMaybe();
1239: }
1240:
1241: $has = $keyType->isSuperTypeOf($offsetType);
1242: if ($has->yes()) {
1243: if ($this->isOptionalKey($i)) {
1244: return TrinaryLogic::createMaybe();
1245: }
1246: return TrinaryLogic::createYes();
1247: }
1248: if (!$has->maybe()) {
1249: continue;
1250: }
1251:
1252: $result = TrinaryLogic::createMaybe();
1253: }
1254:
1255: // Unsealed extras (zero-or-more additional entries) can never make a
1256: // hit definite — they're uncertain by construction. They only matter
1257: // when no explicit key matched ($result is No): if the unsealed key
1258: // range overlaps the offset, upgrade No → Maybe. Explicit keys take
1259: // precedence at any slot they cover (PHP keys are unique), so a
1260: // non-No $result already reflects the strongest answer the unsealed
1261: // extras could contribute.
1262: if ($result->no() && $this->isUnsealed()->yes() && $this->unsealed !== null) {
1263: [$unsealedKeyType] = $this->unsealed;
1264: if (!$unsealedKeyType->isSuperTypeOf($offsetType)->no()) {
1265: $result = TrinaryLogic::createMaybe();
1266: }
1267: }
1268:
1269: return $result;
1270: }
1271:
1272: public function getOffsetValueType(Type $offsetType): Type
1273: {
1274: if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) {
1275: return new ErrorType();
1276: }
1277:
1278: $offsetType = $offsetType->toArrayKey();
1279: if (count($this->keyTypes) > 1) {
1280: // Same result as the scan below for a verified hit: exactly one explicit
1281: // key matches a constant offset, so neither the all-keys nor the
1282: // maybe-all fallbacks apply and the unsealed extras cannot contribute.
1283: $index = $this->findVerifiedKeyIndex($offsetType);
1284: if ($index !== null) {
1285: $type = TypeCombinator::union($this->valueTypes[$index]);
1286: if ($type instanceof ErrorType) {
1287: return new MixedType();
1288: }
1289:
1290: return $type;
1291: }
1292: }
1293:
1294: $matchingValueTypes = [];
1295: $all = true;
1296: $maybeAll = true;
1297: foreach ($this->keyTypes as $i => $keyType) {
1298: if ($keyType->isSuperTypeOf($offsetType)->no()) {
1299: $all = false;
1300:
1301: if (
1302: $keyType instanceof ConstantIntegerType
1303: && !$offsetType->isString()->no()
1304: && $offsetType->isConstantScalarValue()->no()
1305: ) {
1306: continue;
1307: }
1308: $maybeAll = false;
1309: continue;
1310: }
1311:
1312: $matchingValueTypes[] = $this->valueTypes[$i];
1313: }
1314:
1315: // Unsealed extras describe entries at keys NOT in the explicit set —
1316: // PHP array keys are unique, so an explicit key fully owns its slot.
1317: // Only include the unsealed value when the offset has parts not
1318: // covered by any explicit key AND those parts overlap the unsealed
1319: // key range.
1320: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
1321: [$unsealedKeyType, $unsealedValueType] = $this->unsealed;
1322: if (!$this->getKeyTypesUnion()->isSuperTypeOf($offsetType)->yes() && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) {
1323: $matchingValueTypes[] = $unsealedValueType;
1324: }
1325: }
1326:
1327: if ($all && !$this->isUnsealed()->yes()) {
1328: return $this->getIterableValueType();
1329: }
1330:
1331: if (count($matchingValueTypes) > 0) {
1332: $type = TypeCombinator::union(...$matchingValueTypes);
1333: if ($type instanceof ErrorType) {
1334: return new MixedType();
1335: }
1336:
1337: return $type;
1338: }
1339:
1340: if ($maybeAll) {
1341: return $this->getIterableValueType();
1342: }
1343:
1344: return new ErrorType(); // undefined offset
1345: }
1346:
1347: public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $unionValues = true): Type
1348: {
1349: if ($offsetType === null && count($this->nextAutoIndexes) === 0) {
1350: return new ErrorType();
1351: }
1352:
1353: $builder = ConstantArrayTypeBuilder::createFromConstantArray($this);
1354: $builder->setOffsetValueType($offsetType, $valueType);
1355:
1356: return $builder->getArray();
1357: }
1358:
1359: public function setExistingOffsetValueType(Type $offsetType, Type $valueType): Type
1360: {
1361: $builder = ConstantArrayTypeBuilder::createFromConstantArray($this);
1362: $builder->setOffsetValueType($offsetType, $valueType);
1363:
1364: return $builder->getArray();
1365: }
1366:
1367: /**
1368: * Removes or marks as optional the key(s) matching the given offset type from this constant array.
1369: *
1370: * By default, the method assumes an actual `unset()` call was made, which actively modifies the
1371: * array and weakens its list certainty to "maybe". However, in some contexts, such as the else
1372: * branch of an array_key_exists() check, the key is statically known to be absent without any
1373: * modification, so list certainty should be preserved as-is.
1374: */
1375: public function unsetOffset(Type $offsetType, bool $preserveListCertainty = false): Type
1376: {
1377: $offsetType = $offsetType->toArrayKey();
1378: if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
1379: foreach ($this->keyTypes as $i => $keyType) {
1380: if ($keyType->getValue() !== $offsetType->getValue()) {
1381: continue;
1382: }
1383:
1384: $keyTypes = $this->keyTypes;
1385: unset($keyTypes[$i]);
1386: $valueTypes = $this->valueTypes;
1387: unset($valueTypes[$i]);
1388:
1389: $newKeyTypes = [];
1390: $newValueTypes = [];
1391: $newOptionalKeys = [];
1392:
1393: $k = 0;
1394: foreach ($keyTypes as $j => $newKeyType) {
1395: $newKeyTypes[] = $newKeyType;
1396: $newValueTypes[] = $valueTypes[$j];
1397: if (in_array($j, $this->optionalKeys, true)) {
1398: $newOptionalKeys[] = $k;
1399: }
1400: $k++;
1401: }
1402:
1403: $newIsList = self::isListAfterUnset(
1404: $newKeyTypes,
1405: $newOptionalKeys,
1406: $this->isList,
1407: in_array($i, $this->optionalKeys, true),
1408: );
1409: if (!$preserveListCertainty) {
1410: $newIsList = $newIsList->and(TrinaryLogic::createMaybe());
1411: } elseif ($this->isList->yes() && $newIsList->no()) {
1412: return new NeverType();
1413: }
1414:
1415: return $this->recreate($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList, $this->unsealed);
1416: }
1417:
1418: return $this;
1419: }
1420:
1421: $constantScalars = $offsetType->getConstantScalarTypes();
1422: if (count($constantScalars) > 0) {
1423: $optionalKeys = $this->optionalKeys;
1424:
1425: $arrayHasChanged = false;
1426: foreach ($constantScalars as $constantScalar) {
1427: $constantScalar = $constantScalar->toArrayKey();
1428: if (!$constantScalar instanceof ConstantIntegerType && !$constantScalar instanceof ConstantStringType) {
1429: continue;
1430: }
1431:
1432: foreach ($this->keyTypes as $i => $keyType) {
1433: if ($keyType->getValue() !== $constantScalar->getValue()) {
1434: continue;
1435: }
1436:
1437: $arrayHasChanged = true;
1438: if (in_array($i, $optionalKeys, true)) {
1439: continue 2;
1440: }
1441:
1442: $optionalKeys[] = $i;
1443: }
1444: }
1445:
1446: if (!$arrayHasChanged) {
1447: return $this;
1448: }
1449:
1450: $newIsList = self::isListAfterUnset(
1451: $this->keyTypes,
1452: $optionalKeys,
1453: $this->isList,
1454: count($optionalKeys) === count($this->optionalKeys),
1455: );
1456: if (!$preserveListCertainty) {
1457: $newIsList = $newIsList->and(TrinaryLogic::createMaybe());
1458: }
1459:
1460: return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed);
1461: }
1462:
1463: $optionalKeys = $this->optionalKeys;
1464: $arrayHasChanged = false;
1465: foreach ($this->keyTypes as $i => $keyType) {
1466: if (!$offsetType->isSuperTypeOf($keyType)->yes()) {
1467: continue;
1468: }
1469: $arrayHasChanged = true;
1470: $optionalKeys[] = $i;
1471: }
1472: $optionalKeys = array_values(array_unique($optionalKeys));
1473:
1474: if (!$arrayHasChanged) {
1475: return $this;
1476: }
1477:
1478: $newIsList = self::isListAfterUnset(
1479: $this->keyTypes,
1480: $optionalKeys,
1481: $this->isList,
1482: count($optionalKeys) === count($this->optionalKeys),
1483: );
1484: if (!$preserveListCertainty) {
1485: $newIsList = $newIsList->and(TrinaryLogic::createMaybe());
1486: } elseif ($this->isList->yes() && $newIsList->no()) {
1487: return new NeverType();
1488: }
1489:
1490: return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed);
1491: }
1492:
1493: /**
1494: * List-ness of a sealed shape from its keys and optionality: `yes` if every
1495: * realization (choice of present optional keys) is a list, `no` if none is,
1496: * `maybe` otherwise.
1497: *
1498: * @param list<ConstantIntegerType|ConstantStringType> $keyTypes
1499: * @param int[] $optionalKeys
1500: */
1501: private static function inferIsListFromShape(array $keyTypes, array $optionalKeys): TrinaryLogic
1502: {
1503: $optional = [];
1504: foreach ($optionalKeys as $optionalKey) {
1505: $optional[$optionalKey] = true;
1506: }
1507:
1508: // Prefix lengths reachable by realizations that are still a valid list.
1509: $validLengths = [0 => true];
1510: $existsInvalid = false;
1511:
1512: foreach ($keyTypes as $i => $keyType) {
1513: $isOptional = array_key_exists($i, $optional);
1514: // A numeric-string key like "1" is an integer key at runtime, so
1515: // normalize before deciding whether it continues the list.
1516: $arrayKey = $keyType->toArrayKey();
1517: $value = $arrayKey instanceof ConstantIntegerType ? $arrayKey->getValue() : null;
1518:
1519: $newValidLengths = [];
1520: foreach (array_keys($validLengths) as $length) {
1521: if ($isOptional) {
1522: $newValidLengths[$length] = true;
1523: }
1524:
1525: // A key equal to the current length extends the prefix; anything
1526: // else is a non-list realization.
1527: if ($value === $length) {
1528: $newValidLengths[$length + 1] = true;
1529: } else {
1530: $existsInvalid = true;
1531: }
1532: }
1533:
1534: $validLengths = $newValidLengths;
1535: if ($validLengths === []) {
1536: return TrinaryLogic::createNo();
1537: }
1538: }
1539:
1540: return $existsInvalid ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes();
1541: }
1542:
1543: /**
1544: * When we're unsetting something not on the array, it will be untouched,
1545: * So the nextAutoIndexes won't change, and the array might still be a list even with PHPStan definition.
1546: *
1547: * @param list<ConstantIntegerType|ConstantStringType> $newKeyTypes
1548: * @param int[] $newOptionalKeys
1549: */
1550: private static function isListAfterUnset(array $newKeyTypes, array $newOptionalKeys, TrinaryLogic $arrayIsList, bool $unsetOptionalKey): TrinaryLogic
1551: {
1552: if (!$unsetOptionalKey || $arrayIsList->no()) {
1553: return TrinaryLogic::createNo();
1554: }
1555:
1556: $isListOnlyIfKeysAreOptional = false;
1557: foreach ($newKeyTypes as $k2 => $newKeyType2) {
1558: // A numeric-string key like "1" is an integer key at runtime, so
1559: // normalize before deciding whether it continues the list.
1560: $newKeyType2 = $newKeyType2->toArrayKey();
1561: if (!$newKeyType2 instanceof ConstantIntegerType || $newKeyType2->getValue() !== $k2) {
1562: // We found a non-optional key that implies that the array is never a list.
1563: if (!in_array($k2, $newOptionalKeys, true)) {
1564: return TrinaryLogic::createNo();
1565: }
1566:
1567: // The array can still be a list if all the following keys are also optional.
1568: $isListOnlyIfKeysAreOptional = true;
1569: continue;
1570: }
1571:
1572: if ($isListOnlyIfKeysAreOptional && !in_array($k2, $newOptionalKeys, true)) {
1573: return TrinaryLogic::createNo();
1574: }
1575: }
1576:
1577: return $arrayIsList;
1578: }
1579:
1580: public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type
1581: {
1582: // With real unsealed extras, we can't precisely enumerate the
1583: // chunks — the source has an unknown number of extras that
1584: // could form additional partial or full chunks. Fall back to
1585: // the general `list<chunk<sourceValues>>` shape produced by
1586: // the trait, which is correct (just less precise).
1587: if ($this->isUnsealed()->yes()) {
1588: return $this->traitChunkArray($lengthType, $preserveKeys);
1589: }
1590:
1591: $biggerOne = IntegerRangeType::fromInterval(1, null);
1592: $finiteTypes = $lengthType->getFiniteTypes();
1593: if ($biggerOne->isSuperTypeOf($lengthType)->yes() && count($finiteTypes) < self::CHUNK_FINITE_TYPES_LIMIT) {
1594: $results = [];
1595: foreach ($finiteTypes as $finiteType) {
1596: if (!$finiteType instanceof ConstantIntegerType || $finiteType->getValue() < 1) {
1597: return $this->traitChunkArray($lengthType, $preserveKeys);
1598: }
1599:
1600: $length = $finiteType->getValue();
1601:
1602: $builder = ConstantArrayTypeBuilder::createEmpty();
1603:
1604: $keyTypesCount = count($this->keyTypes);
1605: for ($i = 0; $i < $keyTypesCount; $i += $length) {
1606: $chunk = $this->sliceArray(new ConstantIntegerType($i), new ConstantIntegerType($length), TrinaryLogic::createYes());
1607: $builder->setOffsetValueType(null, $preserveKeys->yes() ? $chunk : $chunk->getValuesArray());
1608: }
1609:
1610: $results[] = $builder->getArray();
1611: }
1612:
1613: return TypeCombinator::union(...$results);
1614: }
1615:
1616: return $this->traitChunkArray($lengthType, $preserveKeys);
1617: }
1618:
1619: public function fillKeysArray(Type $valueType): Type
1620: {
1621: $builder = ConstantArrayTypeBuilder::createEmpty();
1622:
1623: foreach ($this->valueTypes as $i => $keyType) {
1624: if ($keyType->isInteger()->no()) {
1625: $stringKeyType = $keyType->toString();
1626: if ($stringKeyType instanceof ErrorType) {
1627: return $stringKeyType;
1628: }
1629:
1630: $builder->setOffsetValueType($stringKeyType, $valueType, $this->isOptionalKey($i) || count($stringKeyType->getConstantScalarTypes()) > 1);
1631: } else {
1632: $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i) || count($keyType->getConstantScalarTypes()) > 1);
1633: }
1634: }
1635:
1636: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
1637: [, $unsealedValue] = $this->unsealed;
1638: $tailKey = $unsealedValue->toArrayKey();
1639: // See flipArray() for the rationale: install the unsealed
1640: // tail only when its key type is non-finite; otherwise let
1641: // setOffsetValueType expand it into optional explicit slots
1642: // (merged with any matching existing keys).
1643: if (count($tailKey->getFiniteTypes()) === 0) {
1644: $builder->makeUnsealed($tailKey, $valueType);
1645: }
1646: $builder->setOffsetValueType($tailKey, $valueType, true);
1647: }
1648:
1649: return $builder->getArray();
1650: }
1651:
1652: public function flipArray(): Type
1653: {
1654: $builder = ConstantArrayTypeBuilder::createEmpty();
1655:
1656: foreach ($this->keyTypes as $i => $keyType) {
1657: $valueType = $this->valueTypes[$i];
1658: $offsetType = $valueType->toArrayKey();
1659: $builder->setOffsetValueType(
1660: $offsetType,
1661: $keyType,
1662: $this->isOptionalKey($i) || count($offsetType->getConstantScalarTypes()) > 1,
1663: );
1664: }
1665:
1666: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
1667: [$unsealedKey, $unsealedValue] = $this->unsealed;
1668: $flippedKey = $unsealedValue->toArrayKey();
1669: $flippedValue = $unsealedKey;
1670: // For a non-finite tail key (e.g. `string`), install the
1671: // unsealed extras first; setOffsetValueType then widens any
1672: // overlapping explicit values with the tail's value type.
1673: // For a finite tail key (e.g. `0|1`), setOffsetValueType
1674: // expands the tail into optional explicit slots that fully
1675: // cover the tail's domain, so no residual unsealed tail is
1676: // needed.
1677: if (count($flippedKey->getFiniteTypes()) === 0) {
1678: $builder->makeUnsealed($flippedKey, $flippedValue);
1679: }
1680: $builder->setOffsetValueType($flippedKey, $flippedValue, true);
1681: }
1682:
1683: return $builder->getArray();
1684: }
1685:
1686: public function intersectKeyArray(Type $otherArraysType): Type
1687: {
1688: $builder = ConstantArrayTypeBuilder::createEmpty();
1689:
1690: foreach ($this->keyTypes as $i => $keyType) {
1691: $valueType = $this->valueTypes[$i];
1692: $has = $otherArraysType->hasOffsetValueType($keyType);
1693: if ($has->no()) {
1694: continue;
1695: }
1696: $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i) || !$has->yes());
1697: }
1698:
1699: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
1700: [$unsealedKey, $unsealedValue] = $this->unsealed;
1701: // An unsealed extra at key K survives only if `$other` can
1702: // also have key K. Narrow the unsealed key to the intersection
1703: // of our extras-range and `$other`'s key type. If they don't
1704: // overlap, the unsealed slot is dropped.
1705: $narrowedKey = TypeCombinator::intersect($unsealedKey, $otherArraysType->getIterableKeyType());
1706: if (!$narrowedKey instanceof NeverType) {
1707: $builder->makeUnsealed($narrowedKey, $unsealedValue);
1708: }
1709: }
1710:
1711: return $builder->getArray();
1712: }
1713:
1714: public function popArray(): Type
1715: {
1716: return $this->removeLastElements(1);
1717: }
1718:
1719: public function reverseArray(TrinaryLogic $preserveKeys): Type
1720: {
1721: $builder = ConstantArrayTypeBuilder::createEmpty();
1722:
1723: for ($i = count($this->keyTypes) - 1; $i >= 0; $i--) {
1724: $offsetType = $preserveKeys->yes() || $this->keyTypes[$i]->isInteger()->no()
1725: ? $this->keyTypes[$i]
1726: : null;
1727: $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $this->isOptionalKey($i));
1728: }
1729:
1730: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
1731: // `array_reverse` only permutes positions; the unsealed slot
1732: // is "zero or more extras at unspecified positions" both
1733: // before and after.
1734: [$unsealedKey, $unsealedValue] = $this->unsealed;
1735: $builder->makeUnsealed($unsealedKey, $unsealedValue);
1736: }
1737:
1738: return $builder->getArray();
1739: }
1740:
1741: public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Type
1742: {
1743: $strict ??= TrinaryLogic::createMaybe();
1744: $matches = [];
1745: $hasIdenticalValue = false;
1746:
1747: foreach ($this->valueTypes as $index => $valueType) {
1748: if ($strict->yes()) {
1749: $isNeedleSuperType = $valueType->isSuperTypeOf($needleType);
1750: if ($isNeedleSuperType->no()) {
1751: continue;
1752: }
1753: }
1754:
1755: if ($needleType instanceof ConstantScalarType && $valueType instanceof ConstantScalarType) {
1756: // @phpstan-ignore equal.notAllowed
1757: $isLooseEqual = $needleType->getValue() == $valueType->getValue(); // phpcs:ignore
1758: if (!$isLooseEqual) {
1759: continue;
1760: }
1761: if (
1762: ($strict->no() || $needleType->getValue() === $valueType->getValue())
1763: && !$this->isOptionalKey($index)
1764: ) {
1765: $hasIdenticalValue = true;
1766: }
1767: }
1768:
1769: $matches[] = $this->keyTypes[$index];
1770: }
1771:
1772: // Unsealed extras can host additional entries beyond the explicit
1773: // keys, so the search may also find the needle there. The unsealed
1774: // extras' presence is uncertain by definition (zero or more
1775: // entries), so they can never make the needle "definitely found"
1776: // (`hasIdenticalValue` stays false) — `false` always remains a
1777: // possible result.
1778: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
1779: [$unsealedKeyType, $unsealedValueType] = $this->unsealed;
1780: $considerUnsealed = true;
1781: if ($strict->yes()) {
1782: $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no();
1783: }
1784: if ($considerUnsealed) {
1785: $matches[] = $unsealedKeyType;
1786: }
1787: }
1788:
1789: if (count($matches) > 0) {
1790: if ($hasIdenticalValue) {
1791: return TypeCombinator::union(...$matches);
1792: }
1793:
1794: return TypeCombinator::union(new ConstantBooleanType(false), ...$matches);
1795: }
1796:
1797: return new ConstantBooleanType(false);
1798: }
1799:
1800: public function shiftArray(): Type
1801: {
1802: return $this->removeFirstElements(1);
1803: }
1804:
1805: public function shuffleArray(): Type
1806: {
1807: return $this->getValuesArray()->degradeToGeneralArray();
1808: }
1809:
1810: public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $preserveKeys): Type
1811: {
1812: $keyTypesCount = count($this->keyTypes);
1813: if ($keyTypesCount === 0) {
1814: return $this;
1815: }
1816:
1817: $offset = $offsetType instanceof ConstantIntegerType ? $offsetType->getValue() : null;
1818:
1819: if ($lengthType instanceof ConstantIntegerType) {
1820: $length = $lengthType->getValue();
1821: } elseif ($lengthType->isNull()->yes()) {
1822: $length = $keyTypesCount;
1823: } else {
1824: $length = null;
1825: }
1826:
1827: if ($offset === null || $length === null) {
1828: return $this->degradeToGeneralArray()
1829: ->sliceArray($offsetType, $lengthType, $preserveKeys);
1830: }
1831:
1832: if ($keyTypesCount + $offset <= 0) {
1833: // A negative offset cannot reach left outside the array twice
1834: $offset = 0;
1835: }
1836:
1837: if ($keyTypesCount + $length <= 0) {
1838: // A negative length cannot reach left outside the array twice
1839: $length = 0;
1840: }
1841:
1842: if ($length === 0 || ($offset < 0 && $length < 0 && $offset - $length >= 0)) {
1843: // 0 / 0, 3 / 0 or e.g. -3 / -3 or -3 / -4 and so on never extract anything
1844: return $this->recreate([], [], [0], [], null, [new NeverType(true), new NeverType(true)]);
1845: }
1846:
1847: if ($length < 0) {
1848: // Negative lengths prevent access to the most right n elements
1849: return $this->removeLastElements($length * -1)
1850: ->sliceArray($offsetType, new NullType(), $preserveKeys);
1851: }
1852:
1853: if ($offset < 0) {
1854: /*
1855: * Transforms the problem with the negative offset in one with a positive offset using array reversion.
1856: * The reason is below handling of optional keys which works only from left to right.
1857: *
1858: * e.g.
1859: * array{a: 0, b: 1, c: 2, d: 3, e: 4}
1860: * with offset -4 and length 2 (which would be sliced to array{b: 1, c: 2})
1861: *
1862: * is transformed via reversion to
1863: *
1864: * array{e: 4, d: 3, c: 2, b: 1, a: 0}
1865: * with offset 2 and length 2 (which will be sliced to array{c: 2, b: 1} and then reversed again)
1866: */
1867: $offset *= -1;
1868: $reversedLength = min($length, $offset);
1869: $reversedOffset = $offset - $reversedLength;
1870: return $this->reverseArray(TrinaryLogic::createYes())
1871: ->sliceArray(new ConstantIntegerType($reversedOffset), new ConstantIntegerType($reversedLength), $preserveKeys)
1872: ->reverseArray(TrinaryLogic::createYes());
1873: }
1874:
1875: if ($offset > 0) {
1876: return $this->removeFirstElements($offset, false)
1877: ->sliceArray(new ConstantIntegerType(0), $lengthType, $preserveKeys);
1878: }
1879:
1880: $builder = ConstantArrayTypeBuilder::createEmpty();
1881:
1882: $nonOptionalElementsCount = 0;
1883: $hasOptional = false;
1884: for ($i = 0; $nonOptionalElementsCount < $length && $i < $keyTypesCount; $i++) {
1885: $isOptional = $this->isOptionalKey($i);
1886: if (!$isOptional) {
1887: $nonOptionalElementsCount++;
1888: } else {
1889: $hasOptional = true;
1890: }
1891:
1892: $isLastElement = $nonOptionalElementsCount >= $length || $i + 1 >= $keyTypesCount;
1893: if ($isLastElement && $length < $keyTypesCount && $hasOptional) {
1894: // If the slice is not full yet, but has at least one optional key
1895: // the last non-optional element is going to be optional.
1896: // Otherwise, it would not fit into the slice if previous non-optional keys are there.
1897: $isOptional = true;
1898: }
1899:
1900: $offsetType = $preserveKeys->yes() || $this->keyTypes[$i]->isInteger()->no()
1901: ? $this->keyTypes[$i]
1902: : null;
1903:
1904: $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $isOptional);
1905: }
1906:
1907: // When the requested length runs past the explicit keys, the
1908: // missing trailing slots could be filled by the source's
1909: // unsealed extras (or be absent). Carry the unsealed slot
1910: // through so the result still describes those potential extras.
1911: if (
1912: $this->isUnsealed()->yes()
1913: && $this->unsealed !== null
1914: && $nonOptionalElementsCount < $length
1915: ) {
1916: [$unsealedKey, $unsealedValue] = $this->unsealed;
1917: $builder->makeUnsealed($unsealedKey, $unsealedValue);
1918: }
1919:
1920: return $builder->getArray();
1921: }
1922:
1923: public function spliceArray(Type $offsetType, Type $lengthType, Type $replacementType): Type
1924: {
1925: $keyTypesCount = count($this->keyTypes);
1926: if ($keyTypesCount === 0) {
1927: return $this;
1928: }
1929:
1930: $offset = $offsetType instanceof ConstantIntegerType ? $offsetType->getValue() : null;
1931:
1932: if ($lengthType instanceof ConstantIntegerType) {
1933: $length = $lengthType->getValue();
1934: } elseif ($lengthType->isNull()->yes()) {
1935: $length = $keyTypesCount;
1936: } else {
1937: $length = null;
1938: }
1939:
1940: if ($offset === null || $length === null) {
1941: return $this->degradeToGeneralArray()
1942: ->spliceArray($offsetType, $lengthType, $replacementType);
1943: }
1944:
1945: $allKeysInteger = $this->getIterableKeyType()->isInteger()->yes();
1946:
1947: if ($keyTypesCount + $offset <= 0) {
1948: // A negative offset cannot reach left outside the array twice
1949: $offset = 0;
1950: }
1951:
1952: if ($keyTypesCount + $length <= 0) {
1953: // A negative length cannot reach left outside the array twice
1954: $length = 0;
1955: }
1956:
1957: $offsetWasNegative = false;
1958: if ($offset < 0) {
1959: $offsetWasNegative = true;
1960: $offset = $keyTypesCount + $offset;
1961: }
1962:
1963: if ($length < 0) {
1964: $length = $keyTypesCount - $offset + $length;
1965: }
1966:
1967: $extractType = $this->sliceArray($offsetType, $lengthType, TrinaryLogic::createYes());
1968:
1969: $types = [];
1970: foreach ($replacementType->toArray()->getArrays() as $replacementArrayType) {
1971: $removeKeysCount = 0;
1972: $optionalKeysBeforeReplacement = 0;
1973:
1974: $builder = ConstantArrayTypeBuilder::createEmpty();
1975: for ($i = 0;; $i++) {
1976: $isOptional = $this->isOptionalKey($i);
1977:
1978: if (!$offsetWasNegative && $i < $offset && $isOptional) {
1979: $optionalKeysBeforeReplacement++;
1980: }
1981:
1982: if ($i === $offset + $optionalKeysBeforeReplacement) {
1983: // When the offset is reached we have to a) put the replacement array in and b) remove $length elements
1984: $removeKeysCount = $length;
1985:
1986: if ($replacementArrayType instanceof self) {
1987: $valuesArray = $replacementArrayType->getValuesArray();
1988: for ($j = 0, $jMax = count($valuesArray->keyTypes); $j < $jMax; $j++) {
1989: $builder->setOffsetValueType(null, $valuesArray->valueTypes[$j], $valuesArray->isOptionalKey($j));
1990: }
1991: } else {
1992: $builder->degradeToGeneralArray();
1993: $builder->setOffsetValueType($replacementArrayType->getValuesArray()->getIterableKeyType(), $replacementArrayType->getIterableValueType(), true);
1994: }
1995: }
1996:
1997: if (!isset($this->keyTypes[$i])) {
1998: break;
1999: }
2000:
2001: if ($removeKeysCount > 0) {
2002: $extractTypeHasOffsetValueType = $extractType->hasOffsetValueType($this->keyTypes[$i]);
2003:
2004: if (
2005: (!$isOptional && $extractTypeHasOffsetValueType->yes())
2006: || ($isOptional && $extractTypeHasOffsetValueType->maybe())
2007: ) {
2008: $removeKeysCount--;
2009: continue;
2010: }
2011: }
2012:
2013: if (!$isOptional && $extractType->hasOffsetValueType($this->keyTypes[$i])->maybe()) {
2014: $isOptional = true;
2015: }
2016:
2017: $builder->setOffsetValueType(
2018: $this->keyTypes[$i]->isInteger()->no() ? $this->keyTypes[$i] : null,
2019: $this->valueTypes[$i],
2020: $isOptional,
2021: );
2022: }
2023:
2024: // `array_splice` removes a slice at an explicit offset and
2025: // inserts a replacement there. Real unsealed extras live at
2026: // positions past the explicit keys, so they're unaffected
2027: // by the operation (re-indexing of int keys keeps the
2028: // `<int, V>` range intact). Carry the slot through.
2029: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
2030: [$unsealedKey, $unsealedValue] = $this->unsealed;
2031: $builder->makeUnsealed($unsealedKey, $unsealedValue);
2032: }
2033:
2034: $builtType = $builder->getArray();
2035: if ($allKeysInteger && !$builtType->isList()->yes()) {
2036: $builtType = TypeCombinator::intersect($builtType, new AccessoryArrayListType());
2037: }
2038: $types[] = $builtType;
2039: }
2040:
2041: return TypeCombinator::union(...$types);
2042: }
2043:
2044: public function truncateListToSize(Type $sizeType): Type
2045: {
2046: [$min, $max] = self::extractTruncateListBounds($sizeType);
2047:
2048: // `getMin() === null` ↔ unbounded below; the narrowing has no anchor
2049: // to start from. Also bail out when the required prefix would exceed
2050: // the array-shape limit — we can't enumerate that many keys.
2051: // `isList()` is intentionally NOT checked here: the call site
2052: // (`TypeSpecifier`) only invokes this when the *outer* aggregate is
2053: // already a list, but a CAT inside a `non-empty-list` intersection
2054: // may have its own `isList()` weakened to `Maybe`.
2055: if (
2056: $min === null
2057: || $min >= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
2058: || !$this->getKeyType()->isSuperTypeOf(IntegerRangeType::fromInterval(0, ($max ?? $min) - 1))->yes()
2059: ) {
2060: return TypeCombinator::intersect($this, new NonEmptyArrayType());
2061: }
2062:
2063: // Required prefix `[0, $min)`: every value definitely present.
2064: $builderData = [];
2065: for ($i = 0; $i < $min; $i++) {
2066: $offsetType = new ConstantIntegerType($i);
2067: $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), false];
2068: }
2069:
2070: if ($max !== null) {
2071: // Optional middle `[$min, $max)`.
2072: if ($max - $min > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
2073: return TypeCombinator::intersect($this, new NonEmptyArrayType());
2074: }
2075: for ($i = $min; $i < $max; $i++) {
2076: $offsetType = new ConstantIntegerType($i);
2077: $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), true];
2078: }
2079: } else {
2080: // Unbounded max: probe explicit keys from `$min` onward until
2081: // `hasOffsetValueType` answers `no`. Each probe contributes one
2082: // optional (or required, when `hasOffsetValueType` is `yes`) slot.
2083: $isUnsealed = $this->isUnsealed()->yes();
2084: for ($i = $min;; $i++) {
2085: $offsetType = new ConstantIntegerType($i);
2086: $hasOffset = $this->hasOffsetValueType($offsetType);
2087: if ($hasOffset->no()) {
2088: break;
2089: }
2090: // Real unsealed extras make `hasOffsetValueType` answer
2091: // `Maybe` for *any* in-range key, so the probe would
2092: // otherwise run until `ARRAY_COUNT_LIMIT` bails (slow +
2093: // lossy). Stop once the explicit keys are exhausted; the
2094: // unsealed slot attached below covers further entries.
2095: if ($isUnsealed && !$hasOffset->yes()) {
2096: break;
2097: }
2098: $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), !$hasOffset->yes()];
2099: }
2100: }
2101:
2102: if (count($builderData) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
2103: return TypeCombinator::intersect($this, new NonEmptyArrayType());
2104: }
2105:
2106: $builder = ConstantArrayTypeBuilder::createEmpty();
2107: foreach ($builderData as [$offsetType, $valueType, $optional]) {
2108: $builder->setOffsetValueType($offsetType, $valueType, $optional);
2109: }
2110:
2111: // Carry the unsealed slot through only for the unbounded-max
2112: // branch — a bounded-max range caps the result size and the
2113: // unsealed extras can't fit.
2114: if ($max === null && $this->isUnsealed()->yes() && $this->unsealed !== null) {
2115: $builder->makeUnsealed($this->unsealed[0], $this->unsealed[1]);
2116: }
2117:
2118: $builtArray = $builder->getArray();
2119: // `setOffsetValueType` on a brand-new builder produces a list when
2120: // the resulting offsets are sequential ints — but it may not preserve
2121: // list-ness in every shape. Reattach it for the single-CAT case.
2122: if (!$builder->isList()) {
2123: $constantArrays = $builtArray->getConstantArrays();
2124: if (count($constantArrays) === 1) {
2125: $builtArray = $constantArrays[0]->makeList();
2126: }
2127: }
2128:
2129: return $builtArray;
2130: }
2131:
2132: /**
2133: * Extracts (min, max) bounds from a size type for `truncateListToSize`.
2134: * `ConstantIntegerType(N)` → `[N, N]`. `IntegerRangeType` →
2135: * `[$min, $max]`. Anything else returns `[null, null]` and the caller
2136: * falls back to the non-precise path.
2137: *
2138: * @return array{?int, ?int}
2139: */
2140: public static function extractTruncateListBounds(Type $sizeType): array
2141: {
2142: if ($sizeType instanceof ConstantIntegerType) {
2143: return [$sizeType->getValue(), $sizeType->getValue()];
2144: }
2145:
2146: if ($sizeType instanceof IntegerRangeType) {
2147: return [$sizeType->getMin(), $sizeType->getMax()];
2148: }
2149:
2150: return [null, null];
2151: }
2152:
2153: public function isIterableAtLeastOnce(): TrinaryLogic
2154: {
2155: $keysCount = count($this->keyTypes);
2156: if ($keysCount === 0) {
2157: if (!$this->isUnsealed()->yes()) {
2158: return TrinaryLogic::createNo();
2159: }
2160: return TrinaryLogic::createMaybe();
2161: }
2162:
2163: $optionalKeysCount = count($this->optionalKeys);
2164: if ($optionalKeysCount < $keysCount) {
2165: return TrinaryLogic::createYes();
2166: }
2167:
2168: return TrinaryLogic::createMaybe();
2169: }
2170:
2171: public function getArraySize(): Type
2172: {
2173: $optionalKeysCount = count($this->optionalKeys);
2174: $totalKeysCount = count($this->getKeyTypes());
2175: if (!$this->isUnsealed()->yes()) {
2176: if ($optionalKeysCount === 0) {
2177: return new ConstantIntegerType($totalKeysCount);
2178: }
2179: $max = $totalKeysCount;
2180: } else {
2181: $max = null;
2182: }
2183:
2184: return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $max);
2185: }
2186:
2187: public function getFirstIterableKeyType(): Type
2188: {
2189: $keyTypes = [];
2190: foreach ($this->keyTypes as $i => $keyType) {
2191: $keyTypes[] = $keyType;
2192: if (!$this->isOptionalKey($i)) {
2193: break;
2194: }
2195: }
2196:
2197: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
2198: $unsealedKeyType = $this->unsealed[0];
2199: if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) {
2200: $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
2201: } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) {
2202: $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
2203: }
2204: $keyTypes[] = $unsealedKeyType;
2205: }
2206:
2207: return TypeCombinator::union(...$keyTypes);
2208: }
2209:
2210: public function getLastIterableKeyType(): Type
2211: {
2212: $keyTypes = [];
2213: for ($i = count($this->keyTypes) - 1; $i >= 0; $i--) {
2214: $keyTypes[] = $this->keyTypes[$i];
2215: if (!$this->isOptionalKey($i)) {
2216: break;
2217: }
2218: }
2219:
2220: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
2221: $unsealedKeyType = $this->unsealed[0];
2222: if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) {
2223: $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
2224: } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) {
2225: $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey();
2226: }
2227: $keyTypes[] = $unsealedKeyType;
2228: }
2229:
2230: return TypeCombinator::union(...$keyTypes);
2231: }
2232:
2233: public function getFirstIterableValueType(): Type
2234: {
2235: $valueTypes = [];
2236: foreach ($this->valueTypes as $i => $valueType) {
2237: $valueTypes[] = $valueType;
2238: if (!$this->isOptionalKey($i)) {
2239: break;
2240: }
2241: }
2242:
2243: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
2244: $valueTypes[] = $this->unsealed[1];
2245: }
2246:
2247: return TypeCombinator::union(...$valueTypes);
2248: }
2249:
2250: public function getLastIterableValueType(): Type
2251: {
2252: $valueTypes = [];
2253: for ($i = count($this->keyTypes) - 1; $i >= 0; $i--) {
2254: $valueTypes[] = $this->valueTypes[$i];
2255: if (!$this->isOptionalKey($i)) {
2256: break;
2257: }
2258: }
2259:
2260: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
2261: $valueTypes[] = $this->unsealed[1];
2262: }
2263:
2264: return TypeCombinator::union(...$valueTypes);
2265: }
2266:
2267: public function isConstantArray(): TrinaryLogic
2268: {
2269: return TrinaryLogic::createYes();
2270: }
2271:
2272: public function isList(): TrinaryLogic
2273: {
2274: return $this->isList;
2275: }
2276:
2277: /** @param positive-int $length */
2278: private function removeLastElements(int $length): self
2279: {
2280: $keyTypesCount = count($this->keyTypes);
2281: if ($keyTypesCount === 0) {
2282: return $this;
2283: }
2284:
2285: // With real unsealed extras on the source, the elements being
2286: // "removed" might come from the unsealed range rather than from
2287: // the trailing explicit keys — the array might have zero extras
2288: // (so the trailing explicit keys are popped) or one+ extras (so
2289: // they're popped instead, leaving the explicit keys intact).
2290: // Encode this by marking the trailing keys as optional and
2291: // keeping the unsealed slot in place.
2292: if ($this->isUnsealed()->yes()) {
2293: $optionalKeys = $this->optionalKeys;
2294: $newLength = $keyTypesCount - $length;
2295: for ($i = $keyTypesCount - 1; $i >= max($newLength, 0); $i--) {
2296: if (in_array($i, $optionalKeys, true)) {
2297: continue;
2298: }
2299: $optionalKeys[] = $i;
2300: }
2301:
2302: return $this->recreate(
2303: $this->keyTypes,
2304: $this->valueTypes,
2305: $this->nextAutoIndexes,
2306: array_values($optionalKeys),
2307: $this->isList,
2308: $this->unsealed,
2309: );
2310: }
2311:
2312: $keyTypes = $this->keyTypes;
2313: $valueTypes = $this->valueTypes;
2314: $optionalKeys = $this->optionalKeys;
2315: $nextAutoindexes = $this->nextAutoIndexes;
2316:
2317: $optionalKeysRemoved = 0;
2318: $newLength = $keyTypesCount - $length;
2319: for ($i = $keyTypesCount - 1; $i >= 0; $i--) {
2320: $isOptional = $this->isOptionalKey($i);
2321:
2322: if ($i >= $newLength) {
2323: if ($isOptional) {
2324: $optionalKeysRemoved++;
2325: foreach ($optionalKeys as $key => $value) {
2326: if ($value === $i) {
2327: unset($optionalKeys[$key]);
2328: break;
2329: }
2330: }
2331: }
2332:
2333: $removedKeyType = array_pop($keyTypes);
2334: array_pop($valueTypes);
2335: $nextAutoindexes = $removedKeyType instanceof ConstantIntegerType
2336: ? [$removedKeyType->getValue()]
2337: : $this->nextAutoIndexes;
2338: continue;
2339: }
2340:
2341: if ($isOptional || $optionalKeysRemoved <= 0) {
2342: continue;
2343: }
2344:
2345: $optionalKeys[] = $i;
2346: $optionalKeysRemoved--;
2347: }
2348:
2349: return $this->recreate(
2350: $keyTypes,
2351: $valueTypes,
2352: $nextAutoindexes,
2353: array_values($optionalKeys),
2354: $this->isList,
2355: $this->unsealed,
2356: );
2357: }
2358:
2359: /** @param positive-int $length */
2360: private function removeFirstElements(int $length, bool $reindex = true): Type
2361: {
2362: $builder = ConstantArrayTypeBuilder::createEmpty();
2363:
2364: $optionalKeysIgnored = 0;
2365: foreach ($this->keyTypes as $i => $keyType) {
2366: $isOptional = $this->isOptionalKey($i);
2367: if ($i <= $length - 1) {
2368: if ($isOptional) {
2369: $optionalKeysIgnored++;
2370: }
2371: continue;
2372: }
2373:
2374: if (!$isOptional && $optionalKeysIgnored > 0) {
2375: $isOptional = true;
2376: $optionalKeysIgnored--;
2377: }
2378:
2379: $valueType = $this->valueTypes[$i];
2380: if ($reindex && $keyType instanceof ConstantIntegerType) {
2381: $keyType = null;
2382: }
2383:
2384: $builder->setOffsetValueType($keyType, $valueType, $isOptional);
2385: }
2386:
2387: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
2388: // `array_shift` removes the *first* element. The explicit
2389: // keys precede the unsealed extras in insertion order, so
2390: // the shift always lands on an explicit key (when there is
2391: // one); the unsealed slot is unaffected. Re-indexing of int
2392: // keys doesn't change the unsealed range — it stays `<int, V>`.
2393: [$unsealedKey, $unsealedValue] = $this->unsealed;
2394: $builder->makeUnsealed($unsealedKey, $unsealedValue);
2395: }
2396:
2397: return $builder->getArray();
2398: }
2399:
2400: public function toBoolean(): BooleanType
2401: {
2402: return $this->getArraySize()->toBoolean();
2403: }
2404:
2405: public function toInteger(): Type
2406: {
2407: return $this->toBoolean()->toInteger();
2408: }
2409:
2410: public function toFloat(): Type
2411: {
2412: return $this->toBoolean()->toFloat();
2413: }
2414:
2415: public function generalize(GeneralizePrecision $precision): Type
2416: {
2417: // No explicit keys and no real extras — actually empty, return as-is.
2418: if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) {
2419: return $this;
2420: }
2421:
2422: if ($precision->isTemplateArgument()) {
2423: return $this->traverse(static fn (Type $type) => $type->generalize($precision));
2424: }
2425:
2426: $arrayType = new ArrayType(
2427: $this->getIterableKeyType()->generalize($precision),
2428: $this->getIterableValueType()->generalize($precision),
2429: );
2430:
2431: $keyTypesCount = count($this->keyTypes);
2432: $optionalKeysCount = count($this->optionalKeys);
2433:
2434: $accessoryTypes = [];
2435: if ($precision->isMoreSpecific() && ($keyTypesCount - $optionalKeysCount) < 32) {
2436: foreach ($this->keyTypes as $i => $keyType) {
2437: if ($this->isOptionalKey($i)) {
2438: continue;
2439: }
2440:
2441: $accessoryTypes[] = new HasOffsetValueType($keyType, $this->valueTypes[$i]->generalize($precision));
2442: }
2443: } elseif ($this->isIterableAtLeastOnce()->yes()) {
2444: // Previously gated on `keyTypesCount > optionalKeysCount`,
2445: // which mishandles "no explicit keys + real unsealed
2446: // extras" (`isIterableAtLeastOnce()` answers `Maybe` —
2447: // extras might be empty — and correctly skips
2448: // `NonEmptyArrayType`). The new gate also covers the
2449: // usual sealed-with-required-keys case, so behaviour for
2450: // existing CAT shapes is unchanged.
2451: $accessoryTypes[] = new NonEmptyArrayType();
2452: }
2453:
2454: if ($this->isList()->yes()) {
2455: $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
2456: }
2457:
2458: if (count($accessoryTypes) > 0) {
2459: return TypeCombinator::intersect($arrayType, ...$accessoryTypes);
2460: }
2461:
2462: return $arrayType;
2463: }
2464:
2465: public function generalizeValues(): self
2466: {
2467: $valueTypes = [];
2468: foreach ($this->valueTypes as $valueType) {
2469: $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific());
2470: }
2471:
2472: $unsealed = $this->unsealed;
2473: if ($unsealed !== null) {
2474: [$unsealedKey, $unsealedValue] = $unsealed;
2475: $unsealed = [$unsealedKey, $unsealedValue->generalize(GeneralizePrecision::lessSpecific())];
2476: }
2477:
2478: return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed);
2479: }
2480:
2481: private function degradeToGeneralArray(): Type
2482: {
2483: $builder = ConstantArrayTypeBuilder::createFromConstantArray($this);
2484: $builder->degradeToGeneralArray();
2485:
2486: return $builder->getArray();
2487: }
2488:
2489: public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type
2490: {
2491: $keysArray = $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null);
2492:
2493: return new IntersectionType([
2494: new ArrayType(
2495: IntegerRangeType::createAllGreaterThanOrEqualTo(0),
2496: $keysArray->getIterableValueType(),
2497: ),
2498: new AccessoryArrayListType(),
2499: ]);
2500: }
2501:
2502: public function getKeysArray(): self
2503: {
2504: return $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null);
2505: }
2506:
2507: public function getValuesArray(): self
2508: {
2509: return $this->getKeysOrValuesArray($this->valueTypes, $this->unsealed[1] ?? null);
2510: }
2511:
2512: /**
2513: * @param array<int, Type> $types
2514: */
2515: private function getKeysOrValuesArray(array $types, ?Type $unsealedSourceType): self
2516: {
2517: $count = count($types);
2518: $autoIndexes = range($count - count($this->optionalKeys), $count);
2519:
2520: // The result is always a list — the source's keys/values are
2521: // numbered sequentially. The new unsealed slot (if the source
2522: // has real extras) describes "zero or more extras at int
2523: // positions >= 0 whose values are the source's unsealed
2524: // key/value type". `int<0, max>` is the conventional unsealed
2525: // key for list-shaped extras; it also enables the short-form
2526: // `<value>` describe.
2527: $resultUnsealed = null;
2528: if ($this->isUnsealed()->yes() && $unsealedSourceType !== null) {
2529: $resultUnsealed = [IntegerRangeType::createAllGreaterThanOrEqualTo(0), $unsealedSourceType];
2530: }
2531:
2532: if ($this->isList->yes()) {
2533: // Optimized version for lists: Assume that if a later key exists, then earlier keys also exist.
2534: $keyTypes = array_map(
2535: static fn (int $i): ConstantIntegerType => new ConstantIntegerType($i),
2536: array_keys($types),
2537: );
2538: return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $resultUnsealed);
2539: }
2540:
2541: $keyTypes = [];
2542: $valueTypes = [];
2543: $optionalKeys = [];
2544: $maxIndex = 0;
2545:
2546: foreach ($types as $i => $type) {
2547: $keyTypes[] = new ConstantIntegerType($i);
2548:
2549: if ($this->isOptionalKey($maxIndex)) {
2550: // move $maxIndex to next non-optional key
2551: do {
2552: $maxIndex++;
2553: } while ($maxIndex < $count && $this->isOptionalKey($maxIndex));
2554: }
2555:
2556: if ($i === $maxIndex) {
2557: $valueTypes[] = $type;
2558: } else {
2559: $valueTypes[] = TypeCombinator::union(...array_slice($types, $i, $maxIndex - $i + 1));
2560: if ($maxIndex >= $count) {
2561: $optionalKeys[] = $i;
2562: }
2563: }
2564: $maxIndex++;
2565: }
2566:
2567: return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $resultUnsealed);
2568: }
2569:
2570: public function describe(VerbosityLevel $level): string
2571: {
2572: $arrayName = $this->shouldBeDescribedAsAList() ? 'list' : 'array';
2573:
2574: $describeValue = function (bool $truncate) use ($level, $arrayName): string {
2575: $items = [];
2576: $values = [];
2577: $exportValuesOnly = true;
2578: foreach ($this->keyTypes as $i => $keyType) {
2579: $valueType = $this->valueTypes[$i];
2580: if ($keyType->getValue() !== $i) {
2581: $exportValuesOnly = false;
2582: }
2583:
2584: $isOptional = $this->isOptionalKey($i);
2585: if ($isOptional) {
2586: $exportValuesOnly = false;
2587: }
2588:
2589: $keyDescription = $keyType->getValue();
2590: if (is_string($keyDescription)) {
2591: if (str_contains($keyDescription, '"')) {
2592: $keyDescription = sprintf('\'%s\'', $keyDescription);
2593: } elseif (str_contains($keyDescription, '\'')) {
2594: $keyDescription = sprintf('"%s"', $keyDescription);
2595: } elseif (!self::isValidIdentifier($keyDescription)) {
2596: $keyDescription = sprintf('\'%s\'', $keyDescription);
2597: }
2598: }
2599:
2600: $valueTypeDescription = $valueType->describe($level);
2601: $items[] = sprintf('%s%s: %s', $keyDescription, $isOptional ? '?' : '', $valueTypeDescription);
2602: $values[] = $valueTypeDescription;
2603: }
2604:
2605: $append = '';
2606: if ($truncate && count($items) > self::DESCRIBE_LIMIT) {
2607: $items = array_slice($items, 0, self::DESCRIBE_LIMIT);
2608: $values = array_slice($values, 0, self::DESCRIBE_LIMIT);
2609: $append = ', ...';
2610: }
2611:
2612: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
2613: if (count($items) > 0) {
2614: $append .= ', ';
2615: }
2616: $append .= '...';
2617: $keyDescription = $this->unsealed[0]->describe(VerbosityLevel::precise());
2618: $isMixedKeyType = $this->unsealed[0] instanceof MixedType && $keyDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed();
2619: $isMixedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed();
2620: if ($isMixedKeyType || ($this->isList()->yes() && $keyDescription === 'int<0, max>')) {
2621: if (!$isMixedItemType) {
2622: $append .= sprintf('<%s>', $this->unsealed[1]->describe($level));
2623: }
2624: } else {
2625: $append .= sprintf('<%s, %s>', $this->unsealed[0]->describe($level), $this->unsealed[1]->describe($level));
2626: }
2627: }
2628:
2629: return sprintf(
2630: '%s{%s%s}',
2631: $arrayName,
2632: implode(', ', $exportValuesOnly ? $values : $items),
2633: $append,
2634: );
2635: };
2636: return $level->handle(
2637: function () use ($arrayName, $level): string {
2638: if ($this->isIterableAtLeastOnce()->no()) {
2639: return $arrayName;
2640: }
2641: $keyType = $this->getIterableKeyType();
2642: // Only a BenevolentUnionType describes with the surrounding parentheses of
2643: // '(int|string)' / '(int|non-decimal-int-string)', so skip the describe()
2644: // call for every other key type.
2645: if ($keyType instanceof BenevolentUnionType && in_array($keyType->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) {
2646: return sprintf('%s<%s>', $arrayName, $this->getIterableValueType()->describe($level));
2647: }
2648: return sprintf('%s<%s, %s>', $arrayName, $keyType->describe($level), $this->getIterableValueType()->describe($level));
2649: },
2650: static fn (): string => $describeValue(true),
2651: static fn (): string => $describeValue(false),
2652: );
2653: }
2654:
2655: private function shouldBeDescribedAsAList(): bool
2656: {
2657: if (!$this->isList->yes()) {
2658: return false;
2659: }
2660:
2661: if (count($this->optionalKeys) === 0) {
2662: return false;
2663: }
2664:
2665: if (count($this->optionalKeys) > 1) {
2666: return true;
2667: }
2668:
2669: return $this->optionalKeys[0] !== count($this->keyTypes) - 1;
2670: }
2671:
2672: public function inferTemplateTypes(Type $receivedType): TemplateTypeMap
2673: {
2674: if ($receivedType instanceof UnionType || $receivedType instanceof IntersectionType) {
2675: return $receivedType->inferTemplateTypesOn($this);
2676: }
2677:
2678: if ($receivedType instanceof self) {
2679: $typeMap = TemplateTypeMap::createEmpty();
2680: foreach ($this->keyTypes as $i => $keyType) {
2681: $valueType = $this->valueTypes[$i];
2682: if ($receivedType->hasOffsetValueType($keyType)->no()) {
2683: continue;
2684: }
2685: $receivedValueType = $receivedType->getOffsetValueType($keyType);
2686: $typeMap = $typeMap->union($valueType->inferTemplateTypes($receivedValueType));
2687: }
2688:
2689: $unsealed = $this->getUnsealedTypes();
2690: if ($unsealed !== null) {
2691: [$unsealedKeyType, $unsealedValueType] = $unsealed;
2692:
2693: // Received's explicit keys not in $this's explicit keys are
2694: // candidates for matching $this's unsealed extras pattern.
2695: // Only contribute when the key type matches; mismatched explicit
2696: // keys are extra entries the parameter wouldn't accept anyway,
2697: // surfaced by the regular argument-type check.
2698: $receivedKeyTypes = $receivedType->getKeyTypes();
2699: $receivedValueTypes = $receivedType->getValueTypes();
2700: foreach ($receivedKeyTypes as $j => $receivedKeyType) {
2701: if ($this->hasOffsetValueType($receivedKeyType)->yes()) {
2702: continue;
2703: }
2704: if (!$unsealedKeyType->isSuperTypeOf($receivedKeyType)->yes()) {
2705: continue;
2706: }
2707: $typeMap = $typeMap->union($unsealedKeyType->inferTemplateTypes($receivedKeyType));
2708: $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes($receivedValueTypes[$j]));
2709: }
2710:
2711: // Received's own unsealed extras describe "all the rest" — when
2712: // the key type doesn't fit $this's unsealed key pattern there
2713: // is no valid template assignment, so force NEVER.
2714: $receivedUnsealed = $receivedType->getUnsealedTypes();
2715: if ($receivedUnsealed !== null) {
2716: [$receivedUnsealedKey, $receivedUnsealedValue] = $receivedUnsealed;
2717: if ($unsealedKeyType->isSuperTypeOf($receivedUnsealedKey)->no()) {
2718: $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes(new NeverType()));
2719: } else {
2720: $typeMap = $typeMap->union($unsealedKeyType->inferTemplateTypes($receivedUnsealedKey));
2721: $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes($receivedUnsealedValue));
2722: }
2723: }
2724: }
2725:
2726: return $typeMap;
2727: }
2728:
2729: if ($receivedType->isArray()->yes()) {
2730: $keyTypeMap = $this->getIterableKeyType()->inferTemplateTypes($receivedType->getIterableKeyType());
2731: $itemTypeMap = $this->getIterableValueType()->inferTemplateTypes($receivedType->getIterableValueType());
2732:
2733: return $keyTypeMap->union($itemTypeMap);
2734: }
2735:
2736: return TemplateTypeMap::createEmpty();
2737: }
2738:
2739: public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array
2740: {
2741: $variance = $positionVariance->compose(TemplateTypeVariance::createCovariant());
2742: $references = [];
2743:
2744: foreach ($this->keyTypes as $type) {
2745: foreach ($type->getReferencedTemplateTypes($variance) as $reference) {
2746: $references[] = $reference;
2747: }
2748: }
2749:
2750: foreach ($this->valueTypes as $type) {
2751: foreach ($type->getReferencedTemplateTypes($variance) as $reference) {
2752: $references[] = $reference;
2753: }
2754: }
2755:
2756: if ($this->unsealed !== null) {
2757: [$unsealedKeyType, $unsealedValueType] = $this->unsealed;
2758: foreach ($unsealedKeyType->getReferencedTemplateTypes($variance) as $reference) {
2759: $references[] = $reference;
2760: }
2761: foreach ($unsealedValueType->getReferencedTemplateTypes($variance) as $reference) {
2762: $references[] = $reference;
2763: }
2764: }
2765:
2766: return $references;
2767: }
2768:
2769: public function tryRemove(Type $typeToRemove): ?Type
2770: {
2771: if ($typeToRemove->isConstantArray()->yes() && $typeToRemove->isIterableAtLeastOnce()->no()) {
2772: return TypeCombinator::intersect($this, new NonEmptyArrayType());
2773: }
2774:
2775: if ($typeToRemove instanceof NonEmptyArrayType) {
2776: return new ConstantArrayType([], []);
2777: }
2778:
2779: if ($typeToRemove instanceof HasOffsetValueType) {
2780: $offsetType = $typeToRemove->getOffsetType();
2781: $valueTypeToRemove = $typeToRemove->getValueType();
2782:
2783: foreach ($this->keyTypes as $i => $keyType) {
2784: if ($keyType->getValue() !== $offsetType->getValue()) {
2785: continue;
2786: }
2787:
2788: $currentValueType = $this->valueTypes[$i];
2789: $valueIsSuperType = $valueTypeToRemove->isSuperTypeOf($currentValueType);
2790:
2791: if ($valueIsSuperType->no()) {
2792: return null;
2793: }
2794:
2795: if ($valueIsSuperType->yes()) {
2796: $unsetResult = $this->unsetOffset($offsetType, true);
2797: // When the source was definitely a list but the post-unset shape
2798: // definitely isn't (e.g. unsetting a non-optional leading key
2799: // creates a hole), no value of $this could have lacked the
2800: // removed key — the subtraction yields the empty set.
2801: if ($this->isList->yes() && $unsetResult->isList()->no()) {
2802: return new NeverType();
2803: }
2804: return $unsetResult;
2805: }
2806:
2807: $newValueType = TypeCombinator::remove($currentValueType, $valueTypeToRemove);
2808: $valueTypes = $this->valueTypes;
2809: $valueTypes[$i] = $newValueType;
2810:
2811: return $this->recreate(
2812: $this->keyTypes,
2813: $valueTypes,
2814: $this->nextAutoIndexes,
2815: $this->optionalKeys,
2816: $this->isList,
2817: $this->unsealed,
2818: );
2819: }
2820:
2821: return null;
2822: }
2823:
2824: if ($typeToRemove instanceof HasOffsetType) {
2825: $unsetResult = $this->unsetOffset($typeToRemove->getOffsetType(), true);
2826: // When the source was definitely a list but the post-unset shape
2827: // definitely isn't (e.g. unsetting a non-optional leading key
2828: // creates a hole), no value of $this could have lacked the
2829: // removed key — the subtraction yields the empty set.
2830: if ($this->isList->yes() && $unsetResult->isList()->no()) {
2831: return new NeverType();
2832: }
2833: return $unsetResult;
2834: }
2835:
2836: return null;
2837: }
2838:
2839: public function traverse(callable $cb): Type
2840: {
2841: $valueTypes = [];
2842:
2843: $stillOriginal = true;
2844: foreach ($this->valueTypes as $valueType) {
2845: $transformedValueType = $cb($valueType);
2846: if ($transformedValueType !== $valueType) {
2847: $stillOriginal = false;
2848: }
2849:
2850: $valueTypes[] = $transformedValueType;
2851: }
2852:
2853: $unsealed = $this->unsealed;
2854: if ($unsealed !== null) {
2855: [$unsealedKeyType, $unsealedValueType] = $unsealed;
2856: $transformedUnsealedKeyType = $cb($unsealedKeyType);
2857: $transformedUnsealedValueType = $cb($unsealedValueType);
2858: if ($transformedUnsealedKeyType !== $unsealedKeyType || $transformedUnsealedValueType !== $unsealedValueType) {
2859: $stillOriginal = false;
2860: $unsealed = [$transformedUnsealedKeyType, $transformedUnsealedValueType];
2861: }
2862: }
2863:
2864: if ($stillOriginal) {
2865: return $this;
2866: }
2867:
2868: return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed);
2869: }
2870:
2871: public function traverseSimultaneously(Type $right, callable $cb): Type
2872: {
2873: if (!$right->isArray()->yes()) {
2874: return $this;
2875: }
2876:
2877: $valueTypes = [];
2878:
2879: $stillOriginal = true;
2880: foreach ($this->valueTypes as $i => $valueType) {
2881: $keyType = $this->keyTypes[$i];
2882: $transformedValueType = $cb($valueType, $right->getOffsetValueType($keyType));
2883: if ($transformedValueType !== $valueType) {
2884: $stillOriginal = false;
2885: }
2886:
2887: $valueTypes[] = $transformedValueType;
2888: }
2889:
2890: $unsealed = $this->unsealed;
2891: if ($unsealed !== null) {
2892: [$unsealedKeyType, $unsealedValueType] = $unsealed;
2893: $transformedUnsealedKeyType = $cb($unsealedKeyType, $right->getIterableKeyType());
2894: $transformedUnsealedValueType = $cb($unsealedValueType, $right->getIterableValueType());
2895: if ($transformedUnsealedKeyType !== $unsealedKeyType || $transformedUnsealedValueType !== $unsealedValueType) {
2896: $stillOriginal = false;
2897: $unsealed = [$transformedUnsealedKeyType, $transformedUnsealedValueType];
2898: }
2899: }
2900:
2901: if ($stillOriginal) {
2902: return $this;
2903: }
2904:
2905: return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed);
2906: }
2907:
2908: public function isKeysSupersetOf(self $otherArray): bool
2909: {
2910: if ($this->unsealed === null || $otherArray->unsealed === null) {
2911: return $this->legacyIsKeysSupersetOf($otherArray);
2912: }
2913:
2914: [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed;
2915: [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed;
2916: $thisHasExtras = $this->isUnsealed()->yes();
2917: $otherHasExtras = $otherArray->isUnsealed()->yes();
2918:
2919: $otherHasRequiredKeys = false;
2920: foreach (array_keys($otherArray->keyTypes) as $j) {
2921: if ($otherArray->isOptionalKey($j)) {
2922: continue;
2923: }
2924: $otherHasRequiredKeys = true;
2925: break;
2926: }
2927:
2928: // Sealed empty $other (no keys, no extras): absorbing it is lossless iff $this
2929: // already accepts []. i.e., all of $this's known keys are optional. Otherwise
2930: // merge would add [] as a new instance.
2931: if (!$otherHasRequiredKeys && !$otherHasExtras && count($otherArray->keyTypes) === 0) {
2932: foreach (array_keys($this->keyTypes) as $i) {
2933: if (!$this->isOptionalKey($i)) {
2934: return false;
2935: }
2936: }
2937: return true;
2938: }
2939:
2940: // With real unsealed extras on both sides that can absorb each other's
2941: // required keys, merging is acceptable regardless of which keys overlap.
2942: if ($thisHasExtras && $otherHasExtras) {
2943: return true;
2944: }
2945:
2946: // Asymmetric extras: one side has real extras that can absorb the other's keys.
2947: if ($thisHasExtras) {
2948: if ($this->legacyIsKeysSupersetOf($otherArray)) {
2949: return true;
2950: }
2951: foreach ($otherArray->keyTypes as $j => $keyType) {
2952: if ($otherArray->isOptionalKey($j)) {
2953: continue;
2954: }
2955: if ($thisUnsealedKey->isSuperTypeOf($keyType)->no()) {
2956: return false;
2957: }
2958: if ($thisUnsealedValue->isSuperTypeOf($otherArray->valueTypes[$j])->no()) {
2959: return false;
2960: }
2961: }
2962: return true;
2963: }
2964:
2965: if ($otherHasExtras) {
2966: if ($this->legacyIsKeysSupersetOf($otherArray)) {
2967: return true;
2968: }
2969: foreach ($this->keyTypes as $i => $keyType) {
2970: if ($this->isOptionalKey($i)) {
2971: continue;
2972: }
2973: if ($otherUnsealedKey->isSuperTypeOf($keyType)->no()) {
2974: return false;
2975: }
2976: if ($otherUnsealedValue->isSuperTypeOf($this->valueTypes[$i])->no()) {
2977: return false;
2978: }
2979: }
2980: return true;
2981: }
2982:
2983: // Both sealed: fall back to the legacy key/value shape check.
2984: return $this->legacyIsKeysSupersetOf($otherArray);
2985: }
2986:
2987: private function legacyIsKeysSupersetOf(self $otherArray): bool
2988: {
2989: $keyTypesCount = count($this->keyTypes);
2990: $otherKeyTypesCount = count($otherArray->keyTypes);
2991:
2992: if ($keyTypesCount < $otherKeyTypesCount) {
2993: return false;
2994: }
2995:
2996: if ($otherKeyTypesCount === 0) {
2997: return $keyTypesCount === 0;
2998: }
2999:
3000: $failOnDifferentValueType = $keyTypesCount !== $otherKeyTypesCount || $keyTypesCount < 2;
3001:
3002: $keyIndexMap = $this->getKeyIndexMap();
3003: $otherKeyValues = [];
3004:
3005: foreach ($otherArray->keyTypes as $j => $keyType) {
3006: $keyValue = $keyType->getValue();
3007: $i = $keyIndexMap[$keyValue] ?? null;
3008: if ($i === null) {
3009: return false;
3010: }
3011:
3012: $otherKeyValues[$keyValue] = true;
3013:
3014: $valueType = $this->valueTypes[$i];
3015: $otherValueType = $otherArray->valueTypes[$j];
3016: if (!$otherValueType->isSuperTypeOf($valueType)->no()) {
3017: continue;
3018: }
3019:
3020: if ($failOnDifferentValueType) {
3021: return false;
3022: }
3023: $failOnDifferentValueType = true;
3024: }
3025:
3026: $requiredKeyCount = 0;
3027: foreach ($this->keyTypes as $i => $keyType) {
3028: if (isset($otherKeyValues[$keyType->getValue()])) {
3029: continue;
3030: }
3031: if ($this->isOptionalKey($i)) {
3032: continue;
3033: }
3034:
3035: $requiredKeyCount++;
3036: if ($requiredKeyCount > 1) {
3037: return false;
3038: }
3039: }
3040:
3041: return true;
3042: }
3043:
3044: public function mergeWith(self $otherArray): self
3045: {
3046: // only call this after verifying isKeysSupersetOf, or if losing tagged unions is not an issue
3047: if ($this->unsealed === null || $otherArray->unsealed === null) {
3048: return $this->legacyMergeWith($otherArray);
3049: }
3050:
3051: [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed;
3052: [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed;
3053:
3054: $mergedUnsealedKey = TypeCombinator::union($thisUnsealedKey, $otherUnsealedKey);
3055: $mergedUnsealedValue = TypeCombinator::union($thisUnsealedValue, $otherUnsealedValue);
3056:
3057: $absorbIntoExtras = static function (Type $keyType, Type $valueType) use (&$mergedUnsealedKey, &$mergedUnsealedValue): void {
3058: $mergedUnsealedKey = TypeCombinator::union($mergedUnsealedKey, $keyType);
3059: $mergedUnsealedValue = TypeCombinator::union($mergedUnsealedValue, $valueType);
3060: };
3061:
3062: $canAbsorb = static function (self $side, Type $keyType, Type $valueType): bool {
3063: if (!$side->isUnsealed()->yes()) {
3064: return false;
3065: }
3066: if ($side->unsealed === null) {
3067: return false;
3068: }
3069: [$sideUnsealedKey, $sideUnsealedValue] = $side->unsealed;
3070: if ($sideUnsealedKey->isSuperTypeOf($keyType)->no()) {
3071: return false;
3072: }
3073: if ($sideUnsealedValue->isSuperTypeOf($valueType)->no()) {
3074: return false;
3075: }
3076: return true;
3077: };
3078:
3079: $keyTypes = [];
3080: $valueTypes = [];
3081: $optionalKeys = [];
3082:
3083: $otherKeyIndexMap = $otherArray->getKeyIndexMap();
3084: $processed = [];
3085:
3086: foreach ($this->keyTypes as $i => $keyType) {
3087: $keyValue = $keyType->getValue();
3088: $processed[$keyValue] = true;
3089: $valueType = $this->valueTypes[$i];
3090:
3091: if (array_key_exists($keyValue, $otherKeyIndexMap)) {
3092: $j = $otherKeyIndexMap[$keyValue];
3093: $otherValueType = $otherArray->valueTypes[$j];
3094: $mergedValue = TypeCombinator::union($valueType, $otherValueType);
3095: $optional = $this->isOptionalKey($i) || $otherArray->isOptionalKey($j);
3096:
3097: $keyTypes[] = $keyType;
3098: $valueTypes[] = $mergedValue;
3099: if ($optional) {
3100: $optionalKeys[] = count($keyTypes) - 1;
3101: }
3102: continue;
3103: }
3104:
3105: if ($canAbsorb($otherArray, $keyType, $valueType)) {
3106: $absorbIntoExtras($keyType, $valueType);
3107: continue;
3108: }
3109:
3110: $keyTypes[] = $keyType;
3111: $valueTypes[] = $valueType;
3112: $optionalKeys[] = count($keyTypes) - 1;
3113: }
3114:
3115: foreach ($otherArray->keyTypes as $j => $keyType) {
3116: $keyValue = $keyType->getValue();
3117: if (array_key_exists($keyValue, $processed)) {
3118: continue;
3119: }
3120: $valueType = $otherArray->valueTypes[$j];
3121:
3122: if ($canAbsorb($this, $keyType, $valueType)) {
3123: $absorbIntoExtras($keyType, $valueType);
3124: continue;
3125: }
3126:
3127: $keyTypes[] = $keyType;
3128: $valueTypes[] = $valueType;
3129: $optionalKeys[] = count($keyTypes) - 1;
3130: }
3131:
3132: $resultUnsealed = [$mergedUnsealedKey, $mergedUnsealedValue];
3133:
3134: $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes)));
3135: sort($nextAutoIndexes);
3136:
3137: $optionalKeys = array_values(array_unique($optionalKeys));
3138:
3139: /** @var list<ConstantIntegerType|ConstantStringType> $keyTypes */
3140: $keyTypes = $keyTypes;
3141:
3142: // Merging widens single-side keys to optional, so a sealed result may gain
3143: // list realizations (e.g. `[]`) the naive `and` misses. `or`-ing in the
3144: // shape's own list-ness lifts a `no`/`maybe`. The `or` also keeps a genuine
3145: // `yes`: widening forgets which keys can appear together, so the shape read
3146: // on its own counts realizations neither input admits and answers `maybe`
3147: // where two list inputs guarantee `yes`.
3148: $naiveIsList = $this->isList->and($otherArray->isList);
3149: $mergedIsSealed = $mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit();
3150: $isList = $mergedIsSealed
3151: ? $naiveIsList->or(self::inferIsListFromShape($keyTypes, $optionalKeys))
3152: : $naiveIsList;
3153:
3154: return $this->recreate(
3155: $keyTypes,
3156: $valueTypes,
3157: $nextAutoIndexes,
3158: $optionalKeys,
3159: $isList,
3160: $resultUnsealed,
3161: );
3162: }
3163:
3164: private function legacyMergeWith(self $otherArray): self
3165: {
3166: $valueTypes = $this->valueTypes;
3167: $optionalKeys = $this->optionalKeys;
3168: foreach ($this->keyTypes as $i => $keyType) {
3169: $otherIndex = $otherArray->getKeyIndex($keyType);
3170: if ($otherIndex === null) {
3171: $optionalKeys[] = $i;
3172: continue;
3173: }
3174: if ($otherArray->isOptionalKey($otherIndex)) {
3175: $optionalKeys[] = $i;
3176: }
3177: $otherValueType = $otherArray->valueTypes[$otherIndex];
3178: $valueTypes[$i] = TypeCombinator::union($valueTypes[$i], $otherValueType);
3179: }
3180:
3181: $optionalKeys = array_values(array_unique($optionalKeys));
3182:
3183: $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes)));
3184: sort($nextAutoIndexes);
3185:
3186: // Same recompute as mergeWith(), over `$this`'s keys only — this legacy
3187: // path drops the other side's extra keys.
3188: $naiveIsList = $this->isList->and($otherArray->isList);
3189: $mergedIsSealed = $this->unsealed === null
3190: || ($this->unsealed[0] instanceof NeverType && $this->unsealed[0]->isExplicit());
3191: $isList = $mergedIsSealed
3192: ? $naiveIsList->or(self::inferIsListFromShape($this->keyTypes, $optionalKeys))
3193: : $naiveIsList;
3194:
3195: return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $this->unsealed);
3196: }
3197:
3198: /**
3199: * @return array<int|string, int>
3200: */
3201: private function getKeyIndexMap(): array
3202: {
3203: if ($this->keyIndexMap !== null) {
3204: return $this->keyIndexMap;
3205: }
3206:
3207: $map = [];
3208: foreach ($this->keyTypes as $i => $keyType) {
3209: $map[$keyType->getValue()] = $i;
3210: }
3211:
3212: return $this->keyIndexMap = $map;
3213: }
3214:
3215: /**
3216: * Index of the explicit key a constant scalar offset (already passed through
3217: * toArrayKey()) resolves to, or null when the offset is not a constant scalar
3218: * or no explicit key is a supertype of it.
3219: */
3220: private function findVerifiedKeyIndex(Type $offsetType): ?int
3221: {
3222: if (!$offsetType->isConstantScalarValue()->yes()) {
3223: return null;
3224: }
3225:
3226: $value = $offsetType->getConstantScalarValues()[0] ?? null;
3227: if (!is_int($value) && !is_string($value)) {
3228: return null;
3229: }
3230:
3231: $index = $this->getKeyIndexMap()[$value] ?? null;
3232: if ($index === null || !$this->keyTypes[$index]->isSuperTypeOf($offsetType)->yes()) {
3233: return null;
3234: }
3235:
3236: return $index;
3237: }
3238:
3239: /**
3240: * @param ConstantIntegerType|ConstantStringType $otherKeyType
3241: */
3242: private function getKeyIndex($otherKeyType): ?int
3243: {
3244: return $this->getKeyIndexMap()[$otherKeyType->getValue()] ?? null;
3245: }
3246:
3247: public function makeOffsetRequired(Type $offsetType): self
3248: {
3249: $offsetType = $offsetType->toArrayKey();
3250: $optionalKeys = $this->optionalKeys;
3251: $isList = $this->isList->yes();
3252: foreach ($this->keyTypes as $i => $keyType) {
3253: if (!$keyType->equals($offsetType)) {
3254: continue;
3255: }
3256:
3257: $keyValue = $keyType->getValue();
3258: foreach ($optionalKeys as $j => $key) {
3259: if (
3260: $i !== $key
3261: && (
3262: !$isList
3263: || !is_int($keyValue)
3264: || !is_int($this->keyTypes[$key]->getValue())
3265: || $this->keyTypes[$key]->getValue() >= $keyValue
3266: )
3267: ) {
3268: continue;
3269: }
3270:
3271: unset($optionalKeys[$j]);
3272: }
3273:
3274: if (count($this->optionalKeys) !== count($optionalKeys)) {
3275: return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList, $this->unsealed);
3276: }
3277:
3278: return $this;
3279: }
3280:
3281: // Offset isn't in the explicit set. If the unsealed extras' key range
3282: // covers it (e.g. `array{a: int, ...<string, float>}` narrowing on
3283: // `array_key_exists('b', $arr)`), promote it into the explicit set as
3284: // a required slot with the unsealed value type. The unsealed extras
3285: // stay around — additional entries at other matching keys are still
3286: // possible.
3287: if (
3288: $this->isUnsealed()->yes()
3289: && $this->unsealed !== null
3290: && ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType)
3291: ) {
3292: [$unsealedKeyType, $unsealedValueType] = $this->unsealed;
3293: if (!$unsealedKeyType->isSuperTypeOf($offsetType)->no()) {
3294: $keyTypes = $this->keyTypes;
3295: $valueTypes = $this->valueTypes;
3296: $keyTypes[] = $offsetType;
3297: $valueTypes[] = $unsealedValueType;
3298:
3299: return $this->recreate(
3300: $keyTypes,
3301: $valueTypes,
3302: $this->nextAutoIndexes,
3303: $this->optionalKeys,
3304: TrinaryLogic::createNo(),
3305: $this->unsealed,
3306: );
3307: }
3308: }
3309:
3310: return $this;
3311: }
3312:
3313: public function makeList(): Type
3314: {
3315: if ($this->isList->yes()) {
3316: return $this;
3317: }
3318:
3319: if ($this->isList->no()) {
3320: return new NeverType();
3321: }
3322:
3323: // isList is Maybe. In a sealed shape a key past a gap in the 0..n sequence
3324: // (or any non-integer key) can never appear in a list, so keep only the
3325: // contiguous 0..m prefix. Unsealed extras may fill the gaps, so keep every
3326: // key there.
3327: if ($this->isUnsealed()->no()) {
3328: $positionByIndex = [];
3329: foreach ($this->keyTypes as $position => $keyType) {
3330: if (!$keyType instanceof ConstantIntegerType) {
3331: continue;
3332: }
3333: $positionByIndex[$keyType->getValue()] = $position;
3334: }
3335:
3336: $keptPositions = [];
3337: for ($index = 0; array_key_exists($index, $positionByIndex); $index++) {
3338: $keptPositions[] = $positionByIndex[$index];
3339: }
3340:
3341: if (count($keptPositions) < count($this->keyTypes)) {
3342: $builder = ConstantArrayTypeBuilder::createEmpty();
3343: foreach ($keptPositions as $position) {
3344: $builder->setOffsetValueType(
3345: $this->keyTypes[$position],
3346: $this->valueTypes[$position],
3347: $this->isOptionalKey($position),
3348: );
3349: }
3350:
3351: return $builder->getArray();
3352: }
3353: }
3354:
3355: return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed);
3356: }
3357:
3358: public function makeListMaybe(): Type
3359: {
3360: if (!$this->isList->yes()) {
3361: return $this;
3362: }
3363:
3364: return $this->recreate(
3365: $this->keyTypes,
3366: $this->valueTypes,
3367: $this->nextAutoIndexes,
3368: $this->optionalKeys,
3369: TrinaryLogic::createMaybe(),
3370: $this->unsealed,
3371: );
3372: }
3373:
3374: public function mapValueType(callable $cb): Type
3375: {
3376: $newValueTypes = [];
3377: foreach ($this->valueTypes as $valueType) {
3378: $newValueTypes[] = $cb($valueType);
3379: }
3380:
3381: $newUnsealed = $this->unsealed === null
3382: ? null
3383: : [$this->unsealed[0], $cb($this->unsealed[1])];
3384:
3385: return $this->recreate(
3386: $this->keyTypes,
3387: $newValueTypes,
3388: $this->nextAutoIndexes,
3389: $this->optionalKeys,
3390: $this->isList,
3391: $newUnsealed,
3392: );
3393: }
3394:
3395: public function mapKeyType(callable $cb): Type
3396: {
3397: // Constant array shapes already encode precise per-slot keys; a
3398: // blanket key-type rewrite (the prior `TypeTraverser`-based pattern
3399: // in `NodeScopeResolver`) would coerce constants into a broader
3400: // type and lose precision. Pass through unchanged.
3401: return $this;
3402: }
3403:
3404: public function makeAllArrayKeysOptional(): Type
3405: {
3406: $keyCount = count($this->keyTypes);
3407: if ($keyCount === 0) {
3408: return $this;
3409: }
3410:
3411: return $this->recreate(
3412: $this->keyTypes,
3413: $this->valueTypes,
3414: $this->nextAutoIndexes,
3415: range(0, $keyCount - 1),
3416: $this->isList,
3417: $this->unsealed,
3418: );
3419: }
3420:
3421: public function changeKeyCaseArray(?int $case): Type
3422: {
3423: $builder = ConstantArrayTypeBuilder::createEmpty();
3424: foreach ($this->keyTypes as $i => $keyType) {
3425: if ($keyType instanceof ConstantStringType) {
3426: $newKeyType = self::foldConstantStringKeyCase($keyType, $case);
3427: } else {
3428: $newKeyType = $keyType;
3429: }
3430: $builder->setOffsetValueType($newKeyType, $this->valueTypes[$i], $this->isOptionalKey($i));
3431: }
3432:
3433: if ($this->unsealed !== null) {
3434: $builder->makeUnsealed(self::foldUnsealedKeyCase($this->unsealed[0], $case), $this->unsealed[1]);
3435: }
3436:
3437: $result = $builder->getArray();
3438: if ($this->isList()->yes()) {
3439: $result = TypeCombinator::intersect($result, new AccessoryArrayListType());
3440: }
3441: return $result;
3442: }
3443:
3444: public function filterArrayRemovingFalsey(): Type
3445: {
3446: $falseyTypes = StaticTypeFactory::falsey();
3447: $builder = ConstantArrayTypeBuilder::createEmpty();
3448: foreach ($this->keyTypes as $i => $keyType) {
3449: $value = $this->valueTypes[$i];
3450: $isFalsey = $falseyTypes->isSuperTypeOf($value);
3451: if ($isFalsey->yes()) {
3452: continue;
3453: }
3454: if ($isFalsey->maybe()) {
3455: $builder->setOffsetValueType($keyType, TypeCombinator::remove($value, $falseyTypes), true);
3456: continue;
3457: }
3458: $builder->setOffsetValueType($keyType, $value, $this->isOptionalKey($i));
3459: }
3460:
3461: if ($this->unsealed !== null) {
3462: $unsealedValue = TypeCombinator::remove($this->unsealed[1], $falseyTypes);
3463: if (!$unsealedValue instanceof NeverType) {
3464: $builder->makeUnsealed($this->unsealed[0], $unsealedValue);
3465: }
3466: }
3467:
3468: return $builder->getArray();
3469: }
3470:
3471: private static function foldConstantStringKeyCase(ConstantStringType $type, ?int $case): Type
3472: {
3473: if ($case === CASE_LOWER) {
3474: return new ConstantStringType(strtolower($type->getValue()));
3475: }
3476: if ($case === CASE_UPPER) {
3477: return new ConstantStringType(strtoupper($type->getValue()));
3478: }
3479:
3480: return TypeCombinator::union(
3481: new ConstantStringType(strtolower($type->getValue())),
3482: new ConstantStringType(strtoupper($type->getValue())),
3483: );
3484: }
3485:
3486: private static function foldUnsealedKeyCase(Type $key, ?int $case): Type
3487: {
3488: if ($key instanceof ConstantStringType) {
3489: return self::foldConstantStringKeyCase($key, $case);
3490: }
3491:
3492: if ($key instanceof UnionType) {
3493: $folded = [];
3494: foreach ($key->getTypes() as $innerKey) {
3495: $folded[] = self::foldUnsealedKeyCase($innerKey, $case);
3496: }
3497:
3498: return TypeCombinator::union(...$folded);
3499: }
3500:
3501: // `array_change_key_case` only folds string keys — int keys
3502: // (e.g. `...<int, ...>`) pass through unchanged.
3503: if (!$key->isString()->yes()) {
3504: return $key;
3505: }
3506:
3507: // Rebuild from a clean `string` plus the non-case accessories that
3508: // case-folding preserves (length is unchanged, so numeric / non-
3509: // falsy / non-empty all survive). Any prior lowercase/uppercase
3510: // accessory is dropped — matches the `ArrayType::changeKeyCaseArray`
3511: // behavior where `strtoupper(lowercase-string)` reads as
3512: // `uppercase-string`, not the contradictory intersection.
3513: $preserved = [new StringType()];
3514: if ($key->isNumericString()->yes()) {
3515: $preserved[] = new AccessoryNumericStringType();
3516: } elseif ($key->isNonFalsyString()->yes()) {
3517: $preserved[] = new AccessoryNonFalsyStringType();
3518: } elseif ($key->isNonEmptyString()->yes()) {
3519: $preserved[] = new AccessoryNonEmptyStringType();
3520: }
3521:
3522: if ($case === CASE_LOWER) {
3523: return new IntersectionType([...$preserved, new AccessoryLowercaseStringType()]);
3524: }
3525: if ($case === CASE_UPPER) {
3526: return new IntersectionType([...$preserved, new AccessoryUppercaseStringType()]);
3527: }
3528:
3529: // `null` (PHP <8.4 / unspecified) yields lower- or upper-case
3530: // keys; record both as a union.
3531: return TypeCombinator::union(
3532: new IntersectionType([...$preserved, new AccessoryLowercaseStringType()]),
3533: new IntersectionType([...$preserved, new AccessoryUppercaseStringType()]),
3534: );
3535: }
3536:
3537: public function toPhpDocNode(): TypeNode
3538: {
3539: $items = [];
3540: $values = [];
3541: $exportValuesOnly = true;
3542: foreach ($this->keyTypes as $i => $keyType) {
3543: if ($keyType->getValue() !== $i) {
3544: $exportValuesOnly = false;
3545: }
3546: $keyPhpDocNode = $keyType->toPhpDocNode();
3547: if (!$keyPhpDocNode instanceof ConstTypeNode) {
3548: continue;
3549: }
3550: $valueType = $this->valueTypes[$i];
3551:
3552: /** @var ConstExprStringNode|ConstExprIntegerNode $keyNode */
3553: $keyNode = $keyPhpDocNode->constExpr;
3554: if ($keyNode instanceof ConstExprStringNode) {
3555: $value = $keyNode->value;
3556: if (self::isValidIdentifier($value)) {
3557: $keyNode = new IdentifierTypeNode($value);
3558: }
3559: }
3560:
3561: $isOptional = $this->isOptionalKey($i);
3562: if ($isOptional) {
3563: $exportValuesOnly = false;
3564: }
3565: $items[] = new ArrayShapeItemNode(
3566: $keyNode,
3567: $isOptional,
3568: $valueType->toPhpDocNode(),
3569: );
3570: $values[] = new ArrayShapeItemNode(
3571: null,
3572: $isOptional,
3573: $valueType->toPhpDocNode(),
3574: );
3575: }
3576:
3577: if ($this->isUnsealed()->yes() && $this->unsealed !== null) {
3578: $unsealedKeyTypeDescription = $this->unsealed[0]->describe(VerbosityLevel::precise());
3579: $isMixedUnsealedKeyType = $this->unsealed[0] instanceof MixedType && $unsealedKeyTypeDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed();
3580: $isMixedUnsealedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed();
3581: if ($isMixedUnsealedKeyType || ($this->isList()->yes() && $unsealedKeyTypeDescription === 'int<0, max>')) {
3582: if ($isMixedUnsealedItemType) {
3583: return ArrayShapeNode::createUnsealed(
3584: $exportValuesOnly ? $values : $items,
3585: null,
3586: $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY,
3587: );
3588: }
3589:
3590: return ArrayShapeNode::createUnsealed(
3591: $exportValuesOnly ? $values : $items,
3592: new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), null),
3593: $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY,
3594: );
3595: }
3596:
3597: return ArrayShapeNode::createUnsealed(
3598: $exportValuesOnly ? $values : $items,
3599: new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), $this->unsealed[0]->toPhpDocNode()),
3600: ArrayShapeNode::KIND_ARRAY,
3601: );
3602: }
3603:
3604: return ArrayShapeNode::createSealed(
3605: $exportValuesOnly ? $values : $items,
3606: $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY,
3607: );
3608: }
3609:
3610: public static function isValidIdentifier(string $value): bool
3611: {
3612: $result = Strings::match($value, '~^(?:[\\\\]?+[a-z_\\x80-\\xFF][0-9a-z_\\x80-\\xFF-]*+)++$~si');
3613:
3614: return $result !== null;
3615: }
3616:
3617: public function getFiniteTypes(): array
3618: {
3619: if ($this->isUnsealed()->yes()) {
3620: return [];
3621: }
3622:
3623: $limit = InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT;
3624:
3625: // Build finite array types incrementally, processing one key at a time.
3626: // For optional keys, fork each partial result into with/without variants.
3627: // This avoids generating 2^N ConstantArrayType objects via getAllArrays().
3628: // Count first: a shape with many optional keys overflows the limit after a
3629: // handful of keys, and building the partial arrays up to that point costs
3630: // hundreds of builder clones per call for a result that is thrown away.
3631: $finiteValueTypesPerKey = [];
3632: $count = 1;
3633: foreach ($this->valueTypes as $i => $valueType) {
3634: $finiteValueTypes = $valueType->getFiniteTypes();
3635: if ($finiteValueTypes === []) {
3636: return [];
3637: }
3638:
3639: $finiteValueTypesPerKey[$i] = $finiteValueTypes;
3640: $count *= count($finiteValueTypes) + ($this->isOptionalKey($i) ? 1 : 0);
3641: if ($count > $limit) {
3642: return [];
3643: }
3644: }
3645:
3646: /** @var list<ConstantArrayTypeBuilder> $partials */
3647: $partials = [ConstantArrayTypeBuilder::createEmpty()];
3648:
3649: foreach ($this->keyTypes as $i => $keyType) {
3650: $finiteValueTypes = $finiteValueTypesPerKey[$i];
3651: $isOptional = $this->isOptionalKey($i);
3652: $newPartials = [];
3653:
3654: foreach ($partials as $partial) {
3655: if ($isOptional) {
3656: $newPartials[] = clone $partial;
3657: }
3658: foreach ($finiteValueTypes as $finiteValueType) {
3659: $newPartial = clone $partial;
3660: $newPartial->setOffsetValueType($keyType, $finiteValueType);
3661: $newPartials[] = $newPartial;
3662: }
3663: }
3664:
3665: $partials = $newPartials;
3666: }
3667:
3668: $finiteTypes = [];
3669: foreach ($partials as $partial) {
3670: $finiteTypes[] = $partial->getArray();
3671: }
3672:
3673: return $finiteTypes;
3674: }
3675:
3676: public function hasTemplateOrLateResolvableType(): bool
3677: {
3678: foreach ($this->valueTypes as $valueType) {
3679: if (!$valueType->hasTemplateOrLateResolvableType()) {
3680: continue;
3681: }
3682:
3683: return true;
3684: }
3685:
3686: foreach ($this->keyTypes as $keyType) {
3687: if (!$keyType instanceof TemplateType) {
3688: continue;
3689: }
3690:
3691: return true;
3692: }
3693:
3694: if ($this->unsealed !== null) {
3695: if ($this->unsealed[0]->hasTemplateOrLateResolvableType()) {
3696: return true;
3697: }
3698: if ($this->unsealed[1]->hasTemplateOrLateResolvableType()) {
3699: return true;
3700: }
3701: }
3702:
3703: return false;
3704: }
3705:
3706: }
3707: