Repository navigation
Expand file tree
/
Copy pathGlobalDocBlockVisitor.php
More file actions
174 lines (155 loc) · 5.83 KB
/
Copy pathGlobalDocBlockVisitor.php
File metadata and controls
174 lines (155 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
<?php
/**
* PHPStan parser node visitor that bridges WordPress core's `@global` PHPDoc
* convention to PHPStan's variable type resolution.
*
* @package WordPress
*/
declare(strict_types=1);
namespace WordPress\PHPStan;
use PhpParser\Comment\Doc;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
/**
* Reads `@global Type $varname` tags from function and method docblocks and
* injects equivalent inline `@var` docblocks onto matching `global $foo;`
* statements inside the function body.
*
* PHPStan does not consult bootstrap- or stub-declared variable types when
* resolving `global $foo;` inside functions. It only honors `@var`
* annotations placed directly on the `global` statement. WordPress core
* documents globals with `@global` tags on function docblocks instead. This
* visitor closes the gap so PHPStan can use the existing core annotations
* without each `global` statement needing its own redundant `@var`.
*
* `@global` tags in a docblock attached directly to a `global` statement are
* honored as well, and take precedence over the enclosing function's tags.
* This makes `@global` sufficient for file-scope `global` statements (e.g. in
* templates included into another scope), which have no enclosing function
* docblock at all.
*
* Functions that do not document a global, or that import a global the
* function docblock does not list, are left untouched and continue to
* resolve as `mixed` — preserving PHPStan's safety guarantees.
*
* Hand-written `@var` annotations on a `global` statement are honored
* per-variable: in `global $a, $b;`, an existing `@var Foo $a` is left
* alone, but `$b` will still receive a synthetic `@var` if the function
* documents it via `@global`.
*
* Registered as `phpstan.parser.richParserNodeVisitor` in `base.neon`.
*/
final class GlobalDocBlockVisitor extends NodeVisitorAbstract {
/**
* Stack of `@global` tag maps, one frame per enclosing function-like node.
*
* Each frame maps variable names (without `$`) to their declared type.
*
* @var list<array<non-empty-string, non-empty-string>>
*/
private array $stack = array();
/**
* Resets state at the start of each parser traversal.
*
* @param array<int, Node> $nodes Top-level nodes about to be traversed.
* @return array<int, Node>|null
*/
public function beforeTraverse( array $nodes ): ?array {
$this->stack = array();
return null;
}
/**
* Pushes a frame when entering a function/method, and injects synthetic
* `@var` doc comments on `global` statements that match a documented tag.
*
* @param Node $node The node being entered.
* @return null
*/
public function enterNode( Node $node ): ?Node {
if ( $node instanceof Node\FunctionLike ) {
$doc = $node->getDocComment();
$this->stack[] = $doc !== null ? $this->parse_global_tags( $doc->getText() ) : array();
return null;
}
if ( ! ( $node instanceof Node\Stmt\Global_ ) ) {
return null;
}
$map = $this->stack !== array() ? $this->stack[ count( $this->stack ) - 1 ] : array();
$existing = $node->getDocComment();
$existing_text = $existing !== null ? $existing->getText() : '';
/*
* Also honor `@global` tags on the docblock attached directly to the
* `global` statement itself. This covers file-scope statements (which
* have no enclosing function docblock) and takes precedence over the
* enclosing function's tags for the same variable.
*/
if ( $existing_text !== '' ) {
$map = array_merge( $map, $this->parse_global_tags( $existing_text ) );
}
if ( $map === array() ) {
return null;
}
/*
* Collect variable names that already have a handwritten `@var` on this
* statement so we can leave them alone but still inject `@var` lines for
* the remaining variables in a multi-variable `global $a, $b;` statement.
*/
$already_typed = array();
if ( $existing_text !== '' && preg_match_all( '/@(?:phpstan-)?var\s+[^\n]*?\$(\w+)/', $existing_text, $existing_matches ) > 0 ) {
$already_typed = array_flip( $existing_matches[1] );
}
$lines = array();
foreach ( $node->vars as $var ) {
if ( ! $var instanceof Node\Expr\Variable || ! is_string( $var->name ) ) {
continue;
}
if ( isset( $already_typed[ $var->name ] ) || ! isset( $map[ $var->name ] ) ) {
continue;
}
$lines[] = sprintf( ' * @var %s $%s', $map[ $var->name ], $var->name );
}
if ( $lines === array() ) {
return null;
}
if ( $existing_text === '' ) {
$node->setDocComment( new Doc( "/**\n" . implode( "\n", $lines ) . "\n */" ) );
} else {
// Insert the new `@var` lines just before the closing `*/`.
$merged = preg_replace( '#\s*\*/\s*$#', "\n" . implode( "\n", $lines ) . "\n */", $existing_text, 1 );
$node->setDocComment( new Doc( (string) $merged ) );
}
return null;
}
/**
* Pops the function-like frame on the way back up.
*
* @param Node $node The node being left.
* @return null
*/
public function leaveNode( Node $node ): ?Node {
if ( $node instanceof Node\FunctionLike ) {
array_pop( $this->stack );
}
return null;
}
/**
* Extracts `@global Type $varname` tags from a docblock.
*
* Handles union types (`A|B`) and namespaced/array forms (`A\B`, `A[]`).
* Whitespace inside the type is collapsed.
*
* @param string $docblock Raw docblock text including `/**` markers.
* @return array<non-empty-string, non-empty-string> Map of variable name (no `$`) to type.
*/
private function parse_global_tags( string $docblock ): array {
$map = array();
if ( preg_match_all( '/@global\s+(?P<type>\S.*?)\s+\$(?P<variable>\w+)/', $docblock, $matches, PREG_SET_ORDER ) > 0 ) {
foreach ( $matches as $match ) {
$type = preg_replace( '/\s+/', '', $match['type'] );
assert( is_string( $type ) && '' !== $type, 'Collapsing whitespace in a non-empty type must yield a non-empty string.' );
$map[ $match['variable'] ] = $type;
}
}
return $map;
}
}