php-parser-py 1.2.4__py3-none-win_amd64.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 +33 -0
- php_parser_py/_exceptions.py +58 -0
- php_parser_py/_graph/__init__.py +19 -0
- php_parser_py/_graph/_ast.py +227 -0
- php_parser_py/_graph/_edge.py +102 -0
- php_parser_py/_graph/_modifier.py +143 -0
- php_parser_py/_graph/_node.py +256 -0
- php_parser_py/_graph/_node_id.py +56 -0
- php_parser_py/_mapping/__init__.py +14 -0
- php_parser_py/_mapping/_build.py +227 -0
- php_parser_py/_mapping/_contract.py +71 -0
- php_parser_py/_mapping/_serialize.py +220 -0
- php_parser_py/_parser.py +311 -0
- php_parser_py/_printer.py +147 -0
- php_parser_py/_resources/ExtendedPrinter.php +212 -0
- php_parser_py/_resources/parse.php +112 -0
- php_parser_py/_resources/php +0 -0
- php_parser_py/_resources/php-parser-5.7.0.tar.gz +0 -0
- php_parser_py/_resources/php.exe +0 -0
- php_parser_py/_resources/print.php +150 -0
- php_parser_py/_resources/transport.php +195 -0
- php_parser_py/_runtime/__init__.py +11 -0
- php_parser_py/_runtime/_binary.py +113 -0
- php_parser_py/_runtime/_bundle.py +164 -0
- php_parser_py/_runtime/_runner.py +258 -0
- php_parser_py/_transport.py +104 -0
- php_parser_py/_types.py +87 -0
- php_parser_py/py.typed +0 -0
- php_parser_py-1.2.4.dist-info/METADATA +100 -0
- php_parser_py-1.2.4.dist-info/RECORD +32 -0
- php_parser_py-1.2.4.dist-info/WHEEL +5 -0
- php_parser_py-1.2.4.dist-info/licenses/LICENSE +339 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""php-parser-py: Python wrapper for PHP-Parser using cpg2py.
|
|
2
|
+
|
|
3
|
+
This package provides a Python interface to PHP-Parser, enabling AST parsing
|
|
4
|
+
and manipulation of PHP code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__version__ = "1.2.2"
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AST",
|
|
12
|
+
"Edge",
|
|
13
|
+
"Modifier",
|
|
14
|
+
"Node",
|
|
15
|
+
"Parser",
|
|
16
|
+
"Printer",
|
|
17
|
+
"StandardPrinter",
|
|
18
|
+
"ExtendedPrinter",
|
|
19
|
+
"PrintMode",
|
|
20
|
+
"PrintRequest",
|
|
21
|
+
"ParseError",
|
|
22
|
+
"RunnerError",
|
|
23
|
+
"NodeNotInFileError",
|
|
24
|
+
"parse_code",
|
|
25
|
+
"parse_file",
|
|
26
|
+
"parse_project",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
from ._exceptions import NodeNotInFileError, ParseError, RunnerError
|
|
30
|
+
from ._graph import AST, Edge, Modifier, Node
|
|
31
|
+
from ._parser import Parser, parse_code, parse_file, parse_project
|
|
32
|
+
from ._printer import ExtendedPrinter, Printer, StandardPrinter
|
|
33
|
+
from ._types import PrintMode, PrintRequest
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exception classes for php-parser-py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ParseError(Exception):
|
|
7
|
+
"""Raised when PHP-Parser reports a syntax error.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
message: Error message from PHP-Parser.
|
|
11
|
+
line: Line number where the error occurred, if available.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, line: int | None = None) -> None:
|
|
15
|
+
"""Initialize ParseError.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
message: Error message from PHP-Parser.
|
|
19
|
+
line: Line number where the error occurred, if available.
|
|
20
|
+
"""
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.line = line
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NodeNotInFileError(Exception):
|
|
27
|
+
"""Raised when a node has no containing file (e.g. project node or orphan).
|
|
28
|
+
|
|
29
|
+
Use when get_file(node_id) is called and the node is not under any file node.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, node_id: str, message: str | None = None) -> None:
|
|
33
|
+
self.node_id = node_id
|
|
34
|
+
self.message = message or f"Node {node_id!r} is not under any file node."
|
|
35
|
+
super().__init__(self.message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RunnerError(Exception):
|
|
39
|
+
"""Raised when PHP process execution fails.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
message: Error message describing the failure.
|
|
43
|
+
stderr: Standard error output from the PHP process.
|
|
44
|
+
exit_code: Exit code returned by the PHP process.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, stderr: str = "", exit_code: int = 1) -> None:
|
|
48
|
+
"""Initialize RunnerError.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
message: Error message describing the failure.
|
|
52
|
+
stderr: Standard error output from the PHP process.
|
|
53
|
+
exit_code: Exit code returned by the PHP process.
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.message = message
|
|
57
|
+
self.stderr = stderr
|
|
58
|
+
self.exit_code = exit_code
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Graph layer: AST querying, node/edge wrappers, and mutation."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=duplicate-code # __all__ mirrors the package re-export list.
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AST",
|
|
9
|
+
"Edge",
|
|
10
|
+
"Modifier",
|
|
11
|
+
"Node",
|
|
12
|
+
"NodeId",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
from ._ast import AST
|
|
16
|
+
from ._edge import Edge
|
|
17
|
+
from ._modifier import Modifier
|
|
18
|
+
from ._node import Node
|
|
19
|
+
from ._node_id import NodeId
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""AST representation for PHP code using cpg2py's graph framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
from cpg2py import AbcGraphQuerier, Storage
|
|
8
|
+
|
|
9
|
+
from .._exceptions import NodeNotInFileError
|
|
10
|
+
from .._types import PARENT_OF_EDGE, PROJECT_ROOT_ID
|
|
11
|
+
from ._edge import Edge
|
|
12
|
+
from ._node import Node
|
|
13
|
+
from ._node_id import NodeId
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AST(AbcGraphQuerier[Node, Edge]):
|
|
17
|
+
"""Represents a PHP Abstract Syntax Tree as a queryable graph.
|
|
18
|
+
|
|
19
|
+
Extends cpg2py's AbcGraphQuerier with Node and Edge type parameters.
|
|
20
|
+
Query-only: traversal, node/edge lookup, and file resolution. JSON
|
|
21
|
+
reconstruction lives in ``Mapping.serialize``.
|
|
22
|
+
|
|
23
|
+
The AST structure follows a hierarchy:
|
|
24
|
+
- Project node (ID: "project") - root of the AST
|
|
25
|
+
- File nodes (ID: hex hash) - children of project
|
|
26
|
+
- Statement nodes (ID: {hex}_{counter}) - children of files
|
|
27
|
+
|
|
28
|
+
Uses the cpg2py graph API (nodes, edges, succ, prev, ancestors,
|
|
29
|
+
descendants); storage is only used for node/edge existence and Node/Edge
|
|
30
|
+
construction.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
_root_node_id: ID of the root project node (always "project").
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, storage: Storage, root_node_id: str = PROJECT_ROOT_ID) -> None:
|
|
37
|
+
"""Initialize AST with populated storage.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
storage: cpg2py Storage containing AST nodes and edges.
|
|
41
|
+
root_node_id: ID of the root node. Defaults to "project".
|
|
42
|
+
"""
|
|
43
|
+
super().__init__(storage)
|
|
44
|
+
self._root_node_id = root_node_id
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def storage(self) -> Storage:
|
|
48
|
+
"""Block public access to the writable graph store.
|
|
49
|
+
|
|
50
|
+
AST is query-only and mutation is copy-on-write via ``Modifier``;
|
|
51
|
+
exposing the live ``Storage`` would reopen the in-place mutation path.
|
|
52
|
+
Internal collaborators use the private ``_graph`` accessor instead.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
AttributeError: Always; the storage is not part of the public API.
|
|
56
|
+
"""
|
|
57
|
+
raise AttributeError(
|
|
58
|
+
"AST.storage is not public; mutate via Modifier (copy-on-write)."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def _graph(self) -> Storage:
|
|
63
|
+
"""Return the underlying graph store for internal reads."""
|
|
64
|
+
return super().storage
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def project_root_id(self) -> str:
|
|
68
|
+
"""Return the root node ID of this AST."""
|
|
69
|
+
return self._root_node_id
|
|
70
|
+
|
|
71
|
+
def copy(self) -> AST:
|
|
72
|
+
"""Return a new AST backed by a deep copy of this graph.
|
|
73
|
+
|
|
74
|
+
Node IDs, the edge set, and all node/edge properties are duplicated
|
|
75
|
+
verbatim, preserving the ``{file_hash}_{counter}`` ID namespace and the
|
|
76
|
+
root node ID. The returned AST shares no mutable state with this one:
|
|
77
|
+
mutating either graph leaves the other unchanged.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A new AST whose storage is an independent copy of this graph.
|
|
81
|
+
"""
|
|
82
|
+
storage = Storage()
|
|
83
|
+
self._copy_nodes_into(storage)
|
|
84
|
+
self._copy_edges_into(storage)
|
|
85
|
+
return AST(storage, root_node_id=self._root_node_id)
|
|
86
|
+
|
|
87
|
+
def _copy_nodes_into(self, storage: Storage) -> None:
|
|
88
|
+
"""Duplicate every node and its properties into ``storage``."""
|
|
89
|
+
for node_id in self._graph.get_nodes():
|
|
90
|
+
storage.add_node(node_id)
|
|
91
|
+
props = self._graph.get_node_props(node_id)
|
|
92
|
+
if props:
|
|
93
|
+
storage.set_node_props(node_id, copy.deepcopy(props))
|
|
94
|
+
|
|
95
|
+
def _copy_edges_into(self, storage: Storage) -> None:
|
|
96
|
+
"""Duplicate every edge and its properties into ``storage``."""
|
|
97
|
+
for edge_id in self._graph.get_edges():
|
|
98
|
+
storage.add_edge(edge_id)
|
|
99
|
+
props = self._graph.get_edge_props(edge_id)
|
|
100
|
+
if props:
|
|
101
|
+
storage.set_edge_props(edge_id, copy.deepcopy(props))
|
|
102
|
+
|
|
103
|
+
def node(self, whose_id_is: str) -> Node:
|
|
104
|
+
"""Return node wrapper by ID.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
whose_id_is: Node ID string.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Node instance for the given ID.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
KeyError: If the node ID is not in the graph.
|
|
114
|
+
"""
|
|
115
|
+
if not self._graph.contains_node(whose_id_is):
|
|
116
|
+
raise KeyError(f"Node not found: {whose_id_is!r}")
|
|
117
|
+
return Node(self._graph, whose_id_is)
|
|
118
|
+
|
|
119
|
+
def edge(self, fid: str, tid: str, eid: str) -> Edge:
|
|
120
|
+
"""Return edge wrapper by IDs.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
fid: From node ID.
|
|
124
|
+
tid: To node ID.
|
|
125
|
+
eid: Edge type.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Edge instance for the given (from, to, type).
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
KeyError: If the edge is not in the graph.
|
|
132
|
+
"""
|
|
133
|
+
edge_id = (fid, tid, eid)
|
|
134
|
+
if not self._graph.contains_edge(edge_id):
|
|
135
|
+
raise KeyError(f"Edge not found: {edge_id!r}")
|
|
136
|
+
return Edge(self._graph, fid, tid, eid)
|
|
137
|
+
|
|
138
|
+
def project_node(self) -> Node:
|
|
139
|
+
"""Return the project node (root of the AST).
|
|
140
|
+
|
|
141
|
+
The project node has ID "project" and is always present when the AST
|
|
142
|
+
was created by parse_file or parse_project.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Project Node instance.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
KeyError: If the root node is not in the graph.
|
|
149
|
+
"""
|
|
150
|
+
if not self._graph.contains_node(self._root_node_id):
|
|
151
|
+
raise KeyError(f"Project node not found: {self._root_node_id!r}")
|
|
152
|
+
return Node(self._graph, self._root_node_id)
|
|
153
|
+
|
|
154
|
+
def file_nodes(self) -> list[Node]:
|
|
155
|
+
"""Return all file nodes in the project.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of File Node instances.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
project = self.project_node()
|
|
162
|
+
except KeyError:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
# succ() follows every edge type; a caller-added project->file edge
|
|
166
|
+
# of another type must not list the file twice.
|
|
167
|
+
children = self.succ(project, lambda e: e.type == PARENT_OF_EDGE)
|
|
168
|
+
file_nodes = [n for n in children if n.node_type == "File"]
|
|
169
|
+
return sorted(file_nodes, key=lambda n: n.get("absolutePath", ""))
|
|
170
|
+
|
|
171
|
+
def get_file_node(self, node_id: str) -> Node:
|
|
172
|
+
"""Get the file node that contains the given node.
|
|
173
|
+
|
|
174
|
+
Uses the AST ID convention: file node ID is the file hash (e.g. 8-char hex);
|
|
175
|
+
nodes inside that file have ID ``file_hash + "_" + increment`` (e.g. ``a1b2c3d4_1``).
|
|
176
|
+
Resolves by structure first, then falls back to ancestors() traversal.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
node_id: ID of any node in the AST.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
File Node instance containing the given node.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
KeyError: If the node ID is not in the graph.
|
|
186
|
+
NodeNotInFileError: If the node is not under any file (e.g. project node).
|
|
187
|
+
"""
|
|
188
|
+
node = self.node(node_id)
|
|
189
|
+
if node.node_type == "File":
|
|
190
|
+
return node
|
|
191
|
+
|
|
192
|
+
if node_id == self._root_node_id:
|
|
193
|
+
raise NodeNotInFileError(node_id, "Project node has no containing file.")
|
|
194
|
+
|
|
195
|
+
result = self._try_file_by_id_prefix(node_id)
|
|
196
|
+
if result is not None:
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
return self._find_file_ancestor(node)
|
|
200
|
+
|
|
201
|
+
def _try_file_by_id_prefix(self, node_id: str) -> Node | None:
|
|
202
|
+
"""Try to find the file node via the ``NodeId`` prefix convention.
|
|
203
|
+
|
|
204
|
+
Returns None when the ID does not match the convention or the file is
|
|
205
|
+
not present.
|
|
206
|
+
"""
|
|
207
|
+
parsed = NodeId.parse(node_id)
|
|
208
|
+
if parsed is None or not parsed.file_hash:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
candidate = self.node(parsed.file_hash)
|
|
213
|
+
except KeyError:
|
|
214
|
+
return None
|
|
215
|
+
return candidate if candidate.node_type == "File" else None
|
|
216
|
+
|
|
217
|
+
def _find_file_ancestor(self, node: Node) -> Node:
|
|
218
|
+
"""Find first File ancestor of a node via traversal.
|
|
219
|
+
|
|
220
|
+
Raises NodeNotInFileError if no File ancestor exists.
|
|
221
|
+
"""
|
|
222
|
+
for ancestor in self.ancestors(node):
|
|
223
|
+
if ancestor.node_type == "File":
|
|
224
|
+
result: Node = ancestor
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
raise NodeNotInFileError(node.id, "No File node among ancestors.")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Edge class for AST relationships."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cpg2py import AbcEdgeQuerier, Storage
|
|
8
|
+
|
|
9
|
+
from .._types import PARENT_OF_EDGE
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Edge(AbcEdgeQuerier):
|
|
13
|
+
"""Represents an edge between AST nodes with generic property access.
|
|
14
|
+
|
|
15
|
+
Extends cpg2py's AbcEdgeQuerier. Edge type is fixed (e.g. "PARENT_OF");
|
|
16
|
+
other data is stored as arbitrary properties via Storage. For PARENT_OF
|
|
17
|
+
edges in our PHP AST mapping, we use properties "field" and "index" to
|
|
18
|
+
represent the PHP-Parser subnode key and array position—access them via
|
|
19
|
+
edge.get("field"), edge.get("index") or edge["field"], edge["index"];
|
|
20
|
+
they are not special-cased on this class.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
_storage: cpg2py Storage instance containing edge data.
|
|
24
|
+
_edge_id: Tuple of (from_id, to_id, edge_type).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, graph: Storage, f_nid: str, t_nid: str, e_type: str = PARENT_OF_EDGE
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Initialize Edge with storage and edge identifiers.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
graph: cpg2py Storage containing the graph.
|
|
34
|
+
f_nid: From node ID.
|
|
35
|
+
t_nid: To node ID.
|
|
36
|
+
e_type: Edge type (default: "PARENT_OF").
|
|
37
|
+
"""
|
|
38
|
+
super().__init__(graph, f_nid, t_nid, e_type)
|
|
39
|
+
self._storage = graph
|
|
40
|
+
self._edge_id = (str(f_nid), str(t_nid), str(e_type))
|
|
41
|
+
|
|
42
|
+
# Core properties
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def type(self) -> str:
|
|
46
|
+
"""Return the edge type (e.g. "PARENT_OF")."""
|
|
47
|
+
return self._edge_id[2]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def all_properties(self) -> dict[str, Any]:
|
|
51
|
+
"""Return all edge properties.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Dictionary of edge properties.
|
|
55
|
+
"""
|
|
56
|
+
return self._storage.get_edge_props(self._edge_id) or {}
|
|
57
|
+
|
|
58
|
+
# Dict-like access methods
|
|
59
|
+
|
|
60
|
+
def __getitem__(self, key: str) -> Any:
|
|
61
|
+
"""Access edge properties using dict-like syntax.
|
|
62
|
+
|
|
63
|
+
Enables edge["field"], edge["index"], etc.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
key: Property name to access.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Property value.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
KeyError: If property doesn't exist.
|
|
73
|
+
"""
|
|
74
|
+
props = self.all_properties
|
|
75
|
+
if key not in props:
|
|
76
|
+
raise KeyError(f"Property '{key}' not found in edge {self._edge_id}")
|
|
77
|
+
return props[key]
|
|
78
|
+
|
|
79
|
+
def __contains__(self, key: str) -> bool:
|
|
80
|
+
"""Check if property exists using 'in' operator.
|
|
81
|
+
|
|
82
|
+
Enables: if "index" in edge: ...
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
key: Property name to check.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if property exists, False otherwise.
|
|
89
|
+
"""
|
|
90
|
+
return key in self.all_properties
|
|
91
|
+
|
|
92
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
93
|
+
"""Get property value with default fallback.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
key: Property name to access.
|
|
97
|
+
default: Default value if property doesn't exist.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Property value or default.
|
|
101
|
+
"""
|
|
102
|
+
return self.all_properties.get(key, default)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Modifier class for AST graph mutation operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._types import PARENT_OF_EDGE
|
|
6
|
+
from ._ast import AST
|
|
7
|
+
from ._edge import Edge
|
|
8
|
+
from ._node import Node
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Modifier:
|
|
12
|
+
"""Encapsulates all graph mutation operations for an AST.
|
|
13
|
+
|
|
14
|
+
The only approved way to add, remove, or structurally modify nodes and
|
|
15
|
+
edges in the AST graph. Callers must never use Storage directly.
|
|
16
|
+
|
|
17
|
+
AST (AbcGraphQuerier) is a query interface; graph mutation is a separate
|
|
18
|
+
concern handled by this class. Mutation is copy-on-write: the constructor
|
|
19
|
+
deep-copies the source AST, and every operation writes through the copy.
|
|
20
|
+
The source AST is never modified. Callers read mutations via the ``ast``
|
|
21
|
+
property, which exposes the modified copy.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
_ast: The modified-copy AST, independent of the source.
|
|
25
|
+
_storage: The copy's internal Storage that operations write through.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, ast: AST) -> None:
|
|
29
|
+
"""Deep-copy an AST so that mutation leaves the source unchanged.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
ast: Source AST to copy. The instance is never modified.
|
|
33
|
+
"""
|
|
34
|
+
self._ast = ast.copy()
|
|
35
|
+
self._storage = self._ast._graph
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def ast(self) -> AST:
|
|
39
|
+
"""Return the modified-copy AST holding all mutations."""
|
|
40
|
+
return self._ast
|
|
41
|
+
|
|
42
|
+
# -- Node Operations --
|
|
43
|
+
|
|
44
|
+
def add_node(self, node_id: str, node_type: str, **props: object) -> Node:
|
|
45
|
+
"""Create a new node in the graph with the given type and properties.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
node_id: Unique node ID string.
|
|
49
|
+
node_type: PHP-Parser node type (e.g. "Stmt_Break").
|
|
50
|
+
**props: Additional node properties to set.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Node instance for the newly created node.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If node_id already exists in the graph.
|
|
57
|
+
"""
|
|
58
|
+
if self._storage.contains_node(node_id):
|
|
59
|
+
raise ValueError(f"Node already exists: {node_id!r}")
|
|
60
|
+
|
|
61
|
+
self._storage.add_node(node_id)
|
|
62
|
+
all_props: dict[str, object] = {"nodeType": node_type, **props}
|
|
63
|
+
self._storage.set_node_props(node_id, all_props)
|
|
64
|
+
return Node(self._storage, node_id)
|
|
65
|
+
|
|
66
|
+
def remove_node(self, node_id: str) -> None:
|
|
67
|
+
"""Remove a node and all its connected edges from the graph.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
node_id: Node ID to remove.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
KeyError: If node_id is not in the graph.
|
|
74
|
+
"""
|
|
75
|
+
if not self._storage.contains_node(node_id):
|
|
76
|
+
raise KeyError(f"Node not found: {node_id!r}")
|
|
77
|
+
# Storage.remove_node drops only the removed node's adjacency list;
|
|
78
|
+
# remove_edge is the only API that also cleans the other endpoint.
|
|
79
|
+
incident = dict.fromkeys(
|
|
80
|
+
(*self._storage.in_edges(node_id), *self._storage.out_edges(node_id))
|
|
81
|
+
)
|
|
82
|
+
for edge_id in incident:
|
|
83
|
+
self._storage.remove_edge(edge_id)
|
|
84
|
+
self._storage.remove_node(node_id)
|
|
85
|
+
|
|
86
|
+
# -- Edge Operations --
|
|
87
|
+
|
|
88
|
+
def add_edge(
|
|
89
|
+
self,
|
|
90
|
+
from_id: str,
|
|
91
|
+
to_id: str,
|
|
92
|
+
edge_type: str = PARENT_OF_EDGE,
|
|
93
|
+
**props: object,
|
|
94
|
+
) -> Edge:
|
|
95
|
+
"""Create a new edge between two existing nodes.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
from_id: Source node ID.
|
|
99
|
+
to_id: Target node ID.
|
|
100
|
+
edge_type: Edge type. Defaults to "PARENT_OF".
|
|
101
|
+
**props: Edge properties (e.g. field="stmts", index=0).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Edge instance for the newly created edge.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
KeyError: If either node does not exist.
|
|
108
|
+
ValueError: If edge already exists.
|
|
109
|
+
"""
|
|
110
|
+
if not self._storage.contains_node(from_id):
|
|
111
|
+
raise KeyError(f"Source node not found: {from_id!r}")
|
|
112
|
+
if not self._storage.contains_node(to_id):
|
|
113
|
+
raise KeyError(f"Target node not found: {to_id!r}")
|
|
114
|
+
|
|
115
|
+
edge_id = (from_id, to_id, edge_type)
|
|
116
|
+
if self._storage.contains_edge(edge_id):
|
|
117
|
+
raise ValueError(f"Edge already exists: {edge_id!r}")
|
|
118
|
+
|
|
119
|
+
self._storage.add_edge(edge_id)
|
|
120
|
+
if props:
|
|
121
|
+
self._storage.set_edge_props(edge_id, props)
|
|
122
|
+
return Edge(self._storage, from_id, to_id, edge_type)
|
|
123
|
+
|
|
124
|
+
def remove_edge(
|
|
125
|
+
self,
|
|
126
|
+
from_id: str,
|
|
127
|
+
to_id: str,
|
|
128
|
+
edge_type: str = PARENT_OF_EDGE,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Remove an edge from the graph.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
from_id: Source node ID.
|
|
134
|
+
to_id: Target node ID.
|
|
135
|
+
edge_type: Edge type. Defaults to "PARENT_OF".
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
KeyError: If edge is not in the graph.
|
|
139
|
+
"""
|
|
140
|
+
edge_id = (from_id, to_id, edge_type)
|
|
141
|
+
if not self._storage.contains_edge(edge_id):
|
|
142
|
+
raise KeyError(f"Edge not found: {edge_id!r}")
|
|
143
|
+
self._storage.remove_edge(edge_id)
|