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 +11 -25
- php_parser_py/_modifier.py +140 -0
- php_parser_py/_parser.py +194 -225
- {php_parser_py-1.2.1.dist-info → php_parser_py-1.2.2.dist-info}/METADATA +18 -19
- {php_parser_py-1.2.1.dist-info → php_parser_py-1.2.2.dist-info}/RECORD +6 -5
- {php_parser_py-1.2.1.dist-info → php_parser_py-1.2.2.dist-info}/WHEEL +0 -0
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
|
|
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
|
|
20
|
-
from
|
|
21
|
-
from
|
|
22
|
-
from
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
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
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
from
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
151
|
+
modifier = self._build_project_structure(
|
|
198
152
|
all_json_data, file_infos, project_path=project_path_obj
|
|
199
153
|
)
|
|
200
|
-
return
|
|
154
|
+
return modifier.ast
|
|
201
155
|
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
-
|
|
306
|
-
node_data:
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
331
|
-
return None
|
|
307
|
+
modifier.add_node(node_id, node_type_val, **properties)
|
|
332
308
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
360
|
+
modifier, item, parent_id, child_key, idx, node_counter, prefix
|
|
390
361
|
)
|
|
391
362
|
else:
|
|
392
363
|
self._process_node(
|
|
393
|
-
|
|
364
|
+
modifier,
|
|
394
365
|
child_value,
|
|
395
|
-
|
|
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.
|
|
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
|
[](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/
|
|
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-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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.
|
|
55
|
+
Complete table of all node types, subnodes (children), and properties.
|
|
57
56
|
|
|
58
|
-
- **[Graph
|
|
59
|
-
|
|
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=
|
|
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=
|
|
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.
|
|
15
|
-
php_parser_py-1.2.
|
|
16
|
-
php_parser_py-1.2.
|
|
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,,
|
|
File without changes
|