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