Skip to content

Commit 70aa971

Browse files
committed
Add support for ListTag type casting and PHPStan generics
1 parent df9d36f commit 70aa971

File tree

7 files changed

+138
-10
lines changed

7 files changed

+138
-10
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"pocketmine/binaryutils": "^0.2.0"
99
},
1010
"require-dev": {
11-
"phpstan/phpstan": "2.1.0",
11+
"phpstan/phpstan": "2.1.27",
1212
"phpunit/phpunit": "^9.5",
1313
"phpstan/extension-installer": "^1.0",
1414
"phpstan/phpstan-strict-rules": "^2.0",

src/tag/CompoundTag.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,26 @@ public function getTag(string $name) : ?Tag{
9191
/**
9292
* Returns the ListTag with the specified name, or null if it does not exist. Triggers an exception if a tag exists
9393
* with that name and the tag is not a ListTag.
94+
*
95+
* @phpstan-template TValue of Tag
96+
* @phpstan-param class-string<TValue> $tagClass
97+
* @phpstan-return ListTag<TValue>|null
98+
*
99+
* @throws UnexpectedTagTypeException
94100
*/
95-
public function getListTag(string $name) : ?ListTag{
101+
public function getListTag(string $name, string $tagClass = Tag::class) : ?ListTag{
96102
$tag = $this->getTag($name);
97-
if($tag !== null && !($tag instanceof ListTag)){
98-
throw new UnexpectedTagTypeException("Expected a tag of type " . ListTag::class . ", got " . get_class($tag));
103+
if($tag !== null){
104+
if(!$tag instanceof ListTag){
105+
throw new UnexpectedTagTypeException("Expected a tag of type " . ListTag::class . ", got " . get_class($tag));
106+
}
107+
$casted = $tag->cast($tagClass);
108+
if($casted === null){
109+
throw new UnexpectedTagTypeException("Unable to cast list to ListTag<$tagClass>");
110+
}
111+
return $casted;
99112
}
100-
return $tag;
113+
return null;
101114
}
102115

103116
/**

src/tag/ListTag.php

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
use function str_repeat;
4343

4444
/**
45-
* @phpstan-implements \IteratorAggregate<int, Tag>
45+
* @phpstan-template TValue of Tag = Tag
46+
* @phpstan-implements \IteratorAggregate<int, TValue>
4647
*/
4748
final class ListTag extends Tag implements \Countable, \IteratorAggregate{
4849
use NoDynamicFieldsTrait;
@@ -51,12 +52,13 @@ final class ListTag extends Tag implements \Countable, \IteratorAggregate{
5152
private $tagType;
5253
/**
5354
* @var Tag[]
54-
* @phpstan-var list<Tag>
55+
* @phpstan-var list<TValue>
5556
*/
5657
private $value = [];
5758

5859
/**
5960
* @param Tag[] $value
61+
* @phpstan-param TValue[] $value
6062
*/
6163
public function __construct(array $value = [], int $tagType = NBT::TAG_End){
6264
self::restrictArgCount(__METHOD__, func_num_args(), 2);
@@ -68,7 +70,7 @@ public function __construct(array $value = [], int $tagType = NBT::TAG_End){
6870

6971
/**
7072
* @return Tag[]
71-
* @phpstan-return list<Tag>
73+
* @phpstan-return list<TValue>
7274
*/
7375
public function getValue() : array{
7476
return $this->value;
@@ -83,6 +85,31 @@ public function getAllValues() : array{
8385
return array_map(fn(Tag $t) => $t->getValue(), $this->value);
8486
}
8587

88+
/**
89+
* @phpstan-template TTarget of Tag
90+
* @phpstan-param class-string<TTarget> $tagClass
91+
* @phpstan-this-out self<TTarget> $this
92+
*/
93+
private function checkTagClass(string $tagClass) : bool{
94+
return count($this->value) === 0 || $this->first() instanceof $tagClass;
95+
}
96+
97+
/**
98+
* Returns $this if the tag values are of type $tagClass, null otherwise.
99+
* The returned value will have the proper PHPStan generic types set if it matches.
100+
*
101+
* If the list is empty, the cast will always succeed, as empty lists infer their
102+
* type from the first value inserted.
103+
*
104+
* @phpstan-template TTarget of Tag
105+
* @phpstan-param class-string<TTarget> $tagClass
106+
*
107+
* @phpstan-return self<TTarget>|null
108+
*/
109+
public function cast(string $tagClass) : ?self{
110+
return $this->checkTagClass($tagClass) ? $this : null;
111+
}
112+
86113
public function count() : int{
87114
return count($this->value);
88115
}
@@ -93,6 +120,10 @@ public function getCount() : int{
93120

94121
/**
95122
* Appends the specified tag to the end of the list.
123+
*
124+
* @phpstan-template TNewValue of TValue
125+
* @phpstan-param TNewValue $tag
126+
* @phpstan-this-out self<TNewValue>
96127
*/
97128
public function push(Tag $tag) : void{
98129
$this->checkTagType($tag);
@@ -101,6 +132,7 @@ public function push(Tag $tag) : void{
101132

102133
/**
103134
* Removes the last tag from the list and returns it.
135+
* @phpstan-return TValue
104136
*/
105137
public function pop() : Tag{
106138
if(count($this->value) === 0){
@@ -111,6 +143,10 @@ public function pop() : Tag{
111143

112144
/**
113145
* Adds the specified tag to the start of the list.
146+
*
147+
* @phpstan-template TNewValue of TValue
148+
* @phpstan-param TNewValue $tag
149+
* @phpstan-this-out self<TNewValue>
114150
*/
115151
public function unshift(Tag $tag) : void{
116152
$this->checkTagType($tag);
@@ -119,6 +155,7 @@ public function unshift(Tag $tag) : void{
119155

120156
/**
121157
* Removes the first tag from the list and returns it.
158+
* @phpstan-return TValue
122159
*/
123160
public function shift() : Tag{
124161
if(count($this->value) === 0){
@@ -131,6 +168,10 @@ public function shift() : Tag{
131168
* Inserts a tag into the list between existing tags, at the specified offset. Later values in the list are moved up
132169
* by 1 position.
133170
*
171+
* @phpstan-template TNewValue of TValue
172+
* @phpstan-param TNewValue $tag
173+
* @phpstan-this-out self<TNewValue>
174+
*
134175
* @return void
135176
* @throws \OutOfRangeException if the offset is not within the bounds of the list
136177
*/
@@ -158,6 +199,8 @@ public function remove(int $offset) : void{
158199
/**
159200
* Returns the tag at the specified offset.
160201
*
202+
* @phpstan-return TValue
203+
*
161204
* @throws \OutOfRangeException if the offset is not within the bounds of the list
162205
*/
163206
public function get(int $offset) : Tag{
@@ -169,6 +212,7 @@ public function get(int $offset) : Tag{
169212

170213
/**
171214
* Returns the element in the first position of the list, without removing it.
215+
* @phpstan-return TValue
172216
*/
173217
public function first() : Tag{
174218
if(count($this->value) === 0){
@@ -179,6 +223,7 @@ public function first() : Tag{
179223

180224
/**
181225
* Returns the element in the last position in the list (the end), without removing it.
226+
* @phpstan-return TValue
182227
*/
183228
public function last() : Tag{
184229
if(count($this->value) === 0){
@@ -190,6 +235,10 @@ public function last() : Tag{
190235
/**
191236
* Overwrites the tag at the specified offset.
192237
*
238+
* @phpstan-template TNewValue of TValue
239+
* @phpstan-param TNewValue $tag
240+
* @phpstan-this-out self<TNewValue>
241+
*
193242
* @throws \OutOfRangeException if the offset is not within the bounds of the list
194243
*/
195244
public function set(int $offset, Tag $tag) : void{
@@ -230,6 +279,7 @@ public function getTagType() : int{
230279
}
231280

232281
/**
282+
* @deprecated
233283
* Sets the type of tag that can be added to this list. If TAG_End is used, the type will be auto-detected from the
234284
* first tag added to the list.
235285
*
@@ -307,7 +357,7 @@ protected function makeCopy(){
307357

308358
/**
309359
* @return \Generator|Tag[]
310-
* @phpstan-return \Generator<int, Tag, void, void>
360+
* @phpstan-return \Generator<int, TValue, void, void>
311361
*/
312362
public function getIterator() : \Generator{
313363
yield from $this->value;

src/tag/Tag.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ abstract protected function stringifyValue(int $indentation) : string;
5757
* Used for cloning tags in tags that have children.
5858
*
5959
* @throws \RuntimeException if a recursive dependency was detected
60+
* @return static
6061
*/
6162
public function safeClone() : Tag{
6263
if($this->cloning){

tests/phpstan/configs/phpstan-bugs.neon

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,37 @@ parameters:
1313
path: ../../../src/tag/IntArrayTag.php
1414

1515
-
16-
message: '#^Property pocketmine\\nbt\\tag\\ListTag\:\:\$value \(list\<pocketmine\\nbt\\tag\\Tag\>\) does not accept non\-empty\-array\<int\<0, max\>, pocketmine\\nbt\\tag\\Tag\>\.$#'
16+
message: '#^Property pocketmine\\nbt\\tag\\ListTag\<TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\>\:\:\$value \(list\<TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\>\) does not accept non\-empty\-array\<int\<0, max\>, \(TNewValue of TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\)\|TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\>\.$#'
1717
identifier: assign.propertyType
1818
count: 1
1919
path: ../../../src/tag/ListTag.php
2020

21+
-
22+
message: '#^Property pocketmine\\nbt\\tag\\ListTag\<TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\>\:\:\$value \(list\<TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\>\) does not accept non\-empty\-list\<\(TNewValue of TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\)\|TValue of pocketmine\\nbt\\tag\\Tag \= pocketmine\\nbt\\tag\\Tag\>\.$#'
23+
identifier: assign.propertyType
24+
count: 3
25+
path: ../../../src/tag/ListTag.php
26+
2127
-
2228
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
2329
identifier: function.alreadyNarrowedType
2430
count: 1
2531
path: ../../phpunit/tag/CompoundTagTest.php
32+
33+
-
34+
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNotSame\(\) with \*NEVER\* and pocketmine\\nbt\\tag\\Tag will always evaluate to true\.$#'
35+
identifier: staticMethod.alreadyNarrowedType
36+
count: 1
37+
path: ../../phpunit/tag/ListTagTest.php
38+
39+
-
40+
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertSame\(\) with \*NEVER\* and pocketmine\\nbt\\tag\\Tag will always evaluate to false\.$#'
41+
identifier: staticMethod.impossibleType
42+
count: 1
43+
path: ../../phpunit/tag/ListTagTest.php
44+
45+
-
46+
message: '#^Instanceof between \*NEVER\* and pocketmine\\nbt\\tag\\ImmutableTag will always evaluate to false\.$#'
47+
identifier: instanceof.alwaysFalse
48+
count: 1
49+
path: ../../phpunit/tag/ListTagTest.php

tests/phpunit/tag/CompoundTagTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
namespace pocketmine\nbt\tag;
2525

2626
use PHPUnit\Framework\TestCase;
27+
use pocketmine\nbt\UnexpectedTagTypeException;
2728
use pocketmine\utils\Limits;
2829
use function array_fill;
2930
use function str_repeat;
@@ -195,5 +196,27 @@ public function testNameLength() : void{
195196
$tag->setTag(str_repeat(".", Limits::INT16_MAX + 1), new IntTag(1)); //error
196197
}
197198

199+
public function testGetListTagWithType() : void{
200+
$tag = CompoundTag::create()
201+
->setTag("empty", new ListTag())
202+
->setTag("string1", new ListTag([new StringTag("string1")]));
203+
204+
//empty always works
205+
self::assertNotNull($tag->getListTag("empty", CompoundTag::class));
206+
self::assertNotNull($tag->getListTag("empty", StringTag::class));
207+
self::assertNotNull($tag->getListTag("string1", StringTag::class));
208+
209+
self::assertNotNull($tag->getListTag("empty")); //no type also allowed
210+
self::assertNotNull($tag->getListTag("string1"));
211+
}
212+
213+
public function testGetListTagWithTypeErrors() : void{
214+
$tag = CompoundTag::create()
215+
->setTag("string1", new ListTag([new StringTag("string1")]));
216+
217+
$this->expectException(UnexpectedTagTypeException::class);
218+
$tag->getListTag("string1", IntTag::class);
219+
}
220+
198221
//TODO: add more tests
199222
}

tests/phpunit/tag/ListTagTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,21 @@ public function testEquals() : void{
178178
self::assertFalse($list1->equals($differentValue));
179179
self::assertFalse($differentValue->equals($list1));
180180
}
181+
182+
public static function castProvider() : \Generator{
183+
yield [new ListTag(), StringTag::class, true]; //empty list can be casted to any type
184+
yield [new ListTag([new StringTag("hello")]), StringTag::class, true];
185+
yield [new ListTag([new StringTag("hello"), new StringTag("hello2")]), StringTag::class, true];
186+
yield [new ListTag([new StringTag("hello")]), IntTag::class, false];
187+
yield [new ListTag([new StringTag("hello"), new StringTag("hello2")]), IntTag::class, false];
188+
}
189+
190+
/**
191+
* @phpstan-template TClass of Tag
192+
* @phpstan-param class-string<TClass> $targetClass
193+
* @dataProvider castProvider
194+
*/
195+
public function testCast(ListTag $in, string $targetClass, bool $succeeds) : void{
196+
self::assertSame($succeeds, $in->cast($targetClass) !== null);
197+
}
181198
}

0 commit comments

Comments
 (0)