php-parser-py 1.1.0__tar.gz → 1.2.0__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.
- php_parser_py-1.2.0/PKG-INFO +61 -0
- php_parser_py-1.2.0/README.md +50 -0
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/pyproject.toml +5 -2
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/__init__.py +21 -39
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/_ast.py +30 -26
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/_edge.py +3 -1
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/_parser.py +83 -101
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/_printer.py +9 -13
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/_resources.py +1 -1
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/_runner.py +12 -32
- php_parser_py-1.1.0/PKG-INFO +0 -279
- php_parser_py-1.1.0/README.md +0 -268
- /php_parser_py-1.1.0/src/php_parser_py/exceptions.py → /php_parser_py-1.2.0/src/php_parser_py/_exceptions.py +0 -0
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/_node.py +0 -0
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/resources/php-parser-4.19.4.zip +0 -0
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/vendor/.extracted +0 -0
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/vendor/LICENSE.txt +0 -0
- {php_parser_py-1.1.0 → php_parser_py-1.2.0}/src/php_parser_py/vendor/php-parser.phar +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: php-parser-py
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Python wrapper for PHP-Parser using cpg2py
|
|
5
|
+
Author: samhsu-dev
|
|
6
|
+
Author-email: samhsu-dev <yxu166@jh.edu>
|
|
7
|
+
Requires-Dist: cpg2py>=1.2.0
|
|
8
|
+
Requires-Dist: static-php-py
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# php-parser-py
|
|
13
|
+
|
|
14
|
+
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
|
+
|
|
16
|
+
- **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
|
|
17
|
+
- **Graph-Based**: AST nodes are stored in a queryable graph database.
|
|
18
|
+
- **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
```bash
|
|
22
|
+
pip install php-parser-py
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from php_parser_py import parse_file, parse_project
|
|
27
|
+
|
|
28
|
+
# 1. Parse
|
|
29
|
+
ast = parse_file("src/User.php")
|
|
30
|
+
# ast = parse_project("src/")
|
|
31
|
+
|
|
32
|
+
# 2. Traverse
|
|
33
|
+
files = ast.file_nodes()
|
|
34
|
+
|
|
35
|
+
# Find specific nodes
|
|
36
|
+
functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
|
|
37
|
+
|
|
38
|
+
# Get properties (scalars)
|
|
39
|
+
for func in functions:
|
|
40
|
+
print(f"Function: {func['namespacedName']}")
|
|
41
|
+
|
|
42
|
+
# Get child nodes (structure)
|
|
43
|
+
for func in functions:
|
|
44
|
+
# Use helper method or traverse edges
|
|
45
|
+
params = [c for c in ast.succ(func) if c.node_type == "Param"]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
- **[Architecture & Design](docs/design.md)**
|
|
51
|
+
Detailed design documentation covering internal architecture, class specifications, and design rationale.
|
|
52
|
+
|
|
53
|
+
- **[AST Structure Reference](docs/libs/php_parser_ast.md)**
|
|
54
|
+
Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
|
|
55
|
+
|
|
56
|
+
- **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
|
|
57
|
+
How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
- Python ≥ 3.11
|
|
61
|
+
- (Optional) Custom PHP binary can be configured via `Parser` constructor.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# php-parser-py
|
|
2
|
+
|
|
3
|
+
Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
|
|
4
|
+
|
|
5
|
+
- **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
|
|
6
|
+
- **Graph-Based**: AST nodes are stored in a queryable graph database.
|
|
7
|
+
- **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
```bash
|
|
11
|
+
pip install php-parser-py
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from php_parser_py import parse_file, parse_project
|
|
16
|
+
|
|
17
|
+
# 1. Parse
|
|
18
|
+
ast = parse_file("src/User.php")
|
|
19
|
+
# ast = parse_project("src/")
|
|
20
|
+
|
|
21
|
+
# 2. Traverse
|
|
22
|
+
files = ast.file_nodes()
|
|
23
|
+
|
|
24
|
+
# Find specific nodes
|
|
25
|
+
functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
|
|
26
|
+
|
|
27
|
+
# Get properties (scalars)
|
|
28
|
+
for func in functions:
|
|
29
|
+
print(f"Function: {func['namespacedName']}")
|
|
30
|
+
|
|
31
|
+
# Get child nodes (structure)
|
|
32
|
+
for func in functions:
|
|
33
|
+
# Use helper method or traverse edges
|
|
34
|
+
params = [c for c in ast.succ(func) if c.node_type == "Param"]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
- **[Architecture & Design](docs/design.md)**
|
|
40
|
+
Detailed design documentation covering internal architecture, class specifications, and design rationale.
|
|
41
|
+
|
|
42
|
+
- **[AST Structure Reference](docs/libs/php_parser_ast.md)**
|
|
43
|
+
Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
|
|
44
|
+
|
|
45
|
+
- **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
|
|
46
|
+
How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
- Python ≥ 3.11
|
|
50
|
+
- (Optional) Custom PHP binary can be configured via `Parser` constructor.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "php-parser-py"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
description = "Python wrapper for PHP-Parser using cpg2py"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -8,7 +8,7 @@ authors = [
|
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"cpg2py>=1.
|
|
11
|
+
"cpg2py>=1.2.0",
|
|
12
12
|
"static-php-py",
|
|
13
13
|
]
|
|
14
14
|
|
|
@@ -25,5 +25,8 @@ source-include = ["resources/*.zip"]
|
|
|
25
25
|
|
|
26
26
|
[dependency-groups]
|
|
27
27
|
dev = [
|
|
28
|
+
"black>=26.1.0",
|
|
29
|
+
"isort>=7.0.0",
|
|
30
|
+
"pylint>=4.0.4",
|
|
28
31
|
"pytest>=9.0.2",
|
|
29
32
|
]
|
|
@@ -5,6 +5,12 @@ 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
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
8
14
|
from php_parser_py._parser import Parser as _Parser
|
|
9
15
|
from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
|
|
10
16
|
from php_parser_py._resources import ensure_php_parser_extracted
|
|
@@ -14,9 +20,9 @@ ensure_php_parser_extracted()
|
|
|
14
20
|
|
|
15
21
|
# Import and expose public classes directly
|
|
16
22
|
from php_parser_py._ast import AST
|
|
17
|
-
from php_parser_py._node import Node
|
|
18
23
|
from php_parser_py._edge import Edge
|
|
19
|
-
from php_parser_py.
|
|
24
|
+
from php_parser_py._exceptions import ParseError, RunnerError
|
|
25
|
+
from php_parser_py._node import Node
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
class Parser(_Parser):
|
|
@@ -53,35 +59,6 @@ def parse_code(code: str) -> list[Node]:
|
|
|
53
59
|
return parser.parse_code(code)
|
|
54
60
|
|
|
55
61
|
|
|
56
|
-
def parse(code: str) -> AST:
|
|
57
|
-
"""Parse PHP code string into an AST (backward compatibility).
|
|
58
|
-
|
|
59
|
-
This is a convenience function that creates a temporary project/file structure.
|
|
60
|
-
For code-only parsing without project structure, use parse_code() instead.
|
|
61
|
-
|
|
62
|
-
Args:
|
|
63
|
-
code: PHP source code string.
|
|
64
|
-
|
|
65
|
-
Returns:
|
|
66
|
-
AST instance with project -> file -> statements hierarchy.
|
|
67
|
-
|
|
68
|
-
Raises:
|
|
69
|
-
ParseError: If PHP-Parser reports syntax error.
|
|
70
|
-
"""
|
|
71
|
-
# Create a temporary file-like structure for backward compatibility
|
|
72
|
-
import tempfile
|
|
73
|
-
from pathlib import Path
|
|
74
|
-
|
|
75
|
-
with tempfile.NamedTemporaryFile(mode='w', suffix='.php', delete=False) as f:
|
|
76
|
-
f.write(code)
|
|
77
|
-
temp_path = f.name
|
|
78
|
-
|
|
79
|
-
try:
|
|
80
|
-
return parse_file(temp_path)
|
|
81
|
-
finally:
|
|
82
|
-
Path(temp_path).unlink()
|
|
83
|
-
|
|
84
|
-
|
|
85
62
|
def parse_file(path: str) -> AST:
|
|
86
63
|
"""Read and parse a single PHP file into an AST.
|
|
87
64
|
|
|
@@ -102,25 +79,30 @@ def parse_file(path: str) -> AST:
|
|
|
102
79
|
return parser.parse_file(path)
|
|
103
80
|
|
|
104
81
|
|
|
105
|
-
def parse_project(
|
|
106
|
-
|
|
82
|
+
def parse_project(
|
|
83
|
+
project_path: str,
|
|
84
|
+
file_filter: "Callable[[Path], bool]" = lambda p: p.suffix == ".php", # type: ignore
|
|
85
|
+
) -> AST:
|
|
86
|
+
"""Parse all PHP files in a project directory into an AST.
|
|
107
87
|
|
|
108
|
-
|
|
109
|
-
|
|
88
|
+
Recursively traverses the project directory to find all PHP files,
|
|
89
|
+
then parses them into a single AST with project and file nodes.
|
|
110
90
|
|
|
111
91
|
Args:
|
|
112
|
-
|
|
113
|
-
|
|
92
|
+
project_path: Project root directory path.
|
|
93
|
+
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.
|
|
114
95
|
|
|
115
96
|
Returns:
|
|
116
97
|
AST instance with project -> files -> statements hierarchy.
|
|
117
98
|
|
|
118
99
|
Raises:
|
|
119
100
|
ParseError: If any file has syntax errors.
|
|
120
|
-
FileNotFoundError: If
|
|
101
|
+
FileNotFoundError: If project directory does not exist.
|
|
102
|
+
ValueError: If project_path is not a directory.
|
|
121
103
|
"""
|
|
122
104
|
parser = Parser()
|
|
123
|
-
return parser.parse_project(
|
|
105
|
+
return parser.parse_project(project_path, file_filter=file_filter)
|
|
124
106
|
|
|
125
107
|
|
|
126
108
|
__version__ = "0.1.0"
|
|
@@ -77,35 +77,25 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
77
77
|
return None
|
|
78
78
|
return Edge(self._storage, fid, tid, eid)
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"""Return the root project node of the AST.
|
|
80
|
+
def project_node(self) -> Optional[Node]:
|
|
81
|
+
"""Return the project node.
|
|
83
82
|
|
|
84
|
-
The
|
|
83
|
+
The project node is the root of the AST with ID "project".
|
|
85
84
|
|
|
86
85
|
Returns:
|
|
87
|
-
|
|
86
|
+
Project Node instance or None if not found.
|
|
88
87
|
"""
|
|
89
88
|
if not self._storage.contains_node(self._root_node_id):
|
|
90
89
|
return None
|
|
91
90
|
return Node(self._storage, self._root_node_id)
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
def project_node(self) -> Optional[Node]:
|
|
95
|
-
"""Return the project node (alias for root_node).
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
Project Node instance or None if not found.
|
|
99
|
-
"""
|
|
100
|
-
return self.root_node
|
|
101
|
-
|
|
102
|
-
def files(self) -> list[Node]:
|
|
92
|
+
def file_nodes(self) -> list[Node]:
|
|
103
93
|
"""Return all file nodes in the project.
|
|
104
94
|
|
|
105
95
|
Returns:
|
|
106
96
|
List of File Node instances.
|
|
107
97
|
"""
|
|
108
|
-
if not self.
|
|
98
|
+
if not self.project_node():
|
|
109
99
|
return []
|
|
110
100
|
|
|
111
101
|
file_nodes = []
|
|
@@ -119,7 +109,9 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
119
109
|
# Sort by file path for consistent ordering
|
|
120
110
|
return sorted(file_nodes, key=lambda n: n.get_property("filePath", ""))
|
|
121
111
|
|
|
122
|
-
def get_file(
|
|
112
|
+
def get_file(
|
|
113
|
+
self, node_id: str
|
|
114
|
+
) -> Optional[Node]: # pylint: disable=too-many-return-statements
|
|
123
115
|
"""Get the file node that contains the given node.
|
|
124
116
|
|
|
125
117
|
Traverses upward from the given node via PARENT_OF edges until
|
|
@@ -202,7 +194,7 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
202
194
|
top_level_nodes = self._get_file_statements(file_hash)
|
|
203
195
|
else:
|
|
204
196
|
# Try to export from file structure first
|
|
205
|
-
file_nodes = self.
|
|
197
|
+
file_nodes = self.file_nodes()
|
|
206
198
|
if file_nodes:
|
|
207
199
|
# Export all files (flattened)
|
|
208
200
|
top_level_nodes = []
|
|
@@ -215,7 +207,7 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
215
207
|
nodes_with_parents = set()
|
|
216
208
|
|
|
217
209
|
for edge_id in self._storage.get_edges():
|
|
218
|
-
|
|
210
|
+
_, to_id, edge_type = edge_id
|
|
219
211
|
if edge_type == "PARENT_OF":
|
|
220
212
|
nodes_with_parents.add(to_id)
|
|
221
213
|
|
|
@@ -253,10 +245,20 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
253
245
|
|
|
254
246
|
return sorted(statement_nodes, key=get_index)
|
|
255
247
|
|
|
256
|
-
def _reconstruct_node(
|
|
257
|
-
|
|
248
|
+
def _reconstruct_node( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
|
249
|
+
self, nid: str
|
|
250
|
+
) -> dict[str, Any]:
|
|
251
|
+
"""Recursively reconstruct JSON object for a node.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
nid: Node ID to reconstruct.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Dictionary representing the node in PHP-Parser JSON format.
|
|
258
|
+
"""
|
|
258
259
|
props = self._storage.get_node_props(nid)
|
|
259
260
|
if props is None:
|
|
261
|
+
logger.warning("Node %s not found in storage, returning empty dict", nid)
|
|
260
262
|
return {}
|
|
261
263
|
|
|
262
264
|
result: dict[str, Any] = {}
|
|
@@ -348,10 +350,12 @@ class AST(AbcGraphQuerier[Node, Edge]):
|
|
|
348
350
|
|
|
349
351
|
# Add default values for common optional fields that PHP-Parser expects
|
|
350
352
|
if node_type and ("attrGroups" not in result):
|
|
351
|
-
if (
|
|
352
|
-
node_type
|
|
353
|
-
node_type
|
|
354
|
-
node_type.startswith("
|
|
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
|
+
):
|
|
355
359
|
result["attrGroups"] = []
|
|
356
|
-
|
|
360
|
+
|
|
357
361
|
return result
|
|
@@ -20,7 +20,9 @@ class Edge(AbcEdgeQuerier):
|
|
|
20
20
|
_edge_id: Tuple of (from_id, to_id, edge_type).
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
-
def __init__(
|
|
23
|
+
def __init__(
|
|
24
|
+
self, graph: Storage, f_nid: str, t_nid: str, e_type: str = "PARENT_OF"
|
|
25
|
+
) -> None:
|
|
24
26
|
"""Initialize Edge with storage and edge identifiers.
|
|
25
27
|
|
|
26
28
|
Args:
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import logging
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Optional
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
7
|
|
|
8
8
|
from cpg2py import Storage
|
|
9
|
+
from static_php_py import PHP
|
|
9
10
|
|
|
10
11
|
from php_parser_py._ast import AST
|
|
12
|
+
from php_parser_py._exceptions import ParseError, RunnerError
|
|
11
13
|
from php_parser_py._node import Node
|
|
12
14
|
from php_parser_py._runner import Runner
|
|
13
|
-
from php_parser_py.exceptions import ParseError, RunnerError
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger(__name__)
|
|
16
17
|
|
|
@@ -21,56 +22,19 @@ class Parser:
|
|
|
21
22
|
Provides three parsing methods:
|
|
22
23
|
- parse_code: Parse code string, returns list of top-level statement nodes
|
|
23
24
|
- parse_file: Parse single file, returns AST with project and file nodes
|
|
24
|
-
- parse_project: Parse
|
|
25
|
+
- parse_project: Parse project directory, returns AST with project and all PHP files
|
|
25
26
|
|
|
26
27
|
Attributes:
|
|
27
28
|
_runner: Runner instance for PHP-Parser invocation.
|
|
28
29
|
"""
|
|
29
30
|
|
|
30
|
-
def __init__(
|
|
31
|
-
self,
|
|
32
|
-
php_binary_path: Optional[Path] = None,
|
|
33
|
-
php_binary_url: Optional[str] = None,
|
|
34
|
-
) -> None:
|
|
31
|
+
def __init__(self, php: Optional[PHP] = None) -> None:
|
|
35
32
|
"""Initialize Parser with Runner.
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
php_binary_path: Optional path to local PHP binary.
|
|
39
|
-
php_binary_url: Optional URL to download PHP binary from.
|
|
40
|
-
"""
|
|
41
|
-
self._runner = Runner(
|
|
42
|
-
php_binary_path=php_binary_path,
|
|
43
|
-
php_binary_url=php_binary_url,
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
def parse(self, code: str) -> AST:
|
|
47
|
-
"""Parse PHP code string into an AST (backward compatibility).
|
|
48
|
-
|
|
49
|
-
Creates a temporary project/file structure. For code-only parsing
|
|
50
|
-
without project structure, use parse_code() instead.
|
|
51
33
|
|
|
52
34
|
Args:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Returns:
|
|
56
|
-
AST instance with project -> file -> statements hierarchy.
|
|
57
|
-
|
|
58
|
-
Raises:
|
|
59
|
-
ParseError: If code has syntax errors.
|
|
60
|
-
RunnerError: If PHP execution fails.
|
|
35
|
+
php: Optional PHP instance. If not provided, uses builtin PHP.
|
|
61
36
|
"""
|
|
62
|
-
|
|
63
|
-
from pathlib import Path
|
|
64
|
-
|
|
65
|
-
# Create temporary file
|
|
66
|
-
with tempfile.NamedTemporaryFile(mode='w', suffix='.php', delete=False) as f:
|
|
67
|
-
f.write(code)
|
|
68
|
-
temp_path = f.name
|
|
69
|
-
|
|
70
|
-
try:
|
|
71
|
-
return self.parse_file(temp_path)
|
|
72
|
-
finally:
|
|
73
|
-
Path(temp_path).unlink()
|
|
37
|
+
self._runner = Runner(php=php)
|
|
74
38
|
|
|
75
39
|
def parse_code(self, code: str) -> list[Node]:
|
|
76
40
|
"""Parse PHP code string into a list of top-level statement nodes.
|
|
@@ -94,7 +58,7 @@ class Parser:
|
|
|
94
58
|
if "Syntax error" in str(e):
|
|
95
59
|
raise ParseError("Syntax error in PHP code", line=1) from e
|
|
96
60
|
raise
|
|
97
|
-
|
|
61
|
+
|
|
98
62
|
# Normalize to list
|
|
99
63
|
if not isinstance(json_data, list):
|
|
100
64
|
json_data = [json_data]
|
|
@@ -105,7 +69,9 @@ class Parser:
|
|
|
105
69
|
node_counter = [1] # Use list to allow mutation in recursive calls
|
|
106
70
|
|
|
107
71
|
for item in json_data:
|
|
108
|
-
node_id = self._process_node(
|
|
72
|
+
node_id = self._process_node(
|
|
73
|
+
storage, item, None, None, None, node_counter, ""
|
|
74
|
+
)
|
|
109
75
|
if node_id:
|
|
110
76
|
node_ids.append(node_id)
|
|
111
77
|
|
|
@@ -140,7 +106,7 @@ class Parser:
|
|
|
140
106
|
file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
|
|
141
107
|
|
|
142
108
|
code = file_path.read_text(encoding="utf-8")
|
|
143
|
-
|
|
109
|
+
|
|
144
110
|
try:
|
|
145
111
|
json_data = self._runner.parse(code)
|
|
146
112
|
except RunnerError as e:
|
|
@@ -153,63 +119,69 @@ class Parser:
|
|
|
153
119
|
json_data = [json_data]
|
|
154
120
|
|
|
155
121
|
storage = self._create_project_structure(
|
|
156
|
-
[(file_path, json_data)],
|
|
122
|
+
[(file_path, json_data)],
|
|
157
123
|
[(file_path, file_hash)],
|
|
158
|
-
project_path=project_path
|
|
124
|
+
project_path=project_path,
|
|
159
125
|
)
|
|
160
126
|
return AST(storage, root_node_id="project")
|
|
161
127
|
|
|
162
|
-
def parse_project(
|
|
163
|
-
|
|
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.
|
|
164
134
|
|
|
165
|
-
|
|
135
|
+
Recursively traverses the project directory to find all PHP files,
|
|
136
|
+
then parses them into a single AST with project and file nodes.
|
|
166
137
|
Each file node has ID based on its path hash.
|
|
167
|
-
Project path is the common parent directory of all files, or the provided project_path.
|
|
168
138
|
|
|
169
139
|
Args:
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
172
143
|
|
|
173
144
|
Returns:
|
|
174
145
|
AST instance with project -> files -> statements hierarchy.
|
|
175
146
|
|
|
176
147
|
Raises:
|
|
177
148
|
ParseError: If any file has syntax errors.
|
|
178
|
-
FileNotFoundError: If
|
|
149
|
+
FileNotFoundError: If project directory does not exist.
|
|
150
|
+
ValueError: If project_path is not a directory.
|
|
179
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(f"No PHP files found in project directory: {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
|
+
{
|
|
173
|
+
"nodeType": "Project",
|
|
174
|
+
"label": "Project",
|
|
175
|
+
"path": str(project_path_obj),
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
return AST(storage, root_node_id="project")
|
|
179
|
+
|
|
180
|
+
# Prepare file information
|
|
180
181
|
file_infos = []
|
|
181
|
-
|
|
182
|
-
for path in paths:
|
|
183
|
-
file_path = Path(path).resolve()
|
|
184
|
-
if not file_path.exists():
|
|
185
|
-
raise FileNotFoundError(f"File not found: {path}")
|
|
182
|
+
for file_path in php_files:
|
|
186
183
|
file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
|
|
187
184
|
file_infos.append((file_path, file_hash))
|
|
188
|
-
resolved_paths.append(file_path)
|
|
189
|
-
|
|
190
|
-
# Determine project path
|
|
191
|
-
if project_path:
|
|
192
|
-
project_path = Path(project_path).resolve()
|
|
193
|
-
else:
|
|
194
|
-
# Compute common parent directory
|
|
195
|
-
if len(resolved_paths) == 1:
|
|
196
|
-
project_path = resolved_paths[0].parent
|
|
197
|
-
else:
|
|
198
|
-
# Find common parent
|
|
199
|
-
common_parts = None
|
|
200
|
-
for file_path in resolved_paths:
|
|
201
|
-
parts = file_path.parts
|
|
202
|
-
if common_parts is None:
|
|
203
|
-
common_parts = parts
|
|
204
|
-
else:
|
|
205
|
-
# Find common prefix
|
|
206
|
-
min_len = min(len(common_parts), len(parts))
|
|
207
|
-
common_parts = [p for i, p in enumerate(common_parts[:min_len]) if parts[i] == p]
|
|
208
|
-
if common_parts:
|
|
209
|
-
project_path = Path(*common_parts)
|
|
210
|
-
else:
|
|
211
|
-
# Fallback to first file's parent
|
|
212
|
-
project_path = resolved_paths[0].parent
|
|
213
185
|
|
|
214
186
|
# Parse all files
|
|
215
187
|
all_json_data = []
|
|
@@ -225,14 +197,16 @@ class Parser:
|
|
|
225
197
|
raise ParseError(f"Syntax error in {file_path}", line=1) from e
|
|
226
198
|
raise
|
|
227
199
|
|
|
228
|
-
storage = self._create_project_structure(
|
|
200
|
+
storage = self._create_project_structure(
|
|
201
|
+
all_json_data, file_infos, project_path=project_path_obj
|
|
202
|
+
)
|
|
229
203
|
return AST(storage, root_node_id="project")
|
|
230
204
|
|
|
231
205
|
def _create_project_structure(
|
|
232
206
|
self,
|
|
233
207
|
files_data: list[tuple[Path, Any]],
|
|
234
208
|
file_infos: list[tuple[Path, str]],
|
|
235
|
-
project_path: Path
|
|
209
|
+
project_path: Path,
|
|
236
210
|
) -> Storage:
|
|
237
211
|
"""Create AST structure with project -> files -> statements hierarchy.
|
|
238
212
|
|
|
@@ -249,11 +223,10 @@ class Parser:
|
|
|
249
223
|
# Create project node (fixed ID: "project")
|
|
250
224
|
project_id = "project"
|
|
251
225
|
storage.add_node(project_id)
|
|
252
|
-
storage.set_node_props(
|
|
253
|
-
|
|
254
|
-
"label": "Project",
|
|
255
|
-
|
|
256
|
-
})
|
|
226
|
+
storage.set_node_props(
|
|
227
|
+
project_id,
|
|
228
|
+
{"nodeType": "Project", "label": "Project", "path": str(project_path)},
|
|
229
|
+
)
|
|
257
230
|
|
|
258
231
|
# Create file nodes and process their statements
|
|
259
232
|
for (file_path, file_hash), (_, json_data) in zip(file_infos, files_data):
|
|
@@ -267,12 +240,15 @@ class Parser:
|
|
|
267
240
|
# Create file node (ID: hex hash)
|
|
268
241
|
file_id = file_hash
|
|
269
242
|
storage.add_node(file_id)
|
|
270
|
-
storage.set_node_props(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
243
|
+
storage.set_node_props(
|
|
244
|
+
file_id,
|
|
245
|
+
{
|
|
246
|
+
"nodeType": "File",
|
|
247
|
+
"label": "File",
|
|
248
|
+
"filePath": str(file_path), # Absolute path
|
|
249
|
+
"path": str(relative_path), # Relative path from project root
|
|
250
|
+
},
|
|
251
|
+
)
|
|
276
252
|
|
|
277
253
|
# Link file to project
|
|
278
254
|
edge_id = (project_id, file_id, "PARENT_OF")
|
|
@@ -291,7 +267,7 @@ class Parser:
|
|
|
291
267
|
|
|
292
268
|
return storage
|
|
293
269
|
|
|
294
|
-
def _process_node(
|
|
270
|
+
def _process_node( # pylint: disable=too-many-arguments,too-many-locals,too-many-branches
|
|
295
271
|
self,
|
|
296
272
|
storage: Storage,
|
|
297
273
|
node_data: Any,
|
|
@@ -299,7 +275,7 @@ class Parser:
|
|
|
299
275
|
field_name: str | None,
|
|
300
276
|
index: int | None,
|
|
301
277
|
node_counter: list[int],
|
|
302
|
-
prefix: str
|
|
278
|
+
prefix: str,
|
|
303
279
|
) -> str | None:
|
|
304
280
|
"""Process a single node and its children recursively.
|
|
305
281
|
|
|
@@ -383,7 +359,13 @@ class Parser:
|
|
|
383
359
|
else:
|
|
384
360
|
# Single child object
|
|
385
361
|
self._process_node(
|
|
386
|
-
storage,
|
|
362
|
+
storage,
|
|
363
|
+
field_value,
|
|
364
|
+
node_id,
|
|
365
|
+
field_name,
|
|
366
|
+
None,
|
|
367
|
+
node_counter,
|
|
368
|
+
prefix,
|
|
387
369
|
)
|
|
388
370
|
|
|
389
371
|
return node_id
|