php-parser-py 1.2.1__tar.gz → 1.2.2__tar.gz

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.
@@ -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
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
4
4
 
5
- Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
5
+ Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/samhsu-dev/cpg2py) graph framework.
6
6
 
7
- - **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
7
+ - **Automated**: Uses [static-php-py](https://github.com/jhu-seclab-beluga/static-php-py) binaries (no local PHP required).
8
8
  - **Graph-Based**: AST nodes are stored in a queryable graph database.
9
9
  - **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
10
10
 
@@ -14,38 +14,37 @@ pip install php-parser-py
14
14
  ```
15
15
 
16
16
  ```python
17
- from php_parser_py import parse_file, parse_project
17
+ from php_parser_py import parse_file, Modifier
18
18
 
19
19
  # 1. Parse
20
20
  ast = parse_file("src/User.php")
21
- # ast = parse_project("src/")
22
21
 
23
- # 2. Traverse
24
- files = ast.file_nodes()
25
-
26
- # Find specific nodes
22
+ # 2. Query
27
23
  functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
28
-
29
- # Get properties (scalars)
30
24
  for func in functions:
31
25
  print(f"Function: {func['namespacedName']}")
32
-
33
- # Get child nodes (structure)
34
- for func in functions:
35
- # Use helper method or traverse edges
36
26
  params = [c for c in ast.succ(func) if c.node_type == "Param"]
27
+
28
+ # 3. Modify properties
29
+ func.set_property("analyzed", True)
30
+
31
+ # 4. Modify structure
32
+ modifier = Modifier(ast)
33
+ modifier.add_node("new_1", "Stmt_Break")
34
+ modifier.add_edge("parent_id", "new_1", field="stmts", index=0)
35
+ modifier.remove_node("obsolete_node_id")
37
36
  ```
38
37
 
39
38
  ## Documentation
40
39
 
41
40
  - **[Architecture & Design](docs/design.md)**
42
- Detailed design documentation covering internal architecture, class specifications, and design rationale.
41
+ Internal architecture, class specifications, and design rationale.
43
42
 
44
43
  - **[AST Structure Reference](docs/libs/php_parser_ast.md)**
45
- Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
44
+ Complete table of all node types, subnodes (children), and properties.
46
45
 
47
- - **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
48
- How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
46
+ - **[cpg2py Graph API](docs/libs/cpg2py.md)**
47
+ Querying (`nodes`, `succ`, `prev`)
49
48
 
50
49
  ## Requirements
51
50
  - Python ≥ 3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "php-parser-py"
3
- version = "1.2.1"
3
+ version = "1.2.2"
4
4
  description = "Python wrapper for PHP-Parser using cpg2py"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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)
@@ -0,0 +1,371 @@
1
+ """Parser class for PHP code parsing."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Callable, Optional
7
+
8
+ from cpg2py import Storage
9
+ from static_php_py import PHP
10
+
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
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Parser:
21
+ """Parses PHP source code using PHP-Parser.
22
+
23
+ Uses Modifier for all AST construction. Parser never calls Storage
24
+ directly — all node/edge creation goes through Modifier.
25
+
26
+ Attributes:
27
+ _runner: Runner instance for PHP-Parser invocation.
28
+ """
29
+
30
+ def __init__(self, php: Optional[PHP] = None) -> None:
31
+ """Initialize Parser with Runner.
32
+
33
+ Args:
34
+ php: Optional PHP instance. If not provided, uses builtin PHP.
35
+ """
36
+ self._runner = Runner(php=php)
37
+
38
+ def parse_code(self, code: str) -> list[Node]:
39
+ """Parse PHP code string into a list of top-level statement nodes.
40
+
41
+ Does not create project or file nodes. Returns raw statement nodes
42
+ that can be used for analysis or inserted into other AST structures.
43
+
44
+ Args:
45
+ code: PHP source code to parse.
46
+
47
+ Returns:
48
+ List of Node instances representing top-level statements.
49
+
50
+ Raises:
51
+ ParseError: If code has syntax errors.
52
+ RunnerError: If PHP execution fails.
53
+ """
54
+ json_data = self._parse_php(code)
55
+ node_list = self._normalize_json(json_data)
56
+
57
+ modifier = Modifier(AST(Storage(), root_node_id="__code_root__"))
58
+ node_counter = [1]
59
+ node_ids: list[str] = []
60
+
61
+ for item in node_list:
62
+ node_id = self._process_node(
63
+ modifier, item, None, None, None, node_counter, ""
64
+ )
65
+ if node_id:
66
+ node_ids.append(node_id)
67
+
68
+ return [modifier.ast.node(nid) for nid in node_ids]
69
+
70
+ def parse_file(self, path: str) -> AST:
71
+ """Parse a single PHP file into an AST with project and file nodes.
72
+
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.
75
+
76
+ Args:
77
+ path: File path string.
78
+
79
+ Returns:
80
+ AST instance with project -> file -> statements hierarchy.
81
+
82
+ Raises:
83
+ ParseError: If PHP-Parser reports syntax error.
84
+ FileNotFoundError: If file does not exist.
85
+ """
86
+ file_path = Path(path).resolve()
87
+ if not file_path.exists():
88
+ raise FileNotFoundError(f"File not found: {path}")
89
+
90
+ project_path = file_path.parent
91
+ file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
92
+ code = file_path.read_text(encoding="utf-8")
93
+ json_data = self._parse_php(code)
94
+ file_list = self._normalize_json(json_data)
95
+
96
+ modifier = self._build_project_structure(
97
+ [(file_path, file_list)],
98
+ [(file_path, file_hash)],
99
+ project_path=project_path,
100
+ )
101
+ return modifier.ast
102
+
103
+ def parse_project(
104
+ self,
105
+ project_path: str,
106
+ file_filter: Callable[[Path], bool] = lambda p: p.suffix == ".php",
107
+ ) -> AST:
108
+ """Parse all PHP files in a project directory into an AST.
109
+
110
+ Recursively traverses the project directory to find all PHP files,
111
+ then parses them into a single AST with project and file nodes.
112
+
113
+ Args:
114
+ project_path: Project root directory path.
115
+ file_filter: Function to filter files. Takes a Path and returns
116
+ True if the file should be parsed. Defaults to .php suffix.
117
+
118
+ Returns:
119
+ AST instance with project -> files -> statements hierarchy.
120
+
121
+ Raises:
122
+ ParseError: If any file has syntax errors.
123
+ FileNotFoundError: If project directory does not exist.
124
+ ValueError: If project_path is not a directory.
125
+ """
126
+ project_path_obj = Path(project_path).resolve()
127
+
128
+ if not project_path_obj.exists():
129
+ raise FileNotFoundError(f"Project directory not found: {project_path}")
130
+ if not project_path_obj.is_dir():
131
+ raise ValueError(f"Project path is not a directory: {project_path}")
132
+
133
+ php_files = [
134
+ f for f in project_path_obj.rglob("*") if f.is_file() and file_filter(f)
135
+ ]
136
+
137
+ if not php_files:
138
+ logger.warning("No PHP files found in project directory: %s", project_path)
139
+ return self._build_empty_project(project_path_obj)
140
+
141
+ file_infos = [
142
+ (fp, hashlib.md5(str(fp).encode()).hexdigest()[:8]) for fp in php_files
143
+ ]
144
+
145
+ all_json_data = []
146
+ for file_path, _ in file_infos:
147
+ code = file_path.read_text(encoding="utf-8")
148
+ json_data = self._parse_php(code, source=str(file_path))
149
+ all_json_data.append((file_path, self._normalize_json(json_data)))
150
+
151
+ modifier = self._build_project_structure(
152
+ all_json_data, file_infos, project_path=project_path_obj
153
+ )
154
+ return modifier.ast
155
+
156
+ # -- Internal helpers --
157
+
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
166
+
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,
189
+ )
190
+ return modifier.ast
191
+
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()))
200
+
201
+ modifier.add_node("project", "Project", absolutePath=str(project_path))
202
+
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)
205
+
206
+ # Set sentinel position values on project node.
207
+ project_node = modifier.ast.node("project")
208
+ project_node.set_properties(
209
+ {
210
+ "startLine": -1,
211
+ "endLine": -1,
212
+ "startFilePos": -1,
213
+ "endFilePos": -1,
214
+ "startTokenPos": -1,
215
+ "endTokenPos": -1,
216
+ }
217
+ )
218
+
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
+ }
285
+
286
+ def _process_node(
287
+ self,
288
+ modifier: Modifier,
289
+ node_data: object,
290
+ parent_id: str | None,
291
+ field_name: str | None,
292
+ index: int | None,
293
+ node_counter: list[int],
294
+ prefix: str,
295
+ ) -> str | None:
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
299
+
300
+ node_id = self._generate_node_id(node_counter, prefix)
301
+ properties, child_fields = self._extract_node_data(node_data)
302
+
303
+ node_type_val = properties.pop("nodeType")
304
+ if not isinstance(node_type_val, str):
305
+ return None
306
+
307
+ modifier.add_node(node_id, node_type_val, **properties)
308
+
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)
314
+
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]
322
+ node_counter[0] += 1
323
+ if prefix:
324
+ return f"{prefix}_{current}"
325
+ return f"node_{current}"
326
+
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]] = []
334
+
335
+ for key, value in node_data.items():
336
+ if key == "attributes" and isinstance(value, dict):
337
+ properties.update(value)
338
+ elif isinstance(value, dict):
339
+ child_fields.append((key, value))
340
+ elif isinstance(value, list) and value and isinstance(value[0], dict):
341
+ child_fields.append((key, value))
342
+ else:
343
+ properties[key] = value
344
+
345
+ return properties, child_fields
346
+
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.
356
+ for child_key, child_value in child_fields:
357
+ if isinstance(child_value, list):
358
+ for idx, item in enumerate(child_value):
359
+ self._process_node(
360
+ modifier, item, parent_id, child_key, idx, node_counter, prefix
361
+ )
362
+ else:
363
+ self._process_node(
364
+ modifier,
365
+ child_value,
366
+ parent_id,
367
+ child_key,
368
+ None,
369
+ node_counter,
370
+ prefix,
371
+ )
@@ -1,402 +0,0 @@
1
- """Parser class for PHP code parsing."""
2
-
3
- import hashlib
4
- import logging
5
- from pathlib import Path
6
- from typing import Any, Callable, Optional
7
-
8
- from cpg2py import Storage
9
- from static_php_py import PHP
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
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- class Parser:
20
- """Parses PHP source code using PHP-Parser.
21
-
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
26
-
27
- Attributes:
28
- _runner: Runner instance for PHP-Parser invocation.
29
- """
30
-
31
- def __init__(self, php: Optional[PHP] = None) -> None:
32
- """Initialize Parser with Runner.
33
-
34
- Args:
35
- php: Optional PHP instance. If not provided, uses builtin PHP.
36
- """
37
- self._runner = Runner(php=php)
38
-
39
- def parse_code(self, code: str) -> list[Node]:
40
- """Parse PHP code string into a list of top-level statement nodes.
41
-
42
- Does not create project or file nodes. Returns raw statement nodes
43
- that can be used for analysis or inserted into other AST structures.
44
-
45
- Args:
46
- code: PHP source code to parse.
47
-
48
- Returns:
49
- List of Node instances representing top-level statements.
50
-
51
- Raises:
52
- ParseError: If code has syntax errors.
53
- RunnerError: If PHP execution fails.
54
- """
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
61
-
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()
69
- node_ids: list[str] = []
70
- node_counter = [1] # Use list to allow mutation in recursive calls
71
-
72
- for item in node_list:
73
- node_id = self._process_node(
74
- storage, item, None, None, None, node_counter, ""
75
- )
76
- if node_id:
77
- node_ids.append(node_id)
78
-
79
- # Return Node instances
80
- return [Node(storage, nid) for nid in node_ids]
81
-
82
- def parse_file(self, path: str) -> AST:
83
- """Parse a single PHP file into an AST with project and file nodes.
84
-
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.
88
-
89
- Args:
90
- path: File path string.
91
-
92
- Returns:
93
- AST instance with project -> file -> statements hierarchy.
94
-
95
- Raises:
96
- ParseError: If PHP-Parser reports syntax error.
97
- FileNotFoundError: If file does not exist.
98
- """
99
- file_path = Path(path).resolve()
100
- if not file_path.exists():
101
- raise FileNotFoundError(f"File not found: {path}")
102
-
103
- # Project path is the file's directory
104
- project_path = file_path.parent
105
-
106
- # Generate file hash (hex)
107
- file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
108
-
109
- code = file_path.read_text(encoding="utf-8")
110
-
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(
122
- [(file_path, file_list)],
123
- [(file_path, file_hash)],
124
- project_path=project_path,
125
- )
126
- return AST(storage, root_node_id="project")
127
-
128
- def parse_project(
129
- self,
130
- project_path: str,
131
- file_filter: Callable[[Path], bool] = lambda p: p.suffix == ".php",
132
- ) -> AST:
133
- """Parse all PHP files in a project directory into an AST.
134
-
135
- Recursively traverses the project directory to find all PHP files,
136
- then parses them into a single AST with project and file nodes.
137
- Each file node has ID based on its path hash.
138
-
139
- Args:
140
- project_path: Project root directory path.
141
- 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.
143
-
144
- Returns:
145
- AST instance with project -> files -> statements hierarchy.
146
-
147
- Raises:
148
- ParseError: If any file has syntax errors.
149
- FileNotFoundError: If project directory does not exist.
150
- ValueError: If project_path is not a directory.
151
- """
152
- project_path_obj = Path(project_path).resolve()
153
-
154
- if not project_path_obj.exists():
155
- raise FileNotFoundError(f"Project directory not found: {project_path}")
156
-
157
- if not project_path_obj.is_dir():
158
- raise ValueError(f"Project path is not a directory: {project_path}")
159
-
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)]
163
-
164
- if not php_files:
165
- 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")
175
-
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))
181
-
182
- # Parse all files
183
- all_json_data = []
184
- for file_path, _ in file_infos:
185
- 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
196
-
197
- storage = self._create_project_structure(
198
- all_json_data, file_infos, project_path=project_path_obj
199
- )
200
- return AST(storage, root_node_id="project")
201
-
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.
209
-
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.
214
-
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)},
226
- )
227
-
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
- )
272
-
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"})
277
-
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
- )
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
-
301
- return storage
302
-
303
- def _process_node(
304
- self,
305
- storage: Storage,
306
- node_data: Any,
307
- parent_id: str | None,
308
- field_name: str | None,
309
- index: int | None,
310
- node_counter: list[int],
311
- prefix: str,
312
- ) -> str | None:
313
- """Process a single node and its children recursively.
314
-
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).
323
-
324
- Returns:
325
- Node ID if a node was created, None otherwise.
326
- """
327
- if node_data is None:
328
- return None
329
-
330
- if not isinstance(node_data, dict):
331
- return None
332
-
333
- # Only create nodes for objects with nodeType
334
- if "nodeType" not in node_data:
335
- return None
336
-
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}"
343
- node_counter[0] += 1
344
-
345
- # Collect node properties
346
- properties: dict[str, Any] = {}
347
- child_fields: list[tuple[str, Any]] = []
348
-
349
- 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)
354
- elif isinstance(value, dict):
355
- # Nested object - will create child node
356
- 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
365
- else:
366
- # Scalar property
367
- properties[key] = value
368
-
369
- # Add node to storage
370
- storage.add_node(node_id)
371
- if properties:
372
- storage.set_node_props(node_id, properties)
373
-
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
385
- for child_key, child_value in child_fields:
386
- if isinstance(child_value, list):
387
- for idx, item in enumerate(child_value):
388
- self._process_node(
389
- storage, item, node_id, child_key, idx, node_counter, prefix
390
- )
391
- else:
392
- self._process_node(
393
- storage,
394
- child_value,
395
- node_id,
396
- child_key,
397
- None,
398
- node_counter,
399
- prefix,
400
- )
401
-
402
- return node_id