Skip to content

Commit 882eabc

Browse files
committedApr 6, 2023
Support for object shapes
1 parent d3753fc commit 882eabc

File tree

5 files changed

+467
-6
lines changed

5 files changed

+467
-6
lines changed
 

‎src/Ast/Type/ObjectShapeItemNode.php

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\PhpDocParser\Ast\Type;
4+
5+
use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode;
6+
use PHPStan\PhpDocParser\Ast\NodeAttributes;
7+
use function sprintf;
8+
9+
class ObjectShapeItemNode implements TypeNode
10+
{
11+
12+
use NodeAttributes;
13+
14+
/** @var ConstExprStringNode|IdentifierTypeNode */
15+
public $keyName;
16+
17+
/** @var bool */
18+
public $optional;
19+
20+
/** @var TypeNode */
21+
public $valueType;
22+
23+
/**
24+
* @param ConstExprStringNode|IdentifierTypeNode $keyName
25+
*/
26+
public function __construct($keyName, bool $optional, TypeNode $valueType)
27+
{
28+
$this->keyName = $keyName;
29+
$this->optional = $optional;
30+
$this->valueType = $valueType;
31+
}
32+
33+
34+
public function __toString(): string
35+
{
36+
if ($this->keyName !== null) {
37+
return sprintf(
38+
'%s%s: %s',
39+
(string) $this->keyName,
40+
$this->optional ? '?' : '',
41+
(string) $this->valueType
42+
);
43+
}
44+
45+
return (string) $this->valueType;
46+
}
47+
48+
}

‎src/Ast/Type/ObjectShapeNode.php

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\PhpDocParser\Ast\Type;
4+
5+
use PHPStan\PhpDocParser\Ast\NodeAttributes;
6+
use function implode;
7+
8+
class ObjectShapeNode implements TypeNode
9+
{
10+
11+
use NodeAttributes;
12+
13+
/** @var ObjectShapeItemNode[] */
14+
public $items;
15+
16+
public function __construct(array $items)
17+
{
18+
$this->items = $items;
19+
}
20+
21+
public function __toString(): string
22+
{
23+
$items = $this->items;
24+
25+
return 'object{' . implode(', ', $items) . '}';
26+
}
27+
28+
}

‎src/Parser/TypeParser.php

+66-2
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,12 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode
124124
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) {
125125
$type = $this->tryParseArrayOrOffsetAccess($tokens, $type);
126126

127-
} elseif (in_array($type->name, ['array', 'list'], true) && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET) && !$tokens->isPrecededByHorizontalWhitespace()) {
128-
$type = $this->parseArrayShape($tokens, $type, $type->name);
127+
} elseif (in_array($type->name, ['array', 'list', 'object'], true) && $tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET) && !$tokens->isPrecededByHorizontalWhitespace()) {
128+
if ($type->name === 'object') {
129+
$type = $this->parseObjectShape($tokens);
130+
} else {
131+
$type = $this->parseArrayShape($tokens, $type, $type->name);
132+
}
129133

130134
if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) {
131135
$type = $this->tryParseArrayOrOffsetAccess($tokens, $type);
@@ -582,4 +586,64 @@ private function parseArrayShapeKey(TokenIterator $tokens)
582586
return $key;
583587
}
584588

589+
/**
590+
* @phpstan-impure
591+
*/
592+
private function parseObjectShape(TokenIterator $tokens): Ast\Type\ObjectShapeNode
593+
{
594+
$tokens->consumeTokenType(Lexer::TOKEN_OPEN_CURLY_BRACKET);
595+
596+
$items = [];
597+
598+
do {
599+
$tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
600+
601+
if ($tokens->tryConsumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET)) {
602+
return new Ast\Type\ObjectShapeNode($items);
603+
}
604+
605+
$items[] = $this->parseObjectShapeItem($tokens);
606+
607+
$tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
608+
} while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA));
609+
610+
$tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
611+
$tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET);
612+
613+
return new Ast\Type\ObjectShapeNode($items);
614+
}
615+
616+
/** @phpstan-impure */
617+
private function parseObjectShapeItem(TokenIterator $tokens): Ast\Type\ObjectShapeItemNode
618+
{
619+
$key = $this->parseObjectShapeKey($tokens);
620+
$optional = $tokens->tryConsumeTokenType(Lexer::TOKEN_NULLABLE);
621+
$tokens->consumeTokenType(Lexer::TOKEN_COLON);
622+
$value = $this->parse($tokens);
623+
624+
return new Ast\Type\ObjectShapeItemNode($key, $optional, $value);
625+
}
626+
627+
/**
628+
* @phpstan-impure
629+
* @return Ast\ConstExpr\ConstExprStringNode|Ast\Type\IdentifierTypeNode
630+
*/
631+
private function parseObjectShapeKey(TokenIterator $tokens)
632+
{
633+
if ($tokens->isCurrentTokenType(Lexer::TOKEN_SINGLE_QUOTED_STRING)) {
634+
$key = new Ast\ConstExpr\ConstExprStringNode(trim($tokens->currentTokenValue(), "'"));
635+
$tokens->next();
636+
637+
} elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_DOUBLE_QUOTED_STRING)) {
638+
$key = new Ast\ConstExpr\ConstExprStringNode(trim($tokens->currentTokenValue(), '"'));
639+
$tokens->next();
640+
641+
} else {
642+
$key = new Ast\Type\IdentifierTypeNode($tokens->currentTokenValue());
643+
$tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER);
644+
}
645+
646+
return $key;
647+
}
648+
585649
}

‎tests/PHPStan/Parser/PhpDocParserTest.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -901,16 +901,16 @@ public function provideVarTagsData(): Iterator
901901

902902
yield [
903903
'invalid object shape',
904-
'/** @psalm-type PARTSTRUCTURE_PARAM = object{attribute:string, value?:string} */',
904+
'/** @psalm-type PARTSTRUCTURE_PARAM = objecttt{attribute:string, value?:string} */',
905905
new PhpDocNode([
906906
new PhpDocTagNode(
907907
'@psalm-type',
908908
new InvalidTagValueNode(
909-
'Unexpected token "{", expected \'*/\' at offset 44',
909+
'Unexpected token "{", expected \'*/\' at offset 46',
910910
new ParserException(
911911
'{',
912912
Lexer::TOKEN_OPEN_CURLY_BRACKET,
913-
44,
913+
46,
914914
Lexer::TOKEN_CLOSE_PHPDOC
915915
)
916916
)
@@ -926,7 +926,7 @@ public function provideVarTagsData(): Iterator
926926
new ParserException(
927927
'{',
928928
Lexer::TOKEN_OPEN_CURLY_BRACKET,
929-
44,
929+
46,
930930
Lexer::TOKEN_PHPDOC_EOL,
931931
null
932932
)

‎tests/PHPStan/Parser/TypeParserTest.php

+321
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
2020
use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
2121
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
22+
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
23+
use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
2224
use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
2325
use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
2426
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
@@ -1539,6 +1541,325 @@ public function provideParseData(): array
15391541
]
15401542
),
15411543
],
1544+
[
1545+
'object{a: int}',
1546+
new ObjectShapeNode([
1547+
new ObjectShapeItemNode(
1548+
new IdentifierTypeNode('a'),
1549+
false,
1550+
new IdentifierTypeNode('int')
1551+
),
1552+
]),
1553+
],
1554+
[
1555+
'object{a: ?int}',
1556+
new ObjectShapeNode([
1557+
new ObjectShapeItemNode(
1558+
new IdentifierTypeNode('a'),
1559+
false,
1560+
new NullableTypeNode(
1561+
new IdentifierTypeNode('int')
1562+
)
1563+
),
1564+
]),
1565+
],
1566+
[
1567+
'object{a?: ?int}',
1568+
new ObjectShapeNode([
1569+
new ObjectShapeItemNode(
1570+
new IdentifierTypeNode('a'),
1571+
true,
1572+
new NullableTypeNode(
1573+
new IdentifierTypeNode('int')
1574+
)
1575+
),
1576+
]),
1577+
],
1578+
[
1579+
'object{a: int, b: string}',
1580+
new ObjectShapeNode([
1581+
new ObjectShapeItemNode(
1582+
new IdentifierTypeNode('a'),
1583+
false,
1584+
new IdentifierTypeNode('int')
1585+
),
1586+
new ObjectShapeItemNode(
1587+
new IdentifierTypeNode('b'),
1588+
false,
1589+
new IdentifierTypeNode('string')
1590+
),
1591+
]),
1592+
],
1593+
[
1594+
'object{a: int, b: array{c: callable(): int}}',
1595+
new ObjectShapeNode([
1596+
new ObjectShapeItemNode(
1597+
new IdentifierTypeNode('a'),
1598+
false,
1599+
new IdentifierTypeNode('int')
1600+
),
1601+
new ObjectShapeItemNode(
1602+
new IdentifierTypeNode('b'),
1603+
false,
1604+
new ArrayShapeNode([
1605+
new ArrayShapeItemNode(
1606+
new IdentifierTypeNode('c'),
1607+
false,
1608+
new CallableTypeNode(
1609+
new IdentifierTypeNode('callable'),
1610+
[],
1611+
new IdentifierTypeNode('int')
1612+
)
1613+
),
1614+
])
1615+
),
1616+
]),
1617+
],
1618+
[
1619+
'object{a: int, b: object{c: callable(): int}}',
1620+
new ObjectShapeNode([
1621+
new ObjectShapeItemNode(
1622+
new IdentifierTypeNode('a'),
1623+
false,
1624+
new IdentifierTypeNode('int')
1625+
),
1626+
new ObjectShapeItemNode(
1627+
new IdentifierTypeNode('b'),
1628+
false,
1629+
new ObjectShapeNode([
1630+
new ObjectShapeItemNode(
1631+
new IdentifierTypeNode('c'),
1632+
false,
1633+
new CallableTypeNode(
1634+
new IdentifierTypeNode('callable'),
1635+
[],
1636+
new IdentifierTypeNode('int')
1637+
)
1638+
),
1639+
])
1640+
),
1641+
]),
1642+
],
1643+
[
1644+
'?object{a: int}',
1645+
new NullableTypeNode(
1646+
new ObjectShapeNode([
1647+
new ObjectShapeItemNode(
1648+
new IdentifierTypeNode('a'),
1649+
false,
1650+
new IdentifierTypeNode('int')
1651+
),
1652+
])
1653+
),
1654+
],
1655+
[
1656+
'object{',
1657+
new ParserException(
1658+
'',
1659+
Lexer::TOKEN_END,
1660+
7,
1661+
Lexer::TOKEN_IDENTIFIER
1662+
),
1663+
],
1664+
[
1665+
'object{a => int}',
1666+
new ParserException(
1667+
'=>',
1668+
Lexer::TOKEN_OTHER,
1669+
9,
1670+
Lexer::TOKEN_COLON
1671+
),
1672+
],
1673+
[
1674+
'object{int}',
1675+
new ParserException(
1676+
'}',
1677+
Lexer::TOKEN_CLOSE_CURLY_BRACKET,
1678+
10,
1679+
Lexer::TOKEN_COLON
1680+
),
1681+
],
1682+
[
1683+
'object{0: int}',
1684+
new ParserException(
1685+
'0',
1686+
Lexer::TOKEN_END,
1687+
7,
1688+
Lexer::TOKEN_IDENTIFIER
1689+
),
1690+
],
1691+
[
1692+
'object{0?: int}',
1693+
new ParserException(
1694+
'0',
1695+
Lexer::TOKEN_END,
1696+
7,
1697+
Lexer::TOKEN_IDENTIFIER
1698+
),
1699+
],
1700+
[
1701+
'object{"a": int}',
1702+
new ObjectShapeNode([
1703+
new ObjectShapeItemNode(
1704+
new ConstExprStringNode('a'),
1705+
false,
1706+
new IdentifierTypeNode('int')
1707+
),
1708+
]),
1709+
],
1710+
[
1711+
'object{\'a\': int}',
1712+
new ObjectShapeNode([
1713+
new ObjectShapeItemNode(
1714+
new ConstExprStringNode('a'),
1715+
false,
1716+
new IdentifierTypeNode('int')
1717+
),
1718+
]),
1719+
],
1720+
[
1721+
'object{\'$ref\': int}',
1722+
new ObjectShapeNode([
1723+
new ObjectShapeItemNode(
1724+
new ConstExprStringNode('$ref'),
1725+
false,
1726+
new IdentifierTypeNode('int')
1727+
),
1728+
]),
1729+
],
1730+
[
1731+
'object{"$ref": int}',
1732+
new ObjectShapeNode([
1733+
new ObjectShapeItemNode(
1734+
new ConstExprStringNode('$ref'),
1735+
false,
1736+
new IdentifierTypeNode('int')
1737+
),
1738+
]),
1739+
],
1740+
[
1741+
'object{
1742+
* a: int
1743+
*}',
1744+
new ObjectShapeNode([
1745+
new ObjectShapeItemNode(
1746+
new IdentifierTypeNode('a'),
1747+
false,
1748+
new IdentifierTypeNode('int')
1749+
),
1750+
]),
1751+
],
1752+
[
1753+
'object{
1754+
a: int,
1755+
}',
1756+
new ObjectShapeNode([
1757+
new ObjectShapeItemNode(
1758+
new IdentifierTypeNode('a'),
1759+
false,
1760+
new IdentifierTypeNode('int')
1761+
),
1762+
]),
1763+
],
1764+
[
1765+
'object{
1766+
a: int,
1767+
b: string,
1768+
}',
1769+
new ObjectShapeNode([
1770+
new ObjectShapeItemNode(
1771+
new IdentifierTypeNode('a'),
1772+
false,
1773+
new IdentifierTypeNode('int')
1774+
),
1775+
new ObjectShapeItemNode(
1776+
new IdentifierTypeNode('b'),
1777+
false,
1778+
new IdentifierTypeNode('string')
1779+
),
1780+
]),
1781+
],
1782+
[
1783+
'object{
1784+
a: int
1785+
, b: string
1786+
, c: string
1787+
}',
1788+
new ObjectShapeNode([
1789+
new ObjectShapeItemNode(
1790+
new IdentifierTypeNode('a'),
1791+
false,
1792+
new IdentifierTypeNode('int')
1793+
),
1794+
new ObjectShapeItemNode(
1795+
new IdentifierTypeNode('b'),
1796+
false,
1797+
new IdentifierTypeNode('string')
1798+
),
1799+
new ObjectShapeItemNode(
1800+
new IdentifierTypeNode('c'),
1801+
false,
1802+
new IdentifierTypeNode('string')
1803+
),
1804+
]),
1805+
],
1806+
[
1807+
'object{
1808+
a: int,
1809+
b: string
1810+
}',
1811+
new ObjectShapeNode([
1812+
new ObjectShapeItemNode(
1813+
new IdentifierTypeNode('a'),
1814+
false,
1815+
new IdentifierTypeNode('int')
1816+
),
1817+
new ObjectShapeItemNode(
1818+
new IdentifierTypeNode('b'),
1819+
false,
1820+
new IdentifierTypeNode('string')
1821+
),
1822+
]),
1823+
],
1824+
[
1825+
'object{foo: int}[]',
1826+
new ArrayTypeNode(
1827+
new ObjectShapeNode([
1828+
new ObjectShapeItemNode(
1829+
new IdentifierTypeNode('foo'),
1830+
false,
1831+
new IdentifierTypeNode('int')
1832+
),
1833+
])
1834+
),
1835+
],
1836+
[
1837+
'int | object{foo: int}[]',
1838+
new UnionTypeNode([
1839+
new IdentifierTypeNode('int'),
1840+
new ArrayTypeNode(
1841+
new ObjectShapeNode([
1842+
new ObjectShapeItemNode(
1843+
new IdentifierTypeNode('foo'),
1844+
false,
1845+
new IdentifierTypeNode('int')
1846+
),
1847+
])
1848+
),
1849+
]),
1850+
],
1851+
[
1852+
'object{}',
1853+
new ObjectShapeNode([]),
1854+
],
1855+
[
1856+
'object{}|int',
1857+
new UnionTypeNode([new ObjectShapeNode([]), new IdentifierTypeNode('int')]),
1858+
],
1859+
[
1860+
'int|object{}',
1861+
new UnionTypeNode([new IdentifierTypeNode('int'), new ObjectShapeNode([])]),
1862+
],
15421863
];
15431864
}
15441865

0 commit comments

Comments
 (0)
Please sign in to comment.