php-parser-py 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- php_parser_py/__init__.py +21 -31
- php_parser_py/_ast.py +180 -200
- php_parser_py/_edge.py +9 -43
- php_parser_py/_exceptions.py +12 -0
- php_parser_py/_modifier.py +140 -0
- php_parser_py/_node.py +133 -28
- php_parser_py/_parser.py +212 -212
- php_parser_py/_printer.py +2 -2
- php_parser_py/_resources.py +7 -14
- php_parser_py/_runner.py +5 -5
- {php_parser_py-1.2.0.dist-info → php_parser_py-1.2.2.dist-info}/METADATA +20 -19
- php_parser_py-1.2.2.dist-info/RECORD +17 -0
- php_parser_py-1.2.0.dist-info/RECORD +0 -16
- {php_parser_py-1.2.0.dist-info → php_parser_py-1.2.2.dist-info}/WHEEL +0 -0
php_parser_py/__init__.py
CHANGED
|
@@ -5,40 +5,22 @@ This package provides a Python interface to PHP-Parser, enabling AST parsing
|
|
|
5
5
|
and manipulation of PHP code.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Callable
|
|
13
|
-
|
|
14
|
-
from php_parser_py._parser import Parser as _Parser
|
|
15
|
-
from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
|
|
16
|
-
from php_parser_py._resources import ensure_php_parser_extracted
|
|
11
|
+
from ._resources import ensure_php_parser_extracted
|
|
17
12
|
|
|
18
13
|
# Ensure PHP-Parser is extracted on first import
|
|
19
14
|
ensure_php_parser_extracted()
|
|
20
15
|
|
|
21
16
|
# Import and expose public classes directly
|
|
22
|
-
from
|
|
23
|
-
from
|
|
24
|
-
from
|
|
25
|
-
from
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"""Parses PHP source code using PHP-Parser.
|
|
30
|
-
|
|
31
|
-
Invokes PHP-Parser to parse PHP code and converts the resulting JSON
|
|
32
|
-
to cpg2py Storage format for graph-based analysis.
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class PrettyPrinter(_PrettyPrinter):
|
|
37
|
-
"""Converts AST to PHP code using PHP-Parser's PrettyPrinter.
|
|
38
|
-
|
|
39
|
-
Reconstructs JSON from AST graph and invokes PHP-Parser to generate
|
|
40
|
-
formatted PHP source code.
|
|
41
|
-
"""
|
|
17
|
+
from ._ast import AST
|
|
18
|
+
from ._edge import Edge
|
|
19
|
+
from ._exceptions import NodeNotInFileError, ParseError, RunnerError
|
|
20
|
+
from ._modifier import Modifier
|
|
21
|
+
from ._node import Node
|
|
22
|
+
from ._parser import Parser
|
|
23
|
+
from ._printer import PrettyPrinter
|
|
42
24
|
|
|
43
25
|
|
|
44
26
|
def parse_code(code: str) -> list[Node]:
|
|
@@ -79,9 +61,13 @@ def parse_file(path: str) -> AST:
|
|
|
79
61
|
return parser.parse_file(path)
|
|
80
62
|
|
|
81
63
|
|
|
64
|
+
def _default_file_filter(path: Path) -> bool:
|
|
65
|
+
return path.suffix == ".php"
|
|
66
|
+
|
|
67
|
+
|
|
82
68
|
def parse_project(
|
|
83
69
|
project_path: str,
|
|
84
|
-
file_filter:
|
|
70
|
+
file_filter: Callable[[Path], bool] | None = None,
|
|
85
71
|
) -> AST:
|
|
86
72
|
"""Parse all PHP files in a project directory into an AST.
|
|
87
73
|
|
|
@@ -91,7 +77,7 @@ def parse_project(
|
|
|
91
77
|
Args:
|
|
92
78
|
project_path: Project root directory path.
|
|
93
79
|
file_filter: Function to filter files. Takes a Path and returns
|
|
94
|
-
True if the file should be parsed. Defaults to
|
|
80
|
+
True if the file should be parsed. Defaults to .php suffix.
|
|
95
81
|
|
|
96
82
|
Returns:
|
|
97
83
|
AST instance with project -> files -> statements hierarchy.
|
|
@@ -101,6 +87,8 @@ def parse_project(
|
|
|
101
87
|
FileNotFoundError: If project directory does not exist.
|
|
102
88
|
ValueError: If project_path is not a directory.
|
|
103
89
|
"""
|
|
90
|
+
if file_filter is None:
|
|
91
|
+
file_filter = _default_file_filter
|
|
104
92
|
parser = Parser()
|
|
105
93
|
return parser.parse_project(project_path, file_filter=file_filter)
|
|
106
94
|
|
|
@@ -108,12 +96,14 @@ def parse_project(
|
|
|
108
96
|
__version__ = "0.1.0"
|
|
109
97
|
__all__ = [
|
|
110
98
|
"AST",
|
|
111
|
-
"Node",
|
|
112
99
|
"Edge",
|
|
100
|
+
"Modifier",
|
|
101
|
+
"Node",
|
|
113
102
|
"Parser",
|
|
114
103
|
"PrettyPrinter",
|
|
115
104
|
"ParseError",
|
|
116
105
|
"RunnerError",
|
|
106
|
+
"NodeNotInFileError",
|
|
117
107
|
"parse_code",
|
|
118
108
|
"parse_file",
|
|
119
109
|
"parse_project",
|
php_parser_py/_ast.py
CHANGED
|
@@ -4,11 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any
|
|
8
8
|
|
|
9
9
|
from cpg2py import AbcGraphQuerier, Storage
|
|
10
10
|
|
|
11
11
|
from php_parser_py._edge import Edge
|
|
12
|
+
from php_parser_py._exceptions import NodeNotInFileError
|
|
12
13
|
from php_parser_py._node import Node
|
|
13
14
|
|
|
14
15
|
logger = logging.getLogger(__name__)
|
|
@@ -30,10 +31,11 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
30
31
|
Edge: The concrete edge type for parent-child relationships
|
|
31
32
|
|
|
32
33
|
Provides graph traversal methods, JSON reconstruction for code generation,
|
|
33
|
-
and AST merging capabilities.
|
|
34
|
+
and AST merging capabilities. Uses cpg2py graph API (nodes, edges, succ,
|
|
35
|
+
prev, ancestors, descendants); storage is only used where the graph API
|
|
36
|
+
does not provide an alternative (node/edge existence and Node/Edge construction).
|
|
34
37
|
|
|
35
38
|
Attributes:
|
|
36
|
-
_storage: cpg2py Storage containing AST nodes and edges.
|
|
37
39
|
_root_node_id: ID of the root project node (always "project").
|
|
38
40
|
"""
|
|
39
41
|
|
|
@@ -45,23 +47,25 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
45
47
|
root_node_id: ID of the root node. Defaults to "project".
|
|
46
48
|
"""
|
|
47
49
|
super().__init__(storage)
|
|
48
|
-
self._storage = storage
|
|
49
50
|
self._root_node_id = root_node_id
|
|
50
51
|
|
|
51
|
-
def node(self, whose_id_is: str) ->
|
|
52
|
+
def node(self, whose_id_is: str) -> Node:
|
|
52
53
|
"""Return node wrapper by ID.
|
|
53
54
|
|
|
54
55
|
Args:
|
|
55
56
|
whose_id_is: Node ID string.
|
|
56
57
|
|
|
57
58
|
Returns:
|
|
58
|
-
Node instance
|
|
59
|
+
Node instance for the given ID.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
KeyError: If the node ID is not in the graph.
|
|
59
63
|
"""
|
|
60
|
-
if not self.
|
|
61
|
-
|
|
62
|
-
return Node(self.
|
|
64
|
+
if not self.storage.contains_node(whose_id_is):
|
|
65
|
+
raise KeyError(f"Node not found: {whose_id_is!r}")
|
|
66
|
+
return Node(self.storage, whose_id_is)
|
|
63
67
|
|
|
64
|
-
def edge(self, fid: str, tid: str, eid: str) ->
|
|
68
|
+
def edge(self, fid: str, tid: str, eid: str) -> Edge:
|
|
65
69
|
"""Return edge wrapper by IDs.
|
|
66
70
|
|
|
67
71
|
Args:
|
|
@@ -70,24 +74,31 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
70
74
|
eid: Edge type.
|
|
71
75
|
|
|
72
76
|
Returns:
|
|
73
|
-
Edge instance
|
|
77
|
+
Edge instance for the given (from, to, type).
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
KeyError: If the edge is not in the graph.
|
|
74
81
|
"""
|
|
75
82
|
edge_id = (fid, tid, eid)
|
|
76
|
-
if not self.
|
|
77
|
-
|
|
78
|
-
return Edge(self.
|
|
83
|
+
if not self.storage.contains_edge(edge_id):
|
|
84
|
+
raise KeyError(f"Edge not found: {edge_id!r}")
|
|
85
|
+
return Edge(self.storage, fid, tid, eid)
|
|
79
86
|
|
|
80
|
-
def project_node(self) ->
|
|
81
|
-
"""Return the project node.
|
|
87
|
+
def project_node(self) -> Node:
|
|
88
|
+
"""Return the project node (root of the AST).
|
|
82
89
|
|
|
83
|
-
The project node is
|
|
90
|
+
The project node has ID "project" and is always present when the AST
|
|
91
|
+
was created by parse_file or parse_project.
|
|
84
92
|
|
|
85
93
|
Returns:
|
|
86
|
-
Project Node instance
|
|
94
|
+
Project Node instance.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
KeyError: If the root node is not in the graph.
|
|
87
98
|
"""
|
|
88
|
-
if not self.
|
|
89
|
-
|
|
90
|
-
return Node(self.
|
|
99
|
+
if not self.storage.contains_node(self._root_node_id):
|
|
100
|
+
raise KeyError(f"Project node not found: {self._root_node_id!r}")
|
|
101
|
+
return Node(self.storage, self._root_node_id)
|
|
91
102
|
|
|
92
103
|
def file_nodes(self) -> list[Node]:
|
|
93
104
|
"""Return all file nodes in the project.
|
|
@@ -95,86 +106,79 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
95
106
|
Returns:
|
|
96
107
|
List of File Node instances.
|
|
97
108
|
"""
|
|
98
|
-
|
|
109
|
+
try:
|
|
110
|
+
project = self.project_node()
|
|
111
|
+
except KeyError:
|
|
99
112
|
return []
|
|
100
113
|
|
|
101
|
-
file_nodes = []
|
|
102
|
-
|
|
103
|
-
from_id, to_id, edge_type = edge_id
|
|
104
|
-
if from_id == self._root_node_id and edge_type == "PARENT_OF":
|
|
105
|
-
file_node = self.node(to_id)
|
|
106
|
-
if file_node and file_node.get_property("nodeType") == "File":
|
|
107
|
-
file_nodes.append(file_node)
|
|
108
|
-
|
|
109
|
-
# Sort by file path for consistent ordering
|
|
110
|
-
return sorted(file_nodes, key=lambda n: n.get_property("filePath", ""))
|
|
114
|
+
file_nodes = [n for n in self.succ(project) if n.node_type == "File"]
|
|
115
|
+
return sorted(file_nodes, key=lambda n: n.get("absolutePath", ""))
|
|
111
116
|
|
|
112
|
-
def
|
|
113
|
-
self, node_id: str
|
|
114
|
-
) -> Optional[Node]: # pylint: disable=too-many-return-statements
|
|
117
|
+
def get_file_node(self, node_id: str) -> Node:
|
|
115
118
|
"""Get the file node that contains the given node.
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
Uses the AST ID convention: file node ID is the file hash (e.g. 8-char hex);
|
|
121
|
+
nodes inside that file have ID ``file_hash + "_" + increment`` (e.g. ``a1b2c3d4_1``).
|
|
122
|
+
Resolves by structure first, then falls back to ancestors() traversal.
|
|
119
123
|
|
|
120
124
|
Args:
|
|
121
125
|
node_id: ID of any node in the AST.
|
|
122
126
|
|
|
123
127
|
Returns:
|
|
124
|
-
File Node instance containing the given node
|
|
125
|
-
- Node ID doesn't exist
|
|
126
|
-
- Node is the project node (project doesn't belong to a file)
|
|
127
|
-
- Node is not under any file node
|
|
128
|
-
"""
|
|
129
|
-
if not self._storage.contains_node(node_id):
|
|
130
|
-
return None
|
|
128
|
+
File Node instance containing the given node.
|
|
131
129
|
|
|
132
|
-
|
|
130
|
+
Raises:
|
|
131
|
+
KeyError: If the node ID is not in the graph.
|
|
132
|
+
NodeNotInFileError: If the node is not under any file (e.g. project node).
|
|
133
|
+
"""
|
|
133
134
|
node = self.node(node_id)
|
|
134
|
-
if node
|
|
135
|
+
if node.node_type == "File":
|
|
135
136
|
return node
|
|
136
137
|
|
|
137
|
-
# Check if it's the project node
|
|
138
138
|
if node_id == self._root_node_id:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# Traverse upward via PARENT_OF edges to find File node
|
|
142
|
-
current_id = node_id
|
|
143
|
-
visited = set()
|
|
139
|
+
raise NodeNotInFileError(node_id, "Project node has no containing file.")
|
|
144
140
|
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
result = self._try_file_by_id_prefix(node_id)
|
|
142
|
+
if result is not None:
|
|
143
|
+
return result
|
|
147
144
|
|
|
148
|
-
|
|
149
|
-
parent_id = None
|
|
150
|
-
for edge_id in self._storage.get_edges():
|
|
151
|
-
from_id, to_id, edge_type = edge_id
|
|
152
|
-
if to_id == current_id and edge_type == "PARENT_OF":
|
|
153
|
-
parent_id = from_id
|
|
154
|
-
break
|
|
145
|
+
return self._find_file_ancestor(node)
|
|
155
146
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return None
|
|
147
|
+
def _try_file_by_id_prefix(self, node_id: str) -> Node | None:
|
|
148
|
+
"""Try to find file node by ID prefix convention.
|
|
159
149
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
150
|
+
If node_id is like "hash_123", attempts to get file node with ID "hash".
|
|
151
|
+
Returns None if ID doesn't match convention or file not found.
|
|
152
|
+
"""
|
|
153
|
+
if "_" not in node_id:
|
|
154
|
+
return None
|
|
164
155
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
elif node_type == "Project":
|
|
169
|
-
# Reached project, no file found
|
|
170
|
-
return None
|
|
156
|
+
prefix, rest = node_id.split("_", 1)
|
|
157
|
+
if not rest.isdigit():
|
|
158
|
+
return None
|
|
171
159
|
|
|
172
|
-
|
|
173
|
-
|
|
160
|
+
try:
|
|
161
|
+
candidate = self.node(prefix)
|
|
162
|
+
if candidate.node_type == "File":
|
|
163
|
+
return candidate
|
|
164
|
+
except KeyError:
|
|
165
|
+
pass
|
|
174
166
|
|
|
175
167
|
return None
|
|
176
168
|
|
|
177
|
-
def
|
|
169
|
+
def _find_file_ancestor(self, node: Node) -> Node:
|
|
170
|
+
"""Find first File ancestor of a node via traversal.
|
|
171
|
+
|
|
172
|
+
Raises NodeNotInFileError if no File ancestor exists.
|
|
173
|
+
"""
|
|
174
|
+
for ancestor in self.ancestors(node):
|
|
175
|
+
if ancestor.node_type == "File":
|
|
176
|
+
result: Node = ancestor
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
raise NodeNotInFileError(node.id, "No File node among ancestors.")
|
|
180
|
+
|
|
181
|
+
def to_json(self, file_hash: str | None = None) -> str:
|
|
178
182
|
"""Reconstruct PHP-Parser JSON from Storage for code generation.
|
|
179
183
|
|
|
180
184
|
If file_hash is provided, exports only that file's statements.
|
|
@@ -187,10 +191,8 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
187
191
|
JSON string compatible with PHP-Parser's JsonDecoder.
|
|
188
192
|
"""
|
|
189
193
|
if file_hash:
|
|
190
|
-
# Export single file
|
|
191
|
-
|
|
192
|
-
if not file_node:
|
|
193
|
-
return json.dumps([])
|
|
194
|
+
# Export single file (node() raises KeyError if file_hash not in graph)
|
|
195
|
+
self.node(file_hash)
|
|
194
196
|
top_level_nodes = self._get_file_statements(file_hash)
|
|
195
197
|
else:
|
|
196
198
|
# Try to export from file structure first
|
|
@@ -201,53 +203,34 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
201
203
|
for file_node in file_nodes:
|
|
202
204
|
top_level_nodes.extend(self._get_file_statements(file_node.id))
|
|
203
205
|
else:
|
|
204
|
-
# No file structure - find root nodes (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
for edge_id in self._storage.get_edges():
|
|
210
|
-
_, to_id, edge_type = edge_id
|
|
211
|
-
if edge_type == "PARENT_OF":
|
|
212
|
-
nodes_with_parents.add(to_id)
|
|
213
|
-
|
|
206
|
+
# No file structure - find root nodes via graph API (no incoming PARENT_OF)
|
|
207
|
+
all_nodes = {n.id for n in self.nodes()}
|
|
208
|
+
nodes_with_parents = {
|
|
209
|
+
e.to_nid for e in self.edges() if e.type == "PARENT_OF"
|
|
210
|
+
}
|
|
214
211
|
root_nodes = all_nodes - nodes_with_parents
|
|
215
|
-
# Exclude project node if present
|
|
216
212
|
root_nodes.discard(self._root_node_id)
|
|
217
|
-
top_level_nodes = sorted(
|
|
213
|
+
top_level_nodes = sorted(root_nodes)
|
|
218
214
|
|
|
219
215
|
result = [self._reconstruct_node(nid) for nid in top_level_nodes]
|
|
220
216
|
return json.dumps(result)
|
|
221
217
|
|
|
222
218
|
def _get_file_statements(self, file_hash: str) -> list[str]:
|
|
223
|
-
"""Get top-level statement
|
|
224
|
-
|
|
225
|
-
Args:
|
|
226
|
-
file_hash: File node ID (hash).
|
|
219
|
+
"""Get top-level statement node IDs for a file (direct children with edge field \"stmts\").
|
|
227
220
|
|
|
228
|
-
|
|
229
|
-
List of statement node IDs.
|
|
221
|
+
Uses succ() traversal; statement IDs follow the convention file_hash_1, file_hash_2, ...
|
|
230
222
|
"""
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if edge_props:
|
|
243
|
-
return edge_props.get("index", 999999)
|
|
244
|
-
return 999999
|
|
245
|
-
|
|
246
|
-
return sorted(statement_nodes, key=get_index)
|
|
247
|
-
|
|
248
|
-
def _reconstruct_node( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
|
249
|
-
self, nid: str
|
|
250
|
-
) -> dict[str, Any]:
|
|
223
|
+
file_node = self.node(file_hash)
|
|
224
|
+
stmts_with_index = []
|
|
225
|
+
for child in self.succ(file_node):
|
|
226
|
+
edge = self.edge(file_hash, child.id, "PARENT_OF")
|
|
227
|
+
if edge.get("field") == "stmts":
|
|
228
|
+
idx = edge.get("index")
|
|
229
|
+
stmts_with_index.append((999999 if idx is None else idx, child.id))
|
|
230
|
+
stmts_with_index.sort(key=lambda t: t[0])
|
|
231
|
+
return [nid for _, nid in stmts_with_index]
|
|
232
|
+
|
|
233
|
+
def _reconstruct_node(self, nid: str) -> dict[str, Any]:
|
|
251
234
|
"""Recursively reconstruct JSON object for a node.
|
|
252
235
|
|
|
253
236
|
Args:
|
|
@@ -256,106 +239,103 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
256
239
|
Returns:
|
|
257
240
|
Dictionary representing the node in PHP-Parser JSON format.
|
|
258
241
|
"""
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
242
|
+
node = self.node(nid)
|
|
243
|
+
props = node.all_properties
|
|
244
|
+
result: dict[str, Any] = {"nodeType": node.node_type}
|
|
245
|
+
|
|
246
|
+
attributes = self._extract_attributes(props)
|
|
247
|
+
if attributes:
|
|
248
|
+
result["attributes"] = attributes
|
|
263
249
|
|
|
264
|
-
result
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
result["nodeType"] = node_type
|
|
250
|
+
self._add_non_attribute_props(result, props)
|
|
251
|
+
self._reconstruct_child_fields(result, nid)
|
|
252
|
+
self._add_default_attrs(result, node.node_type)
|
|
268
253
|
|
|
269
|
-
|
|
270
|
-
attributes = {}
|
|
271
|
-
child_fields: dict[str, Any] = {}
|
|
254
|
+
return result
|
|
272
255
|
|
|
256
|
+
def _extract_attributes(self, props: dict[str, Any]) -> dict[str, Any]:
|
|
257
|
+
"""Extract metadata attributes from node properties.
|
|
258
|
+
|
|
259
|
+
Returns dict with position and comment metadata.
|
|
260
|
+
"""
|
|
261
|
+
attr_keys = {
|
|
262
|
+
"startLine",
|
|
263
|
+
"endLine",
|
|
264
|
+
"startFilePos",
|
|
265
|
+
"endFilePos",
|
|
266
|
+
"startTokenPos",
|
|
267
|
+
"endTokenPos",
|
|
268
|
+
"kind",
|
|
269
|
+
"comments",
|
|
270
|
+
}
|
|
271
|
+
return {k: v for k, v in props.items() if k in attr_keys}
|
|
272
|
+
|
|
273
|
+
def _add_non_attribute_props(
|
|
274
|
+
self, result: dict[str, Any], props: dict[str, Any]
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Add non-attribute, non-nodeType properties to result."""
|
|
277
|
+
attr_keys = {
|
|
278
|
+
"nodeType",
|
|
279
|
+
"startLine",
|
|
280
|
+
"endLine",
|
|
281
|
+
"startFilePos",
|
|
282
|
+
"endFilePos",
|
|
283
|
+
"startTokenPos",
|
|
284
|
+
"endTokenPos",
|
|
285
|
+
"kind",
|
|
286
|
+
"comments",
|
|
287
|
+
}
|
|
273
288
|
for key, value in props.items():
|
|
274
|
-
if key
|
|
275
|
-
continue
|
|
276
|
-
# Attributes are typically line numbers and metadata
|
|
277
|
-
if key in (
|
|
278
|
-
"startLine",
|
|
279
|
-
"endLine",
|
|
280
|
-
"startFilePos",
|
|
281
|
-
"endFilePos",
|
|
282
|
-
"startTokenPos",
|
|
283
|
-
"endTokenPos",
|
|
284
|
-
"kind",
|
|
285
|
-
"comments",
|
|
286
|
-
):
|
|
287
|
-
attributes[key] = value
|
|
288
|
-
else:
|
|
289
|
-
# All other fields go directly in result
|
|
290
|
-
# Child nodes will be added separately via edges
|
|
289
|
+
if key not in attr_keys:
|
|
291
290
|
result[key] = value
|
|
292
291
|
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
def _reconstruct_child_fields(self, result: dict[str, Any], nid: str) -> None:
|
|
293
|
+
"""Reconstruct and add child fields to result.
|
|
295
294
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if eid[0] == nid and eid[2] == "PARENT_OF"
|
|
301
|
-
]
|
|
295
|
+
Child nodes are fetched via succ() and indexed by edge properties.
|
|
296
|
+
"""
|
|
297
|
+
node = self.node(nid)
|
|
298
|
+
child_fields: dict[str, dict[int, Any]] = {}
|
|
302
299
|
|
|
303
|
-
for
|
|
304
|
-
|
|
305
|
-
|
|
300
|
+
for child in self.succ(node):
|
|
301
|
+
try:
|
|
302
|
+
edge = self.edge(nid, child.id, "PARENT_OF")
|
|
303
|
+
except KeyError:
|
|
306
304
|
continue
|
|
307
305
|
|
|
308
|
-
field_name =
|
|
306
|
+
field_name = edge.get("field")
|
|
309
307
|
if field_name is None:
|
|
310
308
|
continue
|
|
311
309
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
# Array field
|
|
321
|
-
if field_name not in child_fields:
|
|
322
|
-
child_fields[field_name] = {}
|
|
323
|
-
child_fields[field_name][index] = child_json
|
|
324
|
-
else:
|
|
325
|
-
# Single object field
|
|
326
|
-
result[field_name] = child_json
|
|
310
|
+
child_json = self._reconstruct_node(child.id)
|
|
311
|
+
index = edge.get("index")
|
|
312
|
+
|
|
313
|
+
if field_name not in child_fields:
|
|
314
|
+
child_fields[field_name] = {}
|
|
315
|
+
|
|
316
|
+
if index is not None:
|
|
317
|
+
child_fields[field_name][index] = child_json
|
|
327
318
|
else:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
index = edge_props.get("index")
|
|
331
|
-
|
|
332
|
-
if index is not None:
|
|
333
|
-
# Array field
|
|
334
|
-
if field_name not in child_fields:
|
|
335
|
-
child_fields[field_name] = {}
|
|
336
|
-
child_fields[field_name][index] = child_json
|
|
337
|
-
else:
|
|
338
|
-
# Single object field
|
|
339
|
-
result[field_name] = child_json
|
|
340
|
-
|
|
341
|
-
# Convert indexed dictionaries to arrays
|
|
319
|
+
result[field_name] = child_json
|
|
320
|
+
|
|
342
321
|
for field_name, indexed_children in child_fields.items():
|
|
343
|
-
if
|
|
344
|
-
# Sort by index and create array
|
|
322
|
+
if indexed_children:
|
|
345
323
|
max_index = max(indexed_children.keys())
|
|
346
324
|
array = [None] * (max_index + 1)
|
|
347
325
|
for idx, child in indexed_children.items():
|
|
348
326
|
array[idx] = child
|
|
349
327
|
result[field_name] = array
|
|
350
328
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
node_type.startswith("Stmt_")
|
|
355
|
-
or node_type == "Param"
|
|
356
|
-
or node_type.startswith("Expr_Closure")
|
|
357
|
-
or node_type.startswith("Expr_ArrowFunction")
|
|
358
|
-
):
|
|
359
|
-
result["attrGroups"] = []
|
|
329
|
+
def _add_default_attrs(self, result: dict[str, Any], node_type: str) -> None:
|
|
330
|
+
"""Add default attrGroups if not already present.
|
|
360
331
|
|
|
361
|
-
|
|
332
|
+
PHP-Parser expects attrGroups on certain node types.
|
|
333
|
+
"""
|
|
334
|
+
if "attrGroups" in result:
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
if any(
|
|
338
|
+
node_type.startswith(prefix)
|
|
339
|
+
for prefix in ("Stmt_", "Expr_Closure", "Expr_ArrowFunction")
|
|
340
|
+
) or node_type == "Param":
|
|
341
|
+
result["attrGroups"] = []
|