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 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 typing import TYPE_CHECKING
8
+ from pathlib import Path
9
+ from typing import Callable
9
10
 
10
- if TYPE_CHECKING:
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 php_parser_py._ast import AST
23
- from php_parser_py._edge import Edge
24
- from php_parser_py._exceptions import ParseError, RunnerError
25
- from php_parser_py._node import Node
26
-
27
-
28
- class Parser(_Parser):
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: "Callable[[Path], bool]" = lambda p: p.suffix == ".php", # type: ignore
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 checking for `.php` suffix.
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, Optional
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) -> Optional[Node]:
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 or None if not found.
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._storage.contains_node(whose_id_is):
61
- return None
62
- return Node(self._storage, whose_id_is)
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) -> Optional[Edge]:
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 or None if not found.
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._storage.contains_edge(edge_id):
77
- return None
78
- return Edge(self._storage, fid, tid, eid)
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) -> Optional[Node]:
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 the root of the AST with ID "project".
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 or None if not found.
94
+ Project Node instance.
95
+
96
+ Raises:
97
+ KeyError: If the root node is not in the graph.
87
98
  """
88
- if not self._storage.contains_node(self._root_node_id):
89
- return None
90
- return Node(self._storage, self._root_node_id)
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
- if not self.project_node():
109
+ try:
110
+ project = self.project_node()
111
+ except KeyError:
99
112
  return []
100
113
 
101
- file_nodes = []
102
- for edge_id in self._storage.get_edges():
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 get_file(
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
- Traverses upward from the given node via PARENT_OF edges until
118
- finding a File node. If the node itself is a File node, returns it.
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, or None if:
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
- # Check if the node itself is a File node
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 and node.get_property("nodeType") == "File":
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
- return None
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
- while current_id and current_id not in visited:
146
- visited.add(current_id)
141
+ result = self._try_file_by_id_prefix(node_id)
142
+ if result is not None:
143
+ return result
147
144
 
148
- # Find parent node via incoming PARENT_OF edge (each node has one parent)
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
- if not parent_id:
157
- # No parent found, can't determine file
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
- # Check parent node
161
- parent_node = self.node(parent_id)
162
- if not parent_node:
163
- return None
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
- node_type = parent_node.get_property("nodeType")
166
- if node_type == "File":
167
- return parent_node
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
- # Continue traversing upward
173
- current_id = parent_id
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 to_json(self, file_hash: Optional[str] = None) -> str:
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
- file_node = self.node(file_hash)
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 (nodes without incoming PARENT_OF edges)
205
- # Exclude project node if it exists
206
- all_nodes = set(self._storage.get_nodes())
207
- nodes_with_parents = set()
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(list(root_nodes))
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 nodes for a specific file.
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
- Returns:
229
- List of statement node IDs.
221
+ Uses succ() traversal; statement IDs follow the convention file_hash_1, file_hash_2, ...
230
222
  """
231
- statement_nodes = []
232
- for edge_id in self._storage.get_edges():
233
- from_id, to_id, edge_type = edge_id
234
- if from_id == file_hash and edge_type == "PARENT_OF":
235
- edge_props = self._storage.get_edge_props(edge_id)
236
- if edge_props and edge_props.get("field") == "stmts":
237
- statement_nodes.append(to_id)
238
-
239
- # Sort by edge index to preserve order
240
- def get_index(nid: str) -> int:
241
- edge_props = self._storage.get_edge_props((file_hash, nid, "PARENT_OF"))
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
- props = self._storage.get_node_props(nid)
260
- if props is None:
261
- logger.warning("Node %s not found in storage, returning empty dict", nid)
262
- return {}
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: dict[str, Any] = {}
265
- node_type = props.get("nodeType")
266
- if node_type:
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
- # Collect attribute fields
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 == "nodeType":
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
- if attributes:
294
- result["attributes"] = attributes
292
+ def _reconstruct_child_fields(self, result: dict[str, Any], nid: str) -> None:
293
+ """Reconstruct and add child fields to result.
295
294
 
296
- # Reconstruct child nodes from PARENT_OF edges
297
- outgoing_edges = [
298
- eid
299
- for eid in self._storage.get_edges()
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 from_id, to_id, edge_type in outgoing_edges:
304
- edge_props = self._storage.get_edge_props((from_id, to_id, edge_type))
305
- if edge_props is None:
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 = edge_props.get("field")
306
+ field_name = edge.get("field")
309
307
  if field_name is None:
310
308
  continue
311
309
 
312
- # Skip project/file structure edges when reconstructing
313
- if field_name in ("files", "stmts"):
314
- # Only include stmts edges for file nodes
315
- if field_name == "stmts":
316
- child_json = self._reconstruct_node(to_id)
317
- index = edge_props.get("index")
318
-
319
- if index is not None:
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
- # Regular child field
329
- child_json = self._reconstruct_node(to_id)
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 isinstance(indexed_children, dict):
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
- # Add default values for common optional fields that PHP-Parser expects
352
- if node_type and ("attrGroups" not in result):
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
- return result
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"] = []