php-parser-py 1.2.0__py3-none-any.whl → 1.2.1__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 +11 -7
- php_parser_py/_ast.py +180 -200
- php_parser_py/_edge.py +9 -43
- php_parser_py/_exceptions.py +12 -0
- php_parser_py/_node.py +133 -28
- php_parser_py/_parser.py +69 -38
- 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.1.dist-info}/METADATA +3 -1
- php_parser_py-1.2.1.dist-info/RECORD +16 -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.1.dist-info}/WHEEL +0 -0
php_parser_py/__init__.py
CHANGED
|
@@ -5,11 +5,8 @@ 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
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
13
10
|
|
|
14
11
|
from php_parser_py._parser import Parser as _Parser
|
|
15
12
|
from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
|
|
@@ -21,7 +18,7 @@ ensure_php_parser_extracted()
|
|
|
21
18
|
# Import and expose public classes directly
|
|
22
19
|
from php_parser_py._ast import AST
|
|
23
20
|
from php_parser_py._edge import Edge
|
|
24
|
-
from php_parser_py._exceptions import ParseError, RunnerError
|
|
21
|
+
from php_parser_py._exceptions import NodeNotInFileError, ParseError, RunnerError
|
|
25
22
|
from php_parser_py._node import Node
|
|
26
23
|
|
|
27
24
|
|
|
@@ -79,9 +76,13 @@ def parse_file(path: str) -> AST:
|
|
|
79
76
|
return parser.parse_file(path)
|
|
80
77
|
|
|
81
78
|
|
|
79
|
+
def _default_file_filter(path: Path) -> bool:
|
|
80
|
+
return path.suffix == ".php"
|
|
81
|
+
|
|
82
|
+
|
|
82
83
|
def parse_project(
|
|
83
84
|
project_path: str,
|
|
84
|
-
file_filter:
|
|
85
|
+
file_filter: Callable[[Path], bool] | None = None,
|
|
85
86
|
) -> AST:
|
|
86
87
|
"""Parse all PHP files in a project directory into an AST.
|
|
87
88
|
|
|
@@ -101,6 +102,8 @@ def parse_project(
|
|
|
101
102
|
FileNotFoundError: If project directory does not exist.
|
|
102
103
|
ValueError: If project_path is not a directory.
|
|
103
104
|
"""
|
|
105
|
+
if file_filter is None:
|
|
106
|
+
file_filter = _default_file_filter
|
|
104
107
|
parser = Parser()
|
|
105
108
|
return parser.parse_project(project_path, file_filter=file_filter)
|
|
106
109
|
|
|
@@ -114,6 +117,7 @@ __all__ = [
|
|
|
114
117
|
"PrettyPrinter",
|
|
115
118
|
"ParseError",
|
|
116
119
|
"RunnerError",
|
|
120
|
+
"NodeNotInFileError",
|
|
117
121
|
"parse_code",
|
|
118
122
|
"parse_file",
|
|
119
123
|
"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"] = []
|
php_parser_py/_edge.py
CHANGED
|
@@ -6,14 +6,14 @@ from cpg2py import AbcEdgeQuerier, Storage
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class Edge(AbcEdgeQuerier):
|
|
9
|
-
"""Represents an edge between AST nodes.
|
|
9
|
+
"""Represents an edge between AST nodes with generic property access.
|
|
10
10
|
|
|
11
|
-
Extends cpg2py's AbcEdgeQuerier
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
Extends cpg2py's AbcEdgeQuerier. Edge type is fixed (e.g. "PARENT_OF");
|
|
12
|
+
other data is stored as arbitrary properties via Storage. For PARENT_OF
|
|
13
|
+
edges in our PHP AST mapping, we use properties "field" and "index" to
|
|
14
|
+
represent the PHP-Parser subnode key and array position—access them via
|
|
15
|
+
edge.get("field"), edge.get("index") or edge["field"], edge["index"];
|
|
16
|
+
they are not special-cased on this class.
|
|
17
17
|
|
|
18
18
|
Attributes:
|
|
19
19
|
_storage: cpg2py Storage instance containing edge data.
|
|
@@ -39,45 +39,11 @@ class Edge(AbcEdgeQuerier):
|
|
|
39
39
|
|
|
40
40
|
@property
|
|
41
41
|
def type(self) -> str:
|
|
42
|
-
"""Return the edge type.
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
Edge type string (e.g., "PARENT_OF").
|
|
46
|
-
"""
|
|
42
|
+
"""Return the edge type (e.g. "PARENT_OF")."""
|
|
47
43
|
return self._edge_id[2]
|
|
48
44
|
|
|
49
45
|
@property
|
|
50
|
-
def
|
|
51
|
-
"""Return the field name for this edge.
|
|
52
|
-
|
|
53
|
-
The field name indicates which subnode property this edge represents
|
|
54
|
-
(e.g., "name", "params", "stmts").
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
Field name string or None.
|
|
58
|
-
"""
|
|
59
|
-
props = self._storage.get_edge_props(self._edge_id)
|
|
60
|
-
if props is None:
|
|
61
|
-
return None
|
|
62
|
-
return props.get("field")
|
|
63
|
-
|
|
64
|
-
@property
|
|
65
|
-
def index(self) -> int | None:
|
|
66
|
-
"""Return the array index for ordered child relationships.
|
|
67
|
-
|
|
68
|
-
For edges representing array elements in PHP-Parser JSON, this property
|
|
69
|
-
contains the index position to maintain ordering.
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
Integer index or None if not an array element.
|
|
73
|
-
"""
|
|
74
|
-
props = self._storage.get_edge_props(self._edge_id)
|
|
75
|
-
if props is None:
|
|
76
|
-
return None
|
|
77
|
-
return props.get("index")
|
|
78
|
-
|
|
79
|
-
@property
|
|
80
|
-
def all_properties(self) -> dict:
|
|
46
|
+
def all_properties(self) -> dict[str, Any]:
|
|
81
47
|
"""Return all edge properties.
|
|
82
48
|
|
|
83
49
|
Returns:
|
php_parser_py/_exceptions.py
CHANGED
|
@@ -21,6 +21,18 @@ class ParseError(Exception):
|
|
|
21
21
|
self.line = line
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class NodeNotInFileError(Exception):
|
|
25
|
+
"""Raised when a node has no containing file (e.g. project node or orphan).
|
|
26
|
+
|
|
27
|
+
Use when get_file(node_id) is called and the node is not under any file node.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, node_id: str, message: str | None = None) -> None:
|
|
31
|
+
self.node_id = node_id
|
|
32
|
+
self.message = message or f"Node {node_id!r} is not under any file node."
|
|
33
|
+
super().__init__(self.message)
|
|
34
|
+
|
|
35
|
+
|
|
24
36
|
class RunnerError(Exception):
|
|
25
37
|
"""Raised when PHP process execution fails.
|
|
26
38
|
|
php_parser_py/_node.py
CHANGED
|
@@ -49,19 +49,21 @@ class Node(AbcNodeQuerier):
|
|
|
49
49
|
return self._nid
|
|
50
50
|
|
|
51
51
|
@property
|
|
52
|
-
def node_type(self) -> str
|
|
52
|
+
def node_type(self) -> str:
|
|
53
53
|
"""Return the node type from PHP-Parser.
|
|
54
54
|
|
|
55
|
-
Returns the `nodeType` field from the stored JSON
|
|
56
|
-
the PHP-Parser class name (e.g., "Stmt_Function", "Expr_Variable").
|
|
55
|
+
Returns the `nodeType` field from the stored JSON (e.g. "Stmt_Function").
|
|
57
56
|
|
|
58
57
|
Returns:
|
|
59
|
-
Node type string
|
|
58
|
+
Node type string.
|
|
60
59
|
"""
|
|
61
|
-
|
|
60
|
+
value = self.get_property("nodeType")
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
return value
|
|
63
|
+
raise TypeError(f"Invalid nodeType for node {self._nid}: {value!r}")
|
|
62
64
|
|
|
63
65
|
@property
|
|
64
|
-
def all_properties(self) -> dict:
|
|
66
|
+
def all_properties(self) -> dict[str, Any]:
|
|
65
67
|
"""Return all stored properties for this node.
|
|
66
68
|
|
|
67
69
|
Includes both subnodes (structural properties) and attributes (metadata).
|
|
@@ -120,69 +122,172 @@ class Node(AbcNodeQuerier):
|
|
|
120
122
|
# PHP-Parser standard attributes (metadata properties)
|
|
121
123
|
|
|
122
124
|
@property
|
|
123
|
-
def start_line(self) -> int
|
|
125
|
+
def start_line(self) -> int:
|
|
124
126
|
"""Get starting line number.
|
|
125
127
|
|
|
126
128
|
Returns:
|
|
127
|
-
Starting line number
|
|
129
|
+
Starting line number.
|
|
128
130
|
"""
|
|
129
|
-
|
|
131
|
+
value = self.get_property("startLine")
|
|
132
|
+
if isinstance(value, int):
|
|
133
|
+
return value
|
|
134
|
+
if isinstance(value, str) and value.isdigit():
|
|
135
|
+
return int(value)
|
|
136
|
+
raise TypeError(f"Invalid startLine for node {self._nid}: {value!r}")
|
|
130
137
|
|
|
131
138
|
@property
|
|
132
|
-
def end_line(self) -> int
|
|
139
|
+
def end_line(self) -> int:
|
|
133
140
|
"""Get ending line number.
|
|
134
141
|
|
|
135
142
|
Returns:
|
|
136
|
-
Ending line number
|
|
143
|
+
Ending line number.
|
|
137
144
|
"""
|
|
138
|
-
|
|
145
|
+
value = self.get_property("endLine")
|
|
146
|
+
if isinstance(value, int):
|
|
147
|
+
return value
|
|
148
|
+
if isinstance(value, str) and value.isdigit():
|
|
149
|
+
return int(value)
|
|
150
|
+
raise TypeError(f"Invalid endLine for node {self._nid}: {value!r}")
|
|
139
151
|
|
|
140
152
|
@property
|
|
141
|
-
def start_file_pos(self) -> int
|
|
153
|
+
def start_file_pos(self) -> int:
|
|
142
154
|
"""Get starting file position (byte offset).
|
|
143
155
|
|
|
144
156
|
Returns:
|
|
145
|
-
Starting byte offset
|
|
157
|
+
Starting byte offset.
|
|
146
158
|
"""
|
|
147
|
-
|
|
159
|
+
value = self.get_property("startFilePos")
|
|
160
|
+
if isinstance(value, int):
|
|
161
|
+
return value
|
|
162
|
+
if isinstance(value, str) and value.isdigit():
|
|
163
|
+
return int(value)
|
|
164
|
+
raise TypeError(f"Invalid startFilePos for node {self._nid}: {value!r}")
|
|
148
165
|
|
|
149
166
|
@property
|
|
150
|
-
def end_file_pos(self) -> int
|
|
167
|
+
def end_file_pos(self) -> int:
|
|
151
168
|
"""Get ending file position (byte offset).
|
|
152
169
|
|
|
153
170
|
Returns:
|
|
154
|
-
Ending byte offset
|
|
171
|
+
Ending byte offset.
|
|
155
172
|
"""
|
|
156
|
-
|
|
173
|
+
value = self.get_property("endFilePos")
|
|
174
|
+
if isinstance(value, int):
|
|
175
|
+
return value
|
|
176
|
+
if isinstance(value, str) and value.isdigit():
|
|
177
|
+
return int(value)
|
|
178
|
+
raise TypeError(f"Invalid endFilePos for node {self._nid}: {value!r}")
|
|
157
179
|
|
|
158
180
|
@property
|
|
159
|
-
def start_token_pos(self) -> int
|
|
181
|
+
def start_token_pos(self) -> int:
|
|
160
182
|
"""Get starting token position.
|
|
161
183
|
|
|
162
184
|
Returns:
|
|
163
|
-
Starting token index
|
|
185
|
+
Starting token index.
|
|
164
186
|
"""
|
|
165
|
-
|
|
187
|
+
value = self.get_property("startTokenPos")
|
|
188
|
+
if isinstance(value, int):
|
|
189
|
+
return value
|
|
190
|
+
if isinstance(value, str) and value.isdigit():
|
|
191
|
+
return int(value)
|
|
192
|
+
raise TypeError(f"Invalid startTokenPos for node {self._nid}: {value!r}")
|
|
166
193
|
|
|
167
194
|
@property
|
|
168
|
-
def end_token_pos(self) -> int
|
|
195
|
+
def end_token_pos(self) -> int:
|
|
169
196
|
"""Get ending token position.
|
|
170
197
|
|
|
171
198
|
Returns:
|
|
172
|
-
Ending token index
|
|
199
|
+
Ending token index.
|
|
173
200
|
"""
|
|
174
|
-
|
|
201
|
+
value = self.get_property("endTokenPos")
|
|
202
|
+
if isinstance(value, int):
|
|
203
|
+
return value
|
|
204
|
+
if isinstance(value, str) and value.isdigit():
|
|
205
|
+
return int(value)
|
|
206
|
+
raise TypeError(f"Invalid endTokenPos for node {self._nid}: {value!r}")
|
|
175
207
|
|
|
176
208
|
@property
|
|
177
|
-
def comments(self) -> list
|
|
209
|
+
def comments(self) -> list[str]:
|
|
178
210
|
"""Get comments associated with this node.
|
|
179
211
|
|
|
180
212
|
Returns:
|
|
181
|
-
List of Comment objects
|
|
213
|
+
List of Comment objects (may be empty).
|
|
182
214
|
"""
|
|
183
|
-
|
|
215
|
+
value = self.get_property("comments")
|
|
216
|
+
if isinstance(value, list):
|
|
217
|
+
return value
|
|
218
|
+
raise TypeError(f"Invalid comments for node {self._nid}: {value!r}")
|
|
184
219
|
|
|
185
|
-
|
|
220
|
+
@property
|
|
221
|
+
def relative_path(self) -> str | None:
|
|
222
|
+
"""Get the relative path of the file containing this node.
|
|
223
|
+
|
|
224
|
+
For File or Project nodes: returns their own relativePath property if set.
|
|
225
|
+
For other nodes: resolves the containing File node and returns its relativePath.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Relative path string, or None if not available.
|
|
229
|
+
"""
|
|
230
|
+
# If this node is a File or Project, get its own relativePath
|
|
231
|
+
if self.node_type in ("File", "Project"):
|
|
232
|
+
value = self.get_property("relativePath")
|
|
233
|
+
return value if isinstance(value, str) else None
|
|
234
|
+
|
|
235
|
+
# For other nodes, try to get from containing file via ID prefix convention
|
|
236
|
+
return self._get_file_property("relativePath")
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def absolute_path(self) -> str | None:
|
|
240
|
+
"""Get the absolute path of the file containing this node.
|
|
241
|
+
|
|
242
|
+
For File or Project nodes: returns their own absolutePath property if set.
|
|
243
|
+
For other nodes: resolves the containing File node and returns its absolutePath.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Absolute path string, or None if not available.
|
|
247
|
+
"""
|
|
248
|
+
# If this node is a File or Project, get its own absolutePath
|
|
249
|
+
if self.node_type in ("File", "Project"):
|
|
250
|
+
value = self.get_property("absolutePath")
|
|
251
|
+
return value if isinstance(value, str) else None
|
|
252
|
+
|
|
253
|
+
# For other nodes, try to get from containing file via ID prefix convention
|
|
254
|
+
return self._get_file_property("absolutePath")
|
|
255
|
+
|
|
256
|
+
def _get_file_property(self, prop_name: str) -> str | None:
|
|
257
|
+
"""Get a path property from the containing file node via ID prefix convention.
|
|
258
|
+
|
|
259
|
+
Node IDs follow the pattern: file_hash_1, file_hash_2, etc.
|
|
260
|
+
This method extracts the file_hash prefix and retrieves the property from that file node.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
prop_name: Property name to retrieve ("relativePath" or "absolutePath").
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Property value from the file node, or None if not found.
|
|
267
|
+
"""
|
|
268
|
+
if "_" not in self._nid:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
# Extract prefix: everything before the last underscore followed by digits
|
|
272
|
+
last_underscore = self._nid.rfind("_")
|
|
273
|
+
if last_underscore == -1:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
potential_index = self._nid[last_underscore + 1 :]
|
|
277
|
+
if not potential_index.isdigit():
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
# Get the file node with ID = prefix
|
|
281
|
+
file_id = self._nid[:last_underscore]
|
|
282
|
+
if not self._storage.contains_node(file_id):
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
file_props = self._storage.get_node_props(file_id)
|
|
286
|
+
if not file_props or file_props.get("nodeType") != "File":
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
value = file_props.get(prop_name)
|
|
290
|
+
return value if isinstance(value, str) else None
|
|
186
291
|
|
|
187
292
|
def has_attribute(self, name: str) -> bool:
|
|
188
293
|
"""Check if attribute exists.
|
php_parser_py/_parser.py
CHANGED
|
@@ -60,15 +60,16 @@ class Parser:
|
|
|
60
60
|
raise
|
|
61
61
|
|
|
62
62
|
# Normalize to list
|
|
63
|
-
|
|
64
|
-
json_data
|
|
63
|
+
node_list: list[dict[str, Any]] = (
|
|
64
|
+
[json_data] if not isinstance(json_data, list) else json_data
|
|
65
|
+
)
|
|
65
66
|
|
|
66
67
|
# Create temporary storage to convert JSON to nodes
|
|
67
68
|
storage = Storage()
|
|
68
|
-
node_ids = []
|
|
69
|
+
node_ids: list[str] = []
|
|
69
70
|
node_counter = [1] # Use list to allow mutation in recursive calls
|
|
70
71
|
|
|
71
|
-
for item in
|
|
72
|
+
for item in node_list:
|
|
72
73
|
node_id = self._process_node(
|
|
73
74
|
storage, item, None, None, None, node_counter, ""
|
|
74
75
|
)
|
|
@@ -114,12 +115,11 @@ class Parser:
|
|
|
114
115
|
raise ParseError("Syntax error in PHP code", line=1) from e
|
|
115
116
|
raise
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
file_list: list[dict[str, Any]] = (
|
|
119
|
+
[json_data] if not isinstance(json_data, list) else json_data
|
|
120
|
+
)
|
|
121
121
|
storage = self._create_project_structure(
|
|
122
|
-
[(file_path,
|
|
122
|
+
[(file_path, file_list)],
|
|
123
123
|
[(file_path, file_hash)],
|
|
124
124
|
project_path=project_path,
|
|
125
125
|
)
|
|
@@ -162,18 +162,14 @@ class Parser:
|
|
|
162
162
|
php_files = [f for f in all_files if f.is_file() and file_filter(f)]
|
|
163
163
|
|
|
164
164
|
if not php_files:
|
|
165
|
-
logger.warning(
|
|
165
|
+
logger.warning("No PHP files found in project directory: %s", project_path)
|
|
166
166
|
# Create empty project structure
|
|
167
167
|
storage = Storage()
|
|
168
168
|
project_id = "project"
|
|
169
169
|
storage.add_node(project_id)
|
|
170
170
|
storage.set_node_props(
|
|
171
171
|
project_id,
|
|
172
|
-
{
|
|
173
|
-
"nodeType": "Project",
|
|
174
|
-
"label": "Project",
|
|
175
|
-
"path": str(project_path_obj),
|
|
176
|
-
},
|
|
172
|
+
{"nodeType": "Project", "absolutePath": str(project_path)},
|
|
177
173
|
)
|
|
178
174
|
return AST(storage, root_node_id="project")
|
|
179
175
|
|
|
@@ -189,9 +185,10 @@ class Parser:
|
|
|
189
185
|
code = file_path.read_text(encoding="utf-8")
|
|
190
186
|
try:
|
|
191
187
|
json_data = self._runner.parse(code)
|
|
192
|
-
|
|
193
|
-
json_data
|
|
194
|
-
|
|
188
|
+
node_list: list[dict[str, Any]] = (
|
|
189
|
+
[json_data] if not isinstance(json_data, list) else json_data
|
|
190
|
+
)
|
|
191
|
+
all_json_data.append((file_path, node_list))
|
|
195
192
|
except RunnerError as e:
|
|
196
193
|
if "Syntax error" in str(e):
|
|
197
194
|
raise ParseError(f"Syntax error in {file_path}", line=1) from e
|
|
@@ -225,7 +222,7 @@ class Parser:
|
|
|
225
222
|
storage.add_node(project_id)
|
|
226
223
|
storage.set_node_props(
|
|
227
224
|
project_id,
|
|
228
|
-
{"nodeType": "Project", "
|
|
225
|
+
{"nodeType": "Project", "absolutePath": str(project_path)},
|
|
229
226
|
)
|
|
230
227
|
|
|
231
228
|
# Create file nodes and process their statements
|
|
@@ -234,8 +231,26 @@ class Parser:
|
|
|
234
231
|
try:
|
|
235
232
|
relative_path = file_path.relative_to(project_path)
|
|
236
233
|
except ValueError:
|
|
237
|
-
|
|
238
|
-
|
|
234
|
+
relative_path = Path(file_path.name)
|
|
235
|
+
|
|
236
|
+
# Normalize and inspect statements to compute file position range
|
|
237
|
+
stmt_list: list[dict[str, Any]] = (
|
|
238
|
+
[json_data] if not isinstance(json_data, list) else json_data
|
|
239
|
+
)
|
|
240
|
+
file_end_line = 1
|
|
241
|
+
file_end_file_pos = 0
|
|
242
|
+
file_end_token_pos = 0
|
|
243
|
+
for stmt in stmt_list:
|
|
244
|
+
attributes = stmt.get("attributes", {})
|
|
245
|
+
end_line = attributes.get("endLine")
|
|
246
|
+
if isinstance(end_line, int) and end_line > file_end_line:
|
|
247
|
+
file_end_line = end_line
|
|
248
|
+
end_file_pos = attributes.get("endFilePos")
|
|
249
|
+
if isinstance(end_file_pos, int) and end_file_pos > file_end_file_pos:
|
|
250
|
+
file_end_file_pos = end_file_pos
|
|
251
|
+
end_token_pos = attributes.get("endTokenPos")
|
|
252
|
+
if isinstance(end_token_pos, int) and end_token_pos > file_end_token_pos:
|
|
253
|
+
file_end_token_pos = end_token_pos
|
|
239
254
|
|
|
240
255
|
# Create file node (ID: hex hash)
|
|
241
256
|
file_id = file_hash
|
|
@@ -244,9 +259,14 @@ class Parser:
|
|
|
244
259
|
file_id,
|
|
245
260
|
{
|
|
246
261
|
"nodeType": "File",
|
|
247
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
262
|
+
"absolutePath": str(file_path),
|
|
263
|
+
"relativePath": str(relative_path),
|
|
264
|
+
"startLine": 1,
|
|
265
|
+
"endLine": file_end_line,
|
|
266
|
+
"startFilePos": 0,
|
|
267
|
+
"endFilePos": file_end_file_pos,
|
|
268
|
+
"startTokenPos": 0,
|
|
269
|
+
"endTokenPos": file_end_token_pos,
|
|
250
270
|
},
|
|
251
271
|
)
|
|
252
272
|
|
|
@@ -257,17 +277,30 @@ class Parser:
|
|
|
257
277
|
|
|
258
278
|
# Process statements in file (node IDs: {hex}_1, {hex}_2, ...)
|
|
259
279
|
node_counter = [1]
|
|
260
|
-
|
|
261
|
-
json_data = [json_data]
|
|
262
|
-
|
|
263
|
-
for idx, item in enumerate(json_data):
|
|
280
|
+
for idx, item in enumerate(stmt_list):
|
|
264
281
|
self._process_node(
|
|
265
282
|
storage, item, file_id, "stmts", idx, node_counter, file_hash
|
|
266
283
|
)
|
|
267
284
|
|
|
285
|
+
# Project node does not correspond to a concrete source range.
|
|
286
|
+
# Use sentinel values for all position attributes.
|
|
287
|
+
storage.set_node_props(
|
|
288
|
+
project_id,
|
|
289
|
+
{
|
|
290
|
+
"nodeType": "Project",
|
|
291
|
+
"absolutePath": str(project_path),
|
|
292
|
+
"startLine": -1,
|
|
293
|
+
"endLine": -1,
|
|
294
|
+
"startFilePos": -1,
|
|
295
|
+
"endFilePos": -1,
|
|
296
|
+
"startTokenPos": -1,
|
|
297
|
+
"endTokenPos": -1,
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
|
|
268
301
|
return storage
|
|
269
302
|
|
|
270
|
-
def _process_node(
|
|
303
|
+
def _process_node(
|
|
271
304
|
self,
|
|
272
305
|
storage: Storage,
|
|
273
306
|
node_data: Any,
|
|
@@ -340,7 +373,7 @@ class Parser:
|
|
|
340
373
|
|
|
341
374
|
# Create edge from parent if applicable
|
|
342
375
|
if parent_id is not None and field_name is not None:
|
|
343
|
-
edge_props = {"field": field_name}
|
|
376
|
+
edge_props: dict[str, str | int] = {"field": field_name}
|
|
344
377
|
if index is not None:
|
|
345
378
|
edge_props["index"] = index
|
|
346
379
|
edge_id = (parent_id, node_id, "PARENT_OF")
|
|
@@ -349,20 +382,18 @@ class Parser:
|
|
|
349
382
|
storage.set_edge_props(edge_id, edge_props)
|
|
350
383
|
|
|
351
384
|
# Process child fields recursively
|
|
352
|
-
for
|
|
353
|
-
if isinstance(
|
|
354
|
-
|
|
355
|
-
for idx, item in enumerate(field_value):
|
|
385
|
+
for child_key, child_value in child_fields:
|
|
386
|
+
if isinstance(child_value, list):
|
|
387
|
+
for idx, item in enumerate(child_value):
|
|
356
388
|
self._process_node(
|
|
357
|
-
storage, item, node_id,
|
|
389
|
+
storage, item, node_id, child_key, idx, node_counter, prefix
|
|
358
390
|
)
|
|
359
391
|
else:
|
|
360
|
-
# Single child object
|
|
361
392
|
self._process_node(
|
|
362
393
|
storage,
|
|
363
|
-
|
|
394
|
+
child_value,
|
|
364
395
|
node_id,
|
|
365
|
-
|
|
396
|
+
child_key,
|
|
366
397
|
None,
|
|
367
398
|
node_counter,
|
|
368
399
|
prefix,
|
php_parser_py/_printer.py
CHANGED
|
@@ -59,7 +59,7 @@ class PrettyPrinter:
|
|
|
59
59
|
# Generate code for each file
|
|
60
60
|
result: dict[str, str] = {}
|
|
61
61
|
for file_node in file_nodes:
|
|
62
|
-
file_path = file_node.get_property("
|
|
62
|
+
file_path = file_node.get_property("absolutePath", "")
|
|
63
63
|
file_hash = file_node.id
|
|
64
64
|
|
|
65
65
|
# Get JSON for this file only
|
|
@@ -97,7 +97,7 @@ class PrettyPrinter:
|
|
|
97
97
|
"""
|
|
98
98
|
# Find file node by its relative path property
|
|
99
99
|
for file_node in ast.file_nodes():
|
|
100
|
-
if file_node.get_property("
|
|
100
|
+
if file_node.get_property("relativePath") == relative_path:
|
|
101
101
|
file_hash = file_node.id
|
|
102
102
|
json_str = ast.to_json(file_hash=file_hash)
|
|
103
103
|
return self._runner.print(json_str)
|
php_parser_py/_resources.py
CHANGED
|
@@ -12,7 +12,7 @@ from typing import Optional
|
|
|
12
12
|
|
|
13
13
|
# Global lock for thread-safe extraction
|
|
14
14
|
_extraction_lock = threading.Lock()
|
|
15
|
-
|
|
15
|
+
_extraction_state: dict[str, bool] = {"done": False}
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def get_vendor_path() -> Path:
|
|
@@ -55,7 +55,7 @@ def is_already_extracted(zip_path: Path) -> bool:
|
|
|
55
55
|
stored_hash = marker_file.read_text().strip()
|
|
56
56
|
current_hash = calculate_zip_hash(zip_path)
|
|
57
57
|
return stored_hash == current_hash
|
|
58
|
-
except
|
|
58
|
+
except OSError:
|
|
59
59
|
# If we can't read the marker or calculate hash, re-extract
|
|
60
60
|
return False
|
|
61
61
|
|
|
@@ -95,10 +95,7 @@ def ensure_php_parser_extracted() -> Path:
|
|
|
95
95
|
FileNotFoundError: If the php-parser zip file is not found
|
|
96
96
|
RuntimeError: If extraction fails
|
|
97
97
|
"""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# Fast path: already extracted in this process
|
|
101
|
-
if _extraction_done:
|
|
98
|
+
if _extraction_state["done"]:
|
|
102
99
|
return get_vendor_path()
|
|
103
100
|
|
|
104
101
|
vendor_path = get_vendor_path()
|
|
@@ -114,25 +111,21 @@ def ensure_php_parser_extracted() -> Path:
|
|
|
114
111
|
|
|
115
112
|
zip_path = zip_files[0]
|
|
116
113
|
|
|
117
|
-
# Check if already extracted (with marker file)
|
|
118
114
|
if is_already_extracted(zip_path):
|
|
119
|
-
|
|
115
|
+
_extraction_state["done"] = True
|
|
120
116
|
return vendor_path
|
|
121
117
|
|
|
122
|
-
# Thread-safe extraction
|
|
123
118
|
with _extraction_lock:
|
|
124
|
-
# Double-check after acquiring lock (another thread might have extracted)
|
|
125
119
|
if is_already_extracted(zip_path):
|
|
126
|
-
|
|
120
|
+
_extraction_state["done"] = True
|
|
127
121
|
return vendor_path
|
|
128
122
|
|
|
129
123
|
try:
|
|
130
|
-
# Extract PHP-Parser
|
|
131
124
|
extract_php_parser(zip_path, vendor_path)
|
|
132
|
-
|
|
125
|
+
_extraction_state["done"] = True
|
|
133
126
|
return vendor_path
|
|
134
127
|
|
|
135
|
-
except
|
|
128
|
+
except (OSError, zipfile.BadZipFile, ValueError) as e:
|
|
136
129
|
raise RuntimeError(
|
|
137
130
|
f"Failed to extract PHP-Parser from {zip_path}: {e}"
|
|
138
131
|
) from e
|
php_parser_py/_runner.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import subprocess
|
|
6
|
-
from typing import Any, Optional
|
|
6
|
+
from typing import Any, Optional, cast
|
|
7
7
|
|
|
8
8
|
from static_php_py import PHP
|
|
9
9
|
from static_php_py.exceptions import BinaryNotFoundError, DownloadError
|
|
@@ -39,7 +39,7 @@ class Runner:
|
|
|
39
39
|
try:
|
|
40
40
|
self._php = php if php is not None else PHP.builtin()
|
|
41
41
|
self._php_binary = self._php.path()
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
except BinaryNotFoundError as e:
|
|
44
44
|
raise RunnerError(
|
|
45
45
|
f"PHP binary not found: {e}. "
|
|
@@ -84,10 +84,10 @@ class Runner:
|
|
|
84
84
|
# Log complete error information
|
|
85
85
|
error_msg = f"PHP execution failed with exit code {result.returncode}"
|
|
86
86
|
if result.stderr:
|
|
87
|
-
logger.error(
|
|
87
|
+
logger.error("PHP stderr: %s", result.stderr)
|
|
88
88
|
error_msg += f"\nStderr: {result.stderr}"
|
|
89
89
|
if result.stdout:
|
|
90
|
-
logger.error(
|
|
90
|
+
logger.error("PHP stdout: %s", result.stdout)
|
|
91
91
|
error_msg += f"\nStdout: {result.stdout}"
|
|
92
92
|
|
|
93
93
|
raise RunnerError(
|
|
@@ -139,7 +139,7 @@ class Runner:
|
|
|
139
139
|
first_error.get("line"),
|
|
140
140
|
)
|
|
141
141
|
|
|
142
|
-
return result
|
|
142
|
+
return cast(dict[str, Any], result)
|
|
143
143
|
|
|
144
144
|
except json.JSONDecodeError as e:
|
|
145
145
|
raise RunnerError(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: php-parser-py
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Python wrapper for PHP-Parser using cpg2py
|
|
5
5
|
Author: samhsu-dev
|
|
6
6
|
Author-email: samhsu-dev <yxu166@jh.edu>
|
|
@@ -11,6 +11,8 @@ Description-Content-Type: text/markdown
|
|
|
11
11
|
|
|
12
12
|
# php-parser-py
|
|
13
13
|
|
|
14
|
+
[](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
|
|
15
|
+
|
|
14
16
|
Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
|
|
15
17
|
|
|
16
18
|
- **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
php_parser_py/__init__.py,sha256=heraS-IKwTKjDeatzSe2OMPv7zUErOeuKZHMeJMEPUg,3387
|
|
2
|
+
php_parser_py/_ast.py,sha256=hEH5XmhCAD_Qn5QgVBmGlas0mUqKVMY1eMFXeLLZdtc,11744
|
|
3
|
+
php_parser_py/_edge.py,sha256=bHMtb0Bf-bQjBzlTaB01pnntYcE3WL6JRWiMyGlzq0M,2919
|
|
4
|
+
php_parser_py/_exceptions.py,sha256=4jX9WfV--vCnSacEQgEE0Xtm09Ii88GkYj17yBMf7Cc,1788
|
|
5
|
+
php_parser_py/_node.py,sha256=zyp0j4GRBCe2VFt22ajbYM9ZYuxgLs-Awdjwi2FisL4,9918
|
|
6
|
+
php_parser_py/_parser.py,sha256=mwi7idEJZLgzkMPvUThHyt1MjOEEuNEqfyJam9YVRg4,14511
|
|
7
|
+
php_parser_py/_printer.py,sha256=mvJ9o8ql_Yk9-6u69Jx-4azD7aIG9W1NDWaJVkzc2gI,3506
|
|
8
|
+
php_parser_py/_resources.py,sha256=TUYfH58HV1y93kq39gW-aHKkR1MRZQb131VHzfR4LBc,4305
|
|
9
|
+
php_parser_py/_runner.py,sha256=MxTh5Z7tuyOLneX0IggfshmyQf6mL1duJPYozu4899c,6985
|
|
10
|
+
php_parser_py/resources/php-parser-4.19.4.zip,sha256=ND6zsylItt4owMoQnq-3id_ScMAXEfVyHzzRi0wKthQ,144334
|
|
11
|
+
php_parser_py/vendor/.extracted,sha256=35VHVRaR8xmvJSW2J6Z8MXxiFVnsRgeuJJPNS_5yzB8,64
|
|
12
|
+
php_parser_py/vendor/LICENSE.txt,sha256=3YWeEH-sn4ekQTq4w8lvDxYRoX9Zai21YqWk9JBMHpc,1520
|
|
13
|
+
php_parser_py/vendor/php-parser.phar,sha256=xeuVm4n37yt_DX3pNH1Ugc1ihGtoC97b7ao7a-AGjTk,1009441
|
|
14
|
+
php_parser_py-1.2.1.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
15
|
+
php_parser_py-1.2.1.dist-info/METADATA,sha256=1IagDWYT0alURw-YwmgfCnu4nF9m5tpdob2vpr_DFS4,2037
|
|
16
|
+
php_parser_py-1.2.1.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
php_parser_py/__init__.py,sha256=Iiea9w5auFtG4yAA8KNECRIwbw6IysYteTWv8HcE16E,3284
|
|
2
|
-
php_parser_py/_ast.py,sha256=QnkdtMsaB3IFDhGRxqr1QIXQif9blIZE8tIGus9qmuo,12728
|
|
3
|
-
php_parser_py/_edge.py,sha256=CFGIakA_-kAeS8PQZ56ukBBohdBN7XpXZmYjuJ7fq1Q,3789
|
|
4
|
-
php_parser_py/_exceptions.py,sha256=cqGfU64-jXzJ9Y0edlMMZUzmMyClco0t-e33p5r90TA,1351
|
|
5
|
-
php_parser_py/_node.py,sha256=8YZfXjKf_4lf1R29UxxByw_iN2pBlTK6HNbx0z-9uWU,5654
|
|
6
|
-
php_parser_py/_parser.py,sha256=rJ8_Oco7sCOwhy512oJJmGr2WijF25rqvLRA4Da6c3Y,13071
|
|
7
|
-
php_parser_py/_printer.py,sha256=wj6poxv_85YvhTHwa0zEJpP3_FXyTWxxzBrxQTvtgSY,3494
|
|
8
|
-
php_parser_py/_resources.py,sha256=1BL6cNiBuGr7kPFw7MKF5F_ZaUvFsvgEPjU2wThNlCE,4519
|
|
9
|
-
php_parser_py/_runner.py,sha256=RVysl2489tBpg0t595nZ1buutz5HdOVjQaPnk36F9Tg,6967
|
|
10
|
-
php_parser_py/resources/php-parser-4.19.4.zip,sha256=ND6zsylItt4owMoQnq-3id_ScMAXEfVyHzzRi0wKthQ,144334
|
|
11
|
-
php_parser_py/vendor/.extracted,sha256=35VHVRaR8xmvJSW2J6Z8MXxiFVnsRgeuJJPNS_5yzB8,64
|
|
12
|
-
php_parser_py/vendor/LICENSE.txt,sha256=3YWeEH-sn4ekQTq4w8lvDxYRoX9Zai21YqWk9JBMHpc,1520
|
|
13
|
-
php_parser_py/vendor/php-parser.phar,sha256=xeuVm4n37yt_DX3pNH1Ugc1ihGtoC97b7ao7a-AGjTk,1009441
|
|
14
|
-
php_parser_py-1.2.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
15
|
-
php_parser_py-1.2.0.dist-info/METADATA,sha256=ji7zPJ5JqLdjVKyj-5AaArrh9Lc8mc-73yRqkAUdgpQ,1932
|
|
16
|
-
php_parser_py-1.2.0.dist-info/RECORD,,
|
|
File without changes
|