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