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