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