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