Skip to content

Commit 31332b0

Browse files
committed
fix: resolve namespaced names, aliases, and FQ hooks like the legacy parser
Mining upstream PR WordPress#247 surfaced four name-resolution gaps in namespaced code (which in wp-includes means the bundled SimplePie/Requests/PHPMailer libraries dd32 diffed): - Method namespace now reports its enclosing namespace (My\Plugin), not '' — '' is correct only for the global namespace. - Exported namespace aliases on functions and methods are fully-qualified ("\Other\Thing"), matching the legacy output (dd32's leading-backslash note). - Function-use names resolve like the legacy parser: unqualified global- fallback calls stay bare (count), while fully-qualified, qualified, and use-function-imported calls become a leading-backslash FQN (\do_action, \Other\helper, \My\Plugin\Sub\thing). The previous code read the wrong resolver attribute and left them all bare. - A fully-qualified \do_action is a plain function call, not a hook. Locked with a golden fixture (namespaced-uses.inc, minted from the legacy parser on PHP 7.4) and a unit test.
1 parent a0058f2 commit 31332b0

6 files changed

Lines changed: 269 additions & 10 deletions

File tree

lib/class-file-reflector.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,9 @@ protected function set_called_in_class( Node\Stmt\Class_ $node, Class_Reflector
315315
* @return bool
316316
*/
317317
protected function is_filter( Node\Expr\FuncCall $node ) {
318-
if ( ! ( $node->name instanceof Node\Name ) ) {
318+
// A fully-qualified call (\do_action) is a plain function call, not a hook —
319+
// the legacy parser only recognised the unqualified hook function names.
320+
if ( ! ( $node->name instanceof Node\Name ) || $node->name instanceof Node\Name\FullyQualified ) {
319321
return false;
320322
}
321323

lib/class-function-call-reflector.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,27 @@ public function __construct( Node\Expr\FuncCall $node ) {
2121
/**
2222
* The called function's name.
2323
*
24-
* Namespaced calls resolve to a leading-backslash FQN (via the NameResolver
25-
* namespacedName attribute); global calls stay unqualified, matching the
26-
* legacy output (e.g. apply_filters, do_action).
24+
* Reproduces the legacy resolution: an unqualified name that resolves to itself
25+
* is a global-fallback call and stays bare (count, apply_filters); a fully-
26+
* qualified, qualified, or use-function-imported name resolves to a leading-
27+
* backslash FQN (\do_action, \Other\helper, \My\Plugin\Sub\thing).
2728
*
2829
* @return string
2930
*/
3031
public function getName() {
3132
$name = $this->node->name;
3233

3334
if ( $name instanceof Node\Name ) {
34-
$namespaced = $name->getAttribute( 'namespacedName' );
35-
if ( null !== $namespaced ) {
36-
return '\\' . $namespaced->toString();
35+
$resolved = $name->getAttribute( 'resolvedName' );
36+
37+
// Unqualified names resolving to themselves stay bare (global fallback).
38+
if ( $name->isUnqualified()
39+
&& ( null === $resolved || $resolved->toString() === $name->toString() ) ) {
40+
return $name->toString();
41+
}
42+
43+
if ( null !== $resolved ) {
44+
return '\\' . $resolved->toString();
3745
}
3846

3947
return $name->toString();

lib/class-reflectors.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ public static function class_name( $name ) {
123123
return '\\' . ( $resolved ? $resolved->toString() : $name->toString() );
124124
}
125125

126+
/**
127+
* Render a namespace-alias map for export, fully-qualifying each target with a
128+
* leading backslash (alias => "\Fully\Qualified"), matching the legacy output.
129+
*
130+
* @param array $aliases Map of alias => fully-qualified name (no leading slash).
131+
* @return array
132+
*/
133+
public static function export_aliases( array $aliases ) {
134+
$out = array();
135+
foreach ( $aliases as $alias => $fqn ) {
136+
$out[ $alias ] = '\\' . ltrim( $fqn, '\\' );
137+
}
138+
139+
return $out;
140+
}
141+
126142
/**
127143
* Build Argument_Reflectors for a list of parameters.
128144
*
@@ -197,7 +213,7 @@ public function getNamespace() {
197213
}
198214

199215
public function getNamespaceAliases() {
200-
return $this->aliases;
216+
return Reflector_Helpers::export_aliases( $this->aliases );
201217
}
202218

203219
public function getLineNumber() {
@@ -326,11 +342,13 @@ public function getShortName() {
326342
}
327343

328344
public function getNamespace() {
329-
return ''; // Methods carry no namespace in the legacy output.
345+
// A method carries its enclosing namespace; the global namespace is reported
346+
// as '' (not 'global', unlike functions/classes), matching the legacy output.
347+
return 'global' === $this->resolve_namespace ? '' : $this->resolve_namespace;
330348
}
331349

332350
public function getNamespaceAliases() {
333-
return $this->aliases;
351+
return Reflector_Helpers::export_aliases( $this->aliases );
334352
}
335353

336354
public function getLineNumber() {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
[
2+
{
3+
"file": {
4+
"description": "Namespaced name-resolution fixture.",
5+
"long_description": "",
6+
"tags": []
7+
},
8+
"path": "namespaced-uses.inc",
9+
"root": "{{ROOT}}",
10+
"functions": [
11+
{
12+
"name": "namespaced_func",
13+
"namespace": "My\\Plugin",
14+
"aliases": {
15+
"Thing": "\\Other\\Thing",
16+
"Aliased": "\\Some\\Long\\ClassName",
17+
"helper": "\\Other\\helper"
18+
},
19+
"line": 30,
20+
"end_line": 32,
21+
"arguments": [
22+
{
23+
"name": "$t",
24+
"default": null,
25+
"type": "\\Other\\Thing"
26+
}
27+
],
28+
"doc": {
29+
"description": "",
30+
"long_description": "",
31+
"tags": []
32+
},
33+
"hooks": [],
34+
"uses": {
35+
"methods": [
36+
{
37+
"name": "create",
38+
"class": "\\Some\\Long\\ClassName",
39+
"static": true,
40+
"line": 31,
41+
"end_line": 31
42+
}
43+
]
44+
}
45+
}
46+
],
47+
"classes": [
48+
{
49+
"name": "Widget",
50+
"namespace": "My\\Plugin",
51+
"line": 12,
52+
"end_line": 28,
53+
"final": false,
54+
"abstract": false,
55+
"extends": "\\WP_Widget",
56+
"implements": [
57+
"\\Other\\Thing"
58+
],
59+
"properties": [],
60+
"methods": [
61+
{
62+
"name": "render",
63+
"namespace": "My\\Plugin",
64+
"aliases": {
65+
"Thing": "\\Other\\Thing",
66+
"Aliased": "\\Some\\Long\\ClassName",
67+
"helper": "\\Other\\helper"
68+
},
69+
"line": 19,
70+
"end_line": 27,
71+
"final": false,
72+
"abstract": false,
73+
"static": false,
74+
"visibility": "public",
75+
"arguments": [
76+
{
77+
"name": "$t",
78+
"default": null,
79+
"type": "\\Other\\Thing"
80+
}
81+
],
82+
"doc": {
83+
"description": "Render.",
84+
"long_description": "",
85+
"tags": [
86+
{
87+
"name": "param",
88+
"content": "A thing.",
89+
"types": [
90+
"\\Other\\Thing"
91+
],
92+
"variable": "$t"
93+
},
94+
{
95+
"name": "return",
96+
"content": "",
97+
"types": [
98+
"\\Some\\Long\\ClassName"
99+
]
100+
}
101+
]
102+
},
103+
"uses": {
104+
"methods": [
105+
{
106+
"name": "__construct",
107+
"class": "\\Some\\Long\\ClassName",
108+
"static": false,
109+
"line": 20,
110+
"end_line": 20
111+
},
112+
{
113+
"name": "make",
114+
"class": "\\Other\\Thing",
115+
"static": true,
116+
"line": 21,
117+
"end_line": 21
118+
},
119+
{
120+
"name": "helper",
121+
"class": "\\My\\Plugin\\Widget",
122+
"static": true,
123+
"line": 22,
124+
"end_line": 22
125+
},
126+
{
127+
"name": "widget",
128+
"class": "\\WP_Widget",
129+
"static": true,
130+
"line": 23,
131+
"end_line": 23
132+
},
133+
{
134+
"name": "__construct",
135+
"class": "\\DateTime",
136+
"static": false,
137+
"line": 26,
138+
"end_line": 26
139+
}
140+
],
141+
"functions": [
142+
{
143+
"name": "\\do_action",
144+
"line": 24,
145+
"end_line": 24
146+
},
147+
{
148+
"name": "\\Other\\helper",
149+
"line": 25,
150+
"end_line": 25
151+
}
152+
]
153+
}
154+
}
155+
],
156+
"doc": {
157+
"description": "",
158+
"long_description": "",
159+
"tags": []
160+
}
161+
}
162+
]
163+
}
164+
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
/**
3+
* Namespaced name-resolution fixture.
4+
*/
5+
6+
namespace My\Plugin;
7+
8+
use Other\Thing;
9+
use Some\Long\ClassName as Aliased;
10+
use function Other\helper;
11+
12+
class Widget extends \WP_Widget implements Thing {
13+
/**
14+
* Render.
15+
*
16+
* @param Thing $t A thing.
17+
* @return Aliased
18+
*/
19+
public function render( Thing $t ) {
20+
$x = new Aliased();
21+
$y = Thing::make();
22+
self::helper();
23+
parent::widget();
24+
\do_action( 'my_hook' );
25+
helper();
26+
return new \DateTime();
27+
}
28+
}
29+
30+
function namespaced_func( Thing $t ) {
31+
return Aliased::create();
32+
}

tests/unit/file-reflector-test.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,39 @@ public function test_file_docblock_claimed_by_first_statement() {
168168
$this->assertSame( $expected, $actual, "first statement: {$statement}" );
169169
}
170170
}
171+
172+
public function test_namespaced_function_and_hook_resolution() {
173+
// In a namespace, function-use names resolve to a leading-backslash FQN unless
174+
// they are unqualified global-fallback calls (which stay bare). A fully-
175+
// qualified \do_action is a plain function call, not a hook. (Golden coverage:
176+
// tests/.../export/namespaced-uses.)
177+
$tmp = tempnam( sys_get_temp_dir(), 'wpp' );
178+
file_put_contents(
179+
$tmp,
180+
"<?php\nnamespace My\\Ns;\nuse function Other\\helper;\nfunction caller() {\n"
181+
. "\thelper();\n\t\\do_action( 'x' );\n\tcount( \$a );\n}\n"
182+
);
183+
184+
try {
185+
$file = new File_Reflector( $tmp );
186+
$file->setFilename( 'ns.php' );
187+
$file->process();
188+
} finally {
189+
unlink( $tmp );
190+
}
191+
192+
$function = $file->getFunctions()[0];
193+
$this->assertSame( 'My\\Ns', $function->getNamespace() );
194+
195+
$names = array_map(
196+
static function ( $use ) {
197+
return $use->getName();
198+
},
199+
$function->uses['functions']
200+
);
201+
$this->assertSame( array( '\\Other\\helper', '\\do_action', 'count' ), $names );
202+
203+
// The fully-qualified \do_action is a function use, not a hook.
204+
$this->assertEmpty( isset( $function->uses['hooks'] ) ? $function->uses['hooks'] : array() );
205+
}
171206
}

0 commit comments

Comments
 (0)