php-parser-py 1.2.1__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
@@ -8,34 +8,19 @@ and manipulation of PHP code.
8
8
  from pathlib import Path
9
9
  from typing import Callable
10
10
 
11
- from php_parser_py._parser import Parser as _Parser
12
- from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
13
- from php_parser_py._resources import ensure_php_parser_extracted
11
+ from ._resources import ensure_php_parser_extracted
14
12
 
15
13
  # Ensure PHP-Parser is extracted on first import
16
14
  ensure_php_parser_extracted()
17
15
 
18
16
  # Import and expose public classes directly
19
- from php_parser_py._ast import AST
20
- from php_parser_py._edge import Edge
21
- from php_parser_py._exceptions import NodeNotInFileError, ParseError, RunnerError
22
- from php_parser_py._node import Node
23
-
24
-
25
- class Parser(_Parser):
26
- """Parses PHP source code using PHP-Parser.
27
-
28
- Invokes PHP-Parser to parse PHP code and converts the resulting JSON
29
- to cpg2py Storage format for graph-based analysis.
30
- """
31
-
32
-
33
- class PrettyPrinter(_PrettyPrinter):
34
- """Converts AST to PHP code using PHP-Parser's PrettyPrinter.
35
-
36
- Reconstructs JSON from AST graph and invokes PHP-Parser to generate
37
- formatted PHP source code.
38
- """
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
39
24
 
40
25
 
41
26
  def parse_code(code: str) -> list[Node]:
@@ -92,7 +77,7 @@ def parse_project(
92
77
  Args:
93
78
  project_path: Project root directory path.
94
79
  file_filter: Function to filter files. Takes a Path and returns
95
- 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.
96
81
 
97
82
  Returns:
98
83
  AST instance with project -> files -> statements hierarchy.
@@ -111,8 +96,9 @@ def parse_project(
111
96
  __version__ = "0.1.0"
112
97
  __all__ = [
113
98
  "AST",
114
- "Node",
115
99
  "Edge",
100
+ "Modifier",
101
+ "Node",
116
102
  "Parser",
117
103
  "PrettyPrinter",
118
104
  "ParseError",
@@ -0,0 +1,140 @@
1
+ """Modifier class for AST graph mutation operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ._edge import Edge
9
+ from ._node import Node
10
+
11
+ if TYPE_CHECKING:
12
+ from ._ast import AST
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class Modifier:
18
+ """Encapsulates all graph mutation operations for an AST.
19
+
20
+ The only approved way to add, remove, or structurally modify nodes and
21
+ edges in the AST graph. Callers must never use Storage directly.
22
+
23
+ AST (AbcGraphQuerier) is a query interface; graph mutation is a separate
24
+ concern handled by this class. Mutations are immediately visible through
25
+ the wrapped AST's query methods.
26
+
27
+ Attributes:
28
+ _ast: The AST instance being modified.
29
+ _storage: Reference to the AST's internal Storage.
30
+ """
31
+
32
+ def __init__(self, ast: AST) -> None:
33
+ """Wrap an existing AST instance for mutation.
34
+
35
+ Args:
36
+ ast: AST instance to modify.
37
+ """
38
+ self._ast = ast
39
+ self._storage = ast.storage
40
+
41
+ @property
42
+ def ast(self) -> AST:
43
+ """Return the underlying AST instance."""
44
+ return self._ast
45
+
46
+ # -- Node Operations --
47
+
48
+ def add_node(self, node_id: str, node_type: str, **props: object) -> Node:
49
+ """Create a new node in the graph with the given type and properties.
50
+
51
+ Args:
52
+ node_id: Unique node ID string.
53
+ node_type: PHP-Parser node type (e.g. "Stmt_Break").
54
+ **props: Additional node properties to set.
55
+
56
+ Returns:
57
+ Node instance for the newly created node.
58
+
59
+ Raises:
60
+ ValueError: If node_id already exists in the graph.
61
+ """
62
+ if self._storage.contains_node(node_id):
63
+ raise ValueError(f"Node already exists: {node_id!r}")
64
+
65
+ self._storage.add_node(node_id)
66
+ all_props: dict[str, object] = {"nodeType": node_type, **props}
67
+ self._storage.set_node_props(node_id, all_props)
68
+ return Node(self._storage, node_id)
69
+
70
+ def remove_node(self, node_id: str) -> None:
71
+ """Remove a node and all its connected edges from the graph.
72
+
73
+ Args:
74
+ node_id: Node ID to remove.
75
+
76
+ Raises:
77
+ KeyError: If node_id is not in the graph.
78
+ """
79
+ if not self._storage.contains_node(node_id):
80
+ raise KeyError(f"Node not found: {node_id!r}")
81
+ self._storage.remove_node(node_id)
82
+
83
+ # -- Edge Operations --
84
+
85
+ def add_edge(
86
+ self,
87
+ from_id: str,
88
+ to_id: str,
89
+ edge_type: str = "PARENT_OF",
90
+ **props: object,
91
+ ) -> Edge:
92
+ """Create a new edge between two existing nodes.
93
+
94
+ Args:
95
+ from_id: Source node ID.
96
+ to_id: Target node ID.
97
+ edge_type: Edge type. Defaults to "PARENT_OF".
98
+ **props: Edge properties (e.g. field="stmts", index=0).
99
+
100
+ Returns:
101
+ Edge instance for the newly created edge.
102
+
103
+ Raises:
104
+ KeyError: If either node does not exist.
105
+ ValueError: If edge already exists.
106
+ """
107
+ if not self._storage.contains_node(from_id):
108
+ raise KeyError(f"Source node not found: {from_id!r}")
109
+ if not self._storage.contains_node(to_id):
110
+ raise KeyError(f"Target node not found: {to_id!r}")
111
+
112
+ edge_id = (from_id, to_id, edge_type)
113
+ if self._storage.contains_edge(edge_id):
114
+ raise ValueError(f"Edge already exists: {edge_id!r}")
115
+
116
+ self._storage.add_edge(edge_id)
117
+ if props:
118
+ self._storage.set_edge_props(edge_id, props)
119
+ return Edge(self._storage, from_id, to_id, edge_type)
120
+
121
+ def remove_edge(
122
+ self,
123
+ from_id: str,
124
+ to_id: str,
125
+ edge_type: str = "PARENT_OF",
126
+ ) -> None:
127
+ """Remove an edge from the graph.
128
+
129
+ Args:
130
+ from_id: Source node ID.
131
+ to_id: Target node ID.
132
+ edge_type: Edge type. Defaults to "PARENT_OF".
133
+
134
+ Raises:
135
+ KeyError: If edge is not in the graph.
136
+ """
137
+ edge_id = (from_id, to_id, edge_type)
138
+ if not self._storage.contains_edge(edge_id):
139
+ raise KeyError(f"Edge not found: {edge_id!r}")
140
+ self._storage.remove_edge(edge_id)
php_parser_py/_parser.py CHANGED
@@ -3,15 +3,16 @@
3
3
  import hashlib
4
4
  import logging
5
5
  from pathlib import Path
6
- from typing import Any, Callable, Optional
6
+ from typing import Callable, Optional
7
7
 
8
8
  from cpg2py import Storage
9
9
  from static_php_py import PHP
10
10
 
11
- from php_parser_py._ast import AST
12
- from php_parser_py._exceptions import ParseError, RunnerError
13
- from php_parser_py._node import Node
14
- from php_parser_py._runner import Runner
11
+ from ._ast import AST
12
+ from ._exceptions import ParseError, RunnerError
13
+ from ._modifier import Modifier
14
+ from ._node import Node
15
+ from ._runner import Runner
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -19,10 +20,8 @@ logger = logging.getLogger(__name__)
19
20
  class Parser:
20
21
  """Parses PHP source code using PHP-Parser.
21
22
 
22
- Provides three parsing methods:
23
- - parse_code: Parse code string, returns list of top-level statement nodes
24
- - parse_file: Parse single file, returns AST with project and file nodes
25
- - parse_project: Parse project directory, returns AST with project and all PHP files
23
+ Uses Modifier for all AST construction. Parser never calls Storage
24
+ directly — all node/edge creation goes through Modifier.
26
25
 
27
26
  Attributes:
28
27
  _runner: Runner instance for PHP-Parser invocation.
@@ -52,39 +51,27 @@ class Parser:
52
51
  ParseError: If code has syntax errors.
53
52
  RunnerError: If PHP execution fails.
54
53
  """
55
- try:
56
- json_data = self._runner.parse(code)
57
- except RunnerError as e:
58
- if "Syntax error" in str(e):
59
- raise ParseError("Syntax error in PHP code", line=1) from e
60
- raise
54
+ json_data = self._parse_php(code)
55
+ node_list = self._normalize_json(json_data)
61
56
 
62
- # Normalize to list
63
- node_list: list[dict[str, Any]] = (
64
- [json_data] if not isinstance(json_data, list) else json_data
65
- )
66
-
67
- # Create temporary storage to convert JSON to nodes
68
- storage = Storage()
57
+ modifier = Modifier(AST(Storage(), root_node_id="__code_root__"))
58
+ node_counter = [1]
69
59
  node_ids: list[str] = []
70
- node_counter = [1] # Use list to allow mutation in recursive calls
71
60
 
72
61
  for item in node_list:
73
62
  node_id = self._process_node(
74
- storage, item, None, None, None, node_counter, ""
63
+ modifier, item, None, None, None, node_counter, ""
75
64
  )
76
65
  if node_id:
77
66
  node_ids.append(node_id)
78
67
 
79
- # Return Node instances
80
- return [Node(storage, nid) for nid in node_ids]
68
+ return [modifier.ast.node(nid) for nid in node_ids]
81
69
 
82
70
  def parse_file(self, path: str) -> AST:
83
71
  """Parse a single PHP file into an AST with project and file nodes.
84
72
 
85
- Creates a project node (ID: "project") and a file node (ID: hex hash of path).
86
- File node contains all statements from the file.
87
- Project path is set to the file's directory.
73
+ Creates a project node (ID: "project") and a file node (ID: hex
74
+ hash of path). File node contains all statements from the file.
88
75
 
89
76
  Args:
90
77
  path: File path string.
@@ -100,30 +87,18 @@ class Parser:
100
87
  if not file_path.exists():
101
88
  raise FileNotFoundError(f"File not found: {path}")
102
89
 
103
- # Project path is the file's directory
104
90
  project_path = file_path.parent
105
-
106
- # Generate file hash (hex)
107
91
  file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
108
-
109
92
  code = file_path.read_text(encoding="utf-8")
93
+ json_data = self._parse_php(code)
94
+ file_list = self._normalize_json(json_data)
110
95
 
111
- try:
112
- json_data = self._runner.parse(code)
113
- except RunnerError as e:
114
- if "Syntax error" in str(e):
115
- raise ParseError("Syntax error in PHP code", line=1) from e
116
- raise
117
-
118
- file_list: list[dict[str, Any]] = (
119
- [json_data] if not isinstance(json_data, list) else json_data
120
- )
121
- storage = self._create_project_structure(
96
+ modifier = self._build_project_structure(
122
97
  [(file_path, file_list)],
123
98
  [(file_path, file_hash)],
124
99
  project_path=project_path,
125
100
  )
126
- return AST(storage, root_node_id="project")
101
+ return modifier.ast
127
102
 
128
103
  def parse_project(
129
104
  self,
@@ -134,12 +109,11 @@ class Parser:
134
109
 
135
110
  Recursively traverses the project directory to find all PHP files,
136
111
  then parses them into a single AST with project and file nodes.
137
- Each file node has ID based on its path hash.
138
112
 
139
113
  Args:
140
114
  project_path: Project root directory path.
141
115
  file_filter: Function to filter files. Takes a Path and returns
142
- True if the file should be parsed. Defaults to checking for `.php` suffix.
116
+ True if the file should be parsed. Defaults to .php suffix.
143
117
 
144
118
  Returns:
145
119
  AST instance with project -> files -> statements hierarchy.
@@ -153,250 +127,245 @@ class Parser:
153
127
 
154
128
  if not project_path_obj.exists():
155
129
  raise FileNotFoundError(f"Project directory not found: {project_path}")
156
-
157
130
  if not project_path_obj.is_dir():
158
131
  raise ValueError(f"Project path is not a directory: {project_path}")
159
132
 
160
- # Find all files recursively and filter
161
- all_files = project_path_obj.rglob("*")
162
- php_files = [f for f in all_files if f.is_file() and file_filter(f)]
133
+ php_files = [
134
+ f for f in project_path_obj.rglob("*") if f.is_file() and file_filter(f)
135
+ ]
163
136
 
164
137
  if not php_files:
165
138
  logger.warning("No PHP files found in project directory: %s", project_path)
166
- # Create empty project structure
167
- storage = Storage()
168
- project_id = "project"
169
- storage.add_node(project_id)
170
- storage.set_node_props(
171
- project_id,
172
- {"nodeType": "Project", "absolutePath": str(project_path)},
173
- )
174
- return AST(storage, root_node_id="project")
139
+ return self._build_empty_project(project_path_obj)
175
140
 
176
- # Prepare file information
177
- file_infos = []
178
- for file_path in php_files:
179
- file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
180
- file_infos.append((file_path, file_hash))
141
+ file_infos = [
142
+ (fp, hashlib.md5(str(fp).encode()).hexdigest()[:8]) for fp in php_files
143
+ ]
181
144
 
182
- # Parse all files
183
145
  all_json_data = []
184
146
  for file_path, _ in file_infos:
185
147
  code = file_path.read_text(encoding="utf-8")
186
- try:
187
- json_data = self._runner.parse(code)
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))
192
- except RunnerError as e:
193
- if "Syntax error" in str(e):
194
- raise ParseError(f"Syntax error in {file_path}", line=1) from e
195
- raise
148
+ json_data = self._parse_php(code, source=str(file_path))
149
+ all_json_data.append((file_path, self._normalize_json(json_data)))
196
150
 
197
- storage = self._create_project_structure(
151
+ modifier = self._build_project_structure(
198
152
  all_json_data, file_infos, project_path=project_path_obj
199
153
  )
200
- return AST(storage, root_node_id="project")
154
+ return modifier.ast
201
155
 
202
- def _create_project_structure(
203
- self,
204
- files_data: list[tuple[Path, Any]],
205
- file_infos: list[tuple[Path, str]],
206
- project_path: Path,
207
- ) -> Storage:
208
- """Create AST structure with project -> files -> statements hierarchy.
156
+ # -- Internal helpers --
209
157
 
210
- Args:
211
- files_data: List of (file_path, json_data) tuples.
212
- file_infos: List of (file_path, file_hash) tuples.
213
- project_path: Project root directory path.
158
+ def _parse_php(self, code: str, source: str = "input") -> object:
159
+ # Invoke PHP-Parser and translate RunnerError into ParseError.
160
+ try:
161
+ return self._runner.parse(code)
162
+ except RunnerError as e:
163
+ if "Syntax error" in str(e):
164
+ raise ParseError(f"Syntax error in {source}", line=1) from e
165
+ raise
214
166
 
215
- Returns:
216
- Storage instance with complete project structure.
217
- """
218
- storage = Storage()
219
-
220
- # Create project node (fixed ID: "project")
221
- project_id = "project"
222
- storage.add_node(project_id)
223
- storage.set_node_props(
224
- project_id,
225
- {"nodeType": "Project", "absolutePath": str(project_path)},
167
+ @staticmethod
168
+ def _normalize_json(json_data: object) -> list[dict[str, object]]:
169
+ # Ensure JSON data is always a list of node dicts.
170
+ if isinstance(json_data, list):
171
+ return json_data
172
+ if isinstance(json_data, dict):
173
+ return [json_data]
174
+ return []
175
+
176
+ def _build_empty_project(self, project_path: Path) -> AST:
177
+ # Create a project-only AST with no files.
178
+ modifier = Modifier(AST(Storage()))
179
+ modifier.add_node(
180
+ "project",
181
+ "Project",
182
+ absolutePath=str(project_path),
183
+ startLine=-1,
184
+ endLine=-1,
185
+ startFilePos=-1,
186
+ endFilePos=-1,
187
+ startTokenPos=-1,
188
+ endTokenPos=-1,
226
189
  )
190
+ return modifier.ast
227
191
 
228
- # Create file nodes and process their statements
229
- for (file_path, file_hash), (_, json_data) in zip(file_infos, files_data):
230
- # Calculate relative path from project root
231
- try:
232
- relative_path = file_path.relative_to(project_path)
233
- except ValueError:
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
254
-
255
- # Create file node (ID: hex hash)
256
- file_id = file_hash
257
- storage.add_node(file_id)
258
- storage.set_node_props(
259
- file_id,
260
- {
261
- "nodeType": "File",
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,
270
- },
271
- )
192
+ def _build_project_structure(
193
+ self,
194
+ files_data: list[tuple[Path, list[dict[str, object]]]],
195
+ file_infos: list[tuple[Path, str]],
196
+ project_path: Path,
197
+ ) -> Modifier:
198
+ # Build project -> files -> statements hierarchy via Modifier.
199
+ modifier = Modifier(AST(Storage()))
272
200
 
273
- # Link file to project
274
- edge_id = (project_id, file_id, "PARENT_OF")
275
- storage.add_edge(edge_id)
276
- storage.set_edge_props(edge_id, {"field": "files"})
201
+ modifier.add_node("project", "Project", absolutePath=str(project_path))
277
202
 
278
- # Process statements in file (node IDs: {hex}_1, {hex}_2, ...)
279
- node_counter = [1]
280
- for idx, item in enumerate(stmt_list):
281
- self._process_node(
282
- storage, item, file_id, "stmts", idx, node_counter, file_hash
283
- )
203
+ for (file_path, file_hash), (_, json_data) in zip(file_infos, files_data):
204
+ self._add_file_node(modifier, file_path, file_hash, json_data, project_path)
284
205
 
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,
206
+ # Set sentinel position values on project node.
207
+ project_node = modifier.ast.node("project")
208
+ project_node.set_properties(
289
209
  {
290
- "nodeType": "Project",
291
- "absolutePath": str(project_path),
292
210
  "startLine": -1,
293
211
  "endLine": -1,
294
212
  "startFilePos": -1,
295
213
  "endFilePos": -1,
296
214
  "startTokenPos": -1,
297
215
  "endTokenPos": -1,
298
- },
216
+ }
299
217
  )
300
218
 
301
- return storage
219
+ return modifier
220
+
221
+ def _add_file_node(
222
+ self,
223
+ modifier: Modifier,
224
+ file_path: Path,
225
+ file_hash: str,
226
+ stmt_list: list[dict[str, object]],
227
+ project_path: Path,
228
+ ) -> None:
229
+ # Create file node, link to project, process statements.
230
+ try:
231
+ relative_path = file_path.relative_to(project_path)
232
+ except ValueError:
233
+ relative_path = Path(file_path.name)
234
+
235
+ end_pos = self._compute_file_end_positions(stmt_list)
236
+
237
+ modifier.add_node(
238
+ file_hash,
239
+ "File",
240
+ absolutePath=str(file_path),
241
+ relativePath=str(relative_path),
242
+ startLine=1,
243
+ endLine=end_pos["endLine"],
244
+ startFilePos=0,
245
+ endFilePos=end_pos["endFilePos"],
246
+ startTokenPos=0,
247
+ endTokenPos=end_pos["endTokenPos"],
248
+ )
249
+ modifier.add_edge("project", file_hash, field="files")
250
+
251
+ node_counter = [1]
252
+ for idx, item in enumerate(stmt_list):
253
+ self._process_node(
254
+ modifier, item, file_hash, "stmts", idx, node_counter, file_hash
255
+ )
256
+
257
+ @staticmethod
258
+ def _compute_file_end_positions(
259
+ stmt_list: list[dict[str, object]],
260
+ ) -> dict[str, int]:
261
+ # Compute file end positions from statement attributes.
262
+ end_line = 1
263
+ end_file_pos = 0
264
+ end_token_pos = 0
265
+
266
+ for stmt in stmt_list:
267
+ attrs = stmt.get("attributes", {})
268
+ if not isinstance(attrs, dict):
269
+ continue
270
+ val = attrs.get("endLine")
271
+ if isinstance(val, int) and val > end_line:
272
+ end_line = val
273
+ val = attrs.get("endFilePos")
274
+ if isinstance(val, int) and val > end_file_pos:
275
+ end_file_pos = val
276
+ val = attrs.get("endTokenPos")
277
+ if isinstance(val, int) and val > end_token_pos:
278
+ end_token_pos = val
279
+
280
+ return {
281
+ "endLine": end_line,
282
+ "endFilePos": end_file_pos,
283
+ "endTokenPos": end_token_pos,
284
+ }
302
285
 
303
286
  def _process_node(
304
287
  self,
305
- storage: Storage,
306
- node_data: Any,
288
+ modifier: Modifier,
289
+ node_data: object,
307
290
  parent_id: str | None,
308
291
  field_name: str | None,
309
292
  index: int | None,
310
293
  node_counter: list[int],
311
294
  prefix: str,
312
295
  ) -> str | None:
313
- """Process a single node and its children recursively.
296
+ # Recursively convert a PHP-Parser JSON node into graph nodes/edges.
297
+ if not isinstance(node_data, dict) or "nodeType" not in node_data:
298
+ return None
314
299
 
315
- Args:
316
- storage: Storage to populate.
317
- node_data: JSON node data.
318
- parent_id: Parent node ID if this is a child.
319
- field_name: Field name in parent if this is a child.
320
- index: Array index if this is an array element.
321
- node_counter: Mutable list with single int counter for generating node IDs.
322
- prefix: Prefix for node IDs (file hash for file nodes, empty for code nodes).
300
+ node_id = self._generate_node_id(node_counter, prefix)
301
+ properties, child_fields = self._extract_node_data(node_data)
323
302
 
324
- Returns:
325
- Node ID if a node was created, None otherwise.
326
- """
327
- if node_data is None:
303
+ node_type_val = properties.pop("nodeType")
304
+ if not isinstance(node_type_val, str):
328
305
  return None
329
306
 
330
- if not isinstance(node_data, dict):
331
- return None
307
+ modifier.add_node(node_id, node_type_val, **properties)
332
308
 
333
- # Only create nodes for objects with nodeType
334
- if "nodeType" not in node_data:
335
- return None
309
+ if parent_id is not None and field_name is not None:
310
+ if index is not None:
311
+ modifier.add_edge(parent_id, node_id, field=field_name, index=index)
312
+ else:
313
+ modifier.add_edge(parent_id, node_id, field=field_name)
336
314
 
337
- # Generate unique node ID
338
- current_counter = node_counter[0]
339
- if prefix:
340
- node_id = f"{prefix}_{current_counter}"
341
- else:
342
- node_id = f"node_{current_counter}"
315
+ self._process_children(modifier, child_fields, node_id, node_counter, prefix)
316
+ return node_id
317
+
318
+ @staticmethod
319
+ def _generate_node_id(node_counter: list[int], prefix: str) -> str:
320
+ # Generate unique node ID from counter and prefix.
321
+ current = node_counter[0]
343
322
  node_counter[0] += 1
323
+ if prefix:
324
+ return f"{prefix}_{current}"
325
+ return f"node_{current}"
344
326
 
345
- # Collect node properties
346
- properties: dict[str, Any] = {}
347
- child_fields: list[tuple[str, Any]] = []
327
+ @staticmethod
328
+ def _extract_node_data(
329
+ node_data: dict[str, object],
330
+ ) -> tuple[dict[str, object], list[tuple[str, object]]]:
331
+ # Separate scalar properties from child fields.
332
+ properties: dict[str, object] = {}
333
+ child_fields: list[tuple[str, object]] = []
348
334
 
349
335
  for key, value in node_data.items():
350
- if key == "attributes":
351
- # Flatten attributes to node properties
352
- if isinstance(value, dict):
353
- properties.update(value)
336
+ if key == "attributes" and isinstance(value, dict):
337
+ properties.update(value)
354
338
  elif isinstance(value, dict):
355
- # Nested object - will create child node
356
339
  child_fields.append((key, value))
357
- elif isinstance(value, list):
358
- # Check if it's an array of objects or scalars
359
- if value and isinstance(value[0], dict):
360
- # Array of objects - create child nodes
361
- child_fields.append((key, value))
362
- else:
363
- # Array of scalars - save as property
364
- properties[key] = value
340
+ elif isinstance(value, list) and value and isinstance(value[0], dict):
341
+ child_fields.append((key, value))
365
342
  else:
366
- # Scalar property
367
343
  properties[key] = value
368
344
 
369
- # Add node to storage
370
- storage.add_node(node_id)
371
- if properties:
372
- storage.set_node_props(node_id, properties)
345
+ return properties, child_fields
373
346
 
374
- # Create edge from parent if applicable
375
- if parent_id is not None and field_name is not None:
376
- edge_props: dict[str, str | int] = {"field": field_name}
377
- if index is not None:
378
- edge_props["index"] = index
379
- edge_id = (parent_id, node_id, "PARENT_OF")
380
- storage.add_edge(edge_id)
381
- if edge_props:
382
- storage.set_edge_props(edge_id, edge_props)
383
-
384
- # Process child fields recursively
347
+ def _process_children(
348
+ self,
349
+ modifier: Modifier,
350
+ child_fields: list[tuple[str, object]],
351
+ parent_id: str,
352
+ node_counter: list[int],
353
+ prefix: str,
354
+ ) -> None:
355
+ # Process child fields recursively.
385
356
  for child_key, child_value in child_fields:
386
357
  if isinstance(child_value, list):
387
358
  for idx, item in enumerate(child_value):
388
359
  self._process_node(
389
- storage, item, node_id, child_key, idx, node_counter, prefix
360
+ modifier, item, parent_id, child_key, idx, node_counter, prefix
390
361
  )
391
362
  else:
392
363
  self._process_node(
393
- storage,
364
+ modifier,
394
365
  child_value,
395
- node_id,
366
+ parent_id,
396
367
  child_key,
397
368
  None,
398
369
  node_counter,
399
370
  prefix,
400
371
  )
401
-
402
- return node_id
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: php-parser-py
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Python wrapper for PHP-Parser using cpg2py
5
5
  Author: samhsu-dev
6
6
  Author-email: samhsu-dev <yxu166@jh.edu>
@@ -13,9 +13,9 @@ Description-Content-Type: text/markdown
13
13
 
14
14
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
15
15
 
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.
16
+ Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/samhsu-dev/cpg2py) graph framework.
17
17
 
18
- - **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
18
+ - **Automated**: Uses [static-php-py](https://github.com/jhu-seclab-beluga/static-php-py) binaries (no local PHP required).
19
19
  - **Graph-Based**: AST nodes are stored in a queryable graph database.
20
20
  - **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
21
21
 
@@ -25,38 +25,37 @@ pip install php-parser-py
25
25
  ```
26
26
 
27
27
  ```python
28
- from php_parser_py import parse_file, parse_project
28
+ from php_parser_py import parse_file, Modifier
29
29
 
30
30
  # 1. Parse
31
31
  ast = parse_file("src/User.php")
32
- # ast = parse_project("src/")
33
32
 
34
- # 2. Traverse
35
- files = ast.file_nodes()
36
-
37
- # Find specific nodes
33
+ # 2. Query
38
34
  functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
39
-
40
- # Get properties (scalars)
41
35
  for func in functions:
42
36
  print(f"Function: {func['namespacedName']}")
43
-
44
- # Get child nodes (structure)
45
- for func in functions:
46
- # Use helper method or traverse edges
47
37
  params = [c for c in ast.succ(func) if c.node_type == "Param"]
38
+
39
+ # 3. Modify properties
40
+ func.set_property("analyzed", True)
41
+
42
+ # 4. Modify structure
43
+ modifier = Modifier(ast)
44
+ modifier.add_node("new_1", "Stmt_Break")
45
+ modifier.add_edge("parent_id", "new_1", field="stmts", index=0)
46
+ modifier.remove_node("obsolete_node_id")
48
47
  ```
49
48
 
50
49
  ## Documentation
51
50
 
52
51
  - **[Architecture & Design](docs/design.md)**
53
- Detailed design documentation covering internal architecture, class specifications, and design rationale.
52
+ Internal architecture, class specifications, and design rationale.
54
53
 
55
54
  - **[AST Structure Reference](docs/libs/php_parser_ast.md)**
56
- Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
55
+ Complete table of all node types, subnodes (children), and properties.
57
56
 
58
- - **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
59
- How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
57
+ - **[cpg2py Graph API](docs/libs/cpg2py.md)**
58
+ Querying (`nodes`, `succ`, `prev`)
60
59
 
61
60
  ## Requirements
62
61
  - Python ≥ 3.11
@@ -1,9 +1,10 @@
1
- php_parser_py/__init__.py,sha256=heraS-IKwTKjDeatzSe2OMPv7zUErOeuKZHMeJMEPUg,3387
1
+ php_parser_py/__init__.py,sha256=6fIFsw0q19xCx3_A1ii8wj07kc5gi9M00S_IORyXebk,2873
2
2
  php_parser_py/_ast.py,sha256=hEH5XmhCAD_Qn5QgVBmGlas0mUqKVMY1eMFXeLLZdtc,11744
3
3
  php_parser_py/_edge.py,sha256=bHMtb0Bf-bQjBzlTaB01pnntYcE3WL6JRWiMyGlzq0M,2919
4
4
  php_parser_py/_exceptions.py,sha256=4jX9WfV--vCnSacEQgEE0Xtm09Ii88GkYj17yBMf7Cc,1788
5
+ php_parser_py/_modifier.py,sha256=Aie9HzaVUsgaPKMmPlp6N_vXYgIWmlCUh5ZGXdLud9E,4265
5
6
  php_parser_py/_node.py,sha256=zyp0j4GRBCe2VFt22ajbYM9ZYuxgLs-Awdjwi2FisL4,9918
6
- php_parser_py/_parser.py,sha256=mwi7idEJZLgzkMPvUThHyt1MjOEEuNEqfyJam9YVRg4,14511
7
+ php_parser_py/_parser.py,sha256=AdbgMkUJJ5aRlESg83eOQbUnfUHT6bEEVrIvmaOyUj4,12497
7
8
  php_parser_py/_printer.py,sha256=mvJ9o8ql_Yk9-6u69Jx-4azD7aIG9W1NDWaJVkzc2gI,3506
8
9
  php_parser_py/_resources.py,sha256=TUYfH58HV1y93kq39gW-aHKkR1MRZQb131VHzfR4LBc,4305
9
10
  php_parser_py/_runner.py,sha256=MxTh5Z7tuyOLneX0IggfshmyQf6mL1duJPYozu4899c,6985
@@ -11,6 +12,6 @@ php_parser_py/resources/php-parser-4.19.4.zip,sha256=ND6zsylItt4owMoQnq-3id_ScMA
11
12
  php_parser_py/vendor/.extracted,sha256=35VHVRaR8xmvJSW2J6Z8MXxiFVnsRgeuJJPNS_5yzB8,64
12
13
  php_parser_py/vendor/LICENSE.txt,sha256=3YWeEH-sn4ekQTq4w8lvDxYRoX9Zai21YqWk9JBMHpc,1520
13
14
  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,,
15
+ php_parser_py-1.2.2.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
16
+ php_parser_py-1.2.2.dist-info/METADATA,sha256=4Am9AShDRMUCfAjY-fT36YtNjE95Yz66q63QM4uqVck,1925
17
+ php_parser_py-1.2.2.dist-info/RECORD,,