Skip to content

Commit 7f9cafc

Browse files
authored
Merge pull request #21 from johnbillion/phpstan-param-arrays
2 parents d1001fe + 547148f commit 7f9cafc

File tree

6 files changed

+2696
-1
lines changed

6 files changed

+2696
-1
lines changed

.travis.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ script:
4848
- "git diff --exit-code"
4949
# Execute stubs
5050
- "php -f wordpress-stubs.php"
51+
# Analyse our code
52+
- "vendor/bin/phpstan"

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
},
1414
"require-dev": {
1515
"php": "~7.3 || ~8.0",
16-
"php-stubs/generator": "^0.7.0",
16+
"phpdocumentor/reflection-docblock": "^5.3",
17+
"php-stubs/generator": "^0.8.0",
18+
"phpstan/phpstan": "^1.2",
1719
"nikic/php-parser": "< 4.12.0"
1820
},
1921
"suggest": {

generate.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ fi
1818
"$(dirname "$0")/vendor/bin/generate-stubs" \
1919
--force \
2020
--finder=finder.php \
21+
--visitor=visitor.php \
2122
--header="$HEADER" \
2223
--functions \
2324
--classes \

phpstan.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
parameters:
2+
level: 8
3+
paths:
4+
- visitor.php

visitor.php

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
use phpDocumentor\Reflection\DocBlock\Description;
6+
use phpDocumentor\Reflection\DocBlock\Tags\Param;
7+
use phpDocumentor\Reflection\DocBlock\Tags\Return_;
8+
use phpDocumentor\Reflection\Type;
9+
use PhpParser\Comment\Doc;
10+
use PhpParser\Node;
11+
use PhpParser\Node\Stmt\ClassMethod;
12+
use PhpParser\Node\Stmt\Function_;
13+
use StubsGenerator\NodeVisitor;
14+
15+
return new class extends NodeVisitor {
16+
17+
/**
18+
* @var \phpDocumentor\Reflection\DocBlockFactory
19+
*/
20+
private $docBlockFactory;
21+
22+
public function __construct()
23+
{
24+
$this->docBlockFactory = \phpDocumentor\Reflection\DocBlockFactory::createInstance();
25+
}
26+
27+
public function enterNode(Node $node)
28+
{
29+
parent::enterNode($node);
30+
31+
if (!($node instanceof Function_) && !($node instanceof ClassMethod)) {
32+
return null;
33+
}
34+
35+
$docComment = $node->getDocComment();
36+
37+
if (!($docComment instanceof Doc)) {
38+
return null;
39+
}
40+
41+
$newDocComment = $this->addArrayHashNotation($docComment);
42+
43+
if ($newDocComment !== null) {
44+
$node->setDocComment($newDocComment);
45+
}
46+
47+
return null;
48+
}
49+
50+
private function addArrayHashNotation(Doc $docComment): ?Doc
51+
{
52+
$docCommentText = $docComment->getText();
53+
54+
try {
55+
$docblock = $this->docBlockFactory->create($docCommentText);
56+
} catch ( \RuntimeException $e ) {
57+
return null;
58+
} catch ( \InvalidArgumentException $e ) {
59+
return null;
60+
}
61+
62+
/** @var \phpDocumentor\Reflection\DocBlock\Tags\Param[] */
63+
$params = $docblock->getTagsByName('param');
64+
65+
/** @var \phpDocumentor\Reflection\DocBlock\Tags\Return_[] */
66+
$returns = $docblock->getTagsByName('return');
67+
68+
if (!$params && !$returns) {
69+
return null;
70+
}
71+
72+
$additions = [];
73+
74+
foreach ($params as $param) {
75+
$addition = $this->getAdditionFromParam($param);
76+
77+
if ($addition !== null) {
78+
$additions[] = $addition;
79+
}
80+
}
81+
82+
if ($returns) {
83+
$addition = $this->getAdditionFromReturn($returns[0]);
84+
85+
if ($addition !== null) {
86+
$additions[] = $addition;
87+
}
88+
}
89+
90+
if (!$additions) {
91+
return null;
92+
}
93+
94+
$newDocComment = sprintf(
95+
"%s\n%s\n */",
96+
substr($docCommentText, 0, -4),
97+
implode("\n", $additions)
98+
);
99+
100+
return new Doc($newDocComment, $docComment->getLine(), $docComment->getFilePos());
101+
}
102+
103+
private function getAdditionFromParam(Param $tag): ?string
104+
{
105+
$tagDescription = $tag->getDescription();
106+
$tagVariableName = $tag->getVariableName();
107+
$tagVariableType = $tag->getType();
108+
109+
// Skip if information we need is missing.
110+
if (!$tagDescription || !$tagVariableName || !$tagVariableType) {
111+
return null;
112+
}
113+
114+
$elements = $this->getElementsFromDescription($tagDescription, true);
115+
116+
if ($elements === null) {
117+
return null;
118+
}
119+
120+
$tagVariableType = $this->getTypeNameFromType($tagVariableType);
121+
122+
if ($tagVariableType === null) {
123+
return null;
124+
}
125+
126+
// It's common for an args parameter to accept a query var string or array with `string|array`.
127+
// Remove the accepted string type for these so we get the strongest typing we can manage.
128+
$tagVariableType = str_replace(['|string', 'string|'], '', $tagVariableType);
129+
130+
return sprintf(
131+
" * @phpstan-param %1\$s{\n * %2\$s,\n * } $%3\$s",
132+
$tagVariableType,
133+
implode(",\n * ", $elements),
134+
$tagVariableName
135+
);
136+
}
137+
138+
private function getAdditionFromReturn(Return_ $tag): ?string
139+
{
140+
$tagDescription = $tag->getDescription();
141+
$tagVariableType = $tag->getType();
142+
143+
// Skip if information we need is missing.
144+
if (!$tagDescription || !$tagVariableType) {
145+
return null;
146+
}
147+
148+
$elements = $this->getElementsFromDescription($tagDescription, false);
149+
150+
if ($elements === null) {
151+
return null;
152+
}
153+
154+
$tagVariableType = $this->getTypeNameFromType($tagVariableType);
155+
156+
if ($tagVariableType === null) {
157+
return null;
158+
}
159+
160+
return sprintf(
161+
" * @phpstan-return %1\$s{\n * %2\$s,\n * }",
162+
$tagVariableType,
163+
implode(",\n * ", $elements)
164+
);
165+
}
166+
167+
private function getTypeNameFromType(Type $tagVariableType): ?string
168+
{
169+
// PHPStan dosn't support typed array shapes (`int[]{...}`) so replace
170+
// typed arrays such as `int[]` with `array`.
171+
$tagVariableType = preg_replace('#[a-zA-Z0-9_]+\[\]#', 'array', $tagVariableType->__toString());
172+
173+
if ($tagVariableType === null) {
174+
return null;
175+
}
176+
177+
if (strpos($tagVariableType, 'array') === false) {
178+
// Skip if we have hash notation that's not for an array (ie. for `object`).
179+
return null;
180+
}
181+
182+
if (strpos($tagVariableType, 'array|') !== false) {
183+
// Move `array` to the end of union types so the appended array shape works.
184+
$tagVariableType = str_replace('array|', '', $tagVariableType) . '|array';
185+
}
186+
187+
return $tagVariableType;
188+
}
189+
190+
/**
191+
* @return ?string[]
192+
*/
193+
private function getElementsFromDescription(Description $tagDescription, bool $optional): ?array
194+
{
195+
$text = $tagDescription->__toString();
196+
197+
// Skip if the description doesn't contain at least one correctly
198+
// formatted `@type`, which indicates an array hash.
199+
if (strpos($text, ' @type ') === false) {
200+
return null;
201+
}
202+
203+
// Populate `$types` with the value of each top level `@type`.
204+
$types = preg_split('/\R+ @type /', $text);
205+
206+
if ($types === false) {
207+
return null;
208+
}
209+
210+
unset($types[0]);
211+
$elements = [];
212+
213+
foreach ($types as $typeTag) {
214+
$parts = preg_split('#\s+#', trim($typeTag));
215+
216+
if ($parts === false || count($parts) < 2) {
217+
return null;
218+
}
219+
220+
list($type, $name) = $parts;
221+
222+
// Bail out completely if any element doesn't have a static key.
223+
if (strpos($name, '...$') !== false) {
224+
return null;
225+
}
226+
227+
// Bail out completely if the name of any element is invalid.
228+
if (strpos($name, '$') !== 0) {
229+
return null;
230+
}
231+
232+
$elements[] = sprintf(
233+
'%1$s%2$s: %3$s',
234+
substr($name, 1),
235+
$optional ? '?' : '',
236+
$type
237+
);
238+
}
239+
240+
return $elements;
241+
}
242+
};

0 commit comments

Comments
 (0)