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