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,256 @@
|
|
|
1
|
+
"""Node class for PHP AST nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cpg2py import AbcNodeQuerier, Storage
|
|
8
|
+
|
|
9
|
+
from ._node_id import NodeId
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Node(AbcNodeQuerier):
|
|
13
|
+
"""Wraps a single AST node with dynamic property access to PHP-Parser JSON fields.
|
|
14
|
+
|
|
15
|
+
Extends cpg2py's AbcNodeQuerier to provide access to PHP-Parser node data
|
|
16
|
+
stored in cpg2py Storage. All node properties come from PHP-Parser's JSON
|
|
17
|
+
output and are accessed dynamically without hardcoded field definitions.
|
|
18
|
+
|
|
19
|
+
PHP-Parser nodes have two types of data:
|
|
20
|
+
- **Subnodes**: Structural properties like name, params, stmts, byRef, etc.
|
|
21
|
+
- **Attributes**: Metadata like startLine, endLine, comments, etc.
|
|
22
|
+
|
|
23
|
+
Both are accessible through this class using dict-like syntax or properties.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
_storage: cpg2py Storage instance containing node data.
|
|
27
|
+
_nid: Unique identifier for this node.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, storage: Storage, nid: str) -> None:
|
|
31
|
+
"""Initialize Node with storage reference and node ID.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
storage: cpg2py Storage instance containing node data.
|
|
35
|
+
nid: Unique identifier for this node.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
Exception: If node ID is not found in storage.
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(storage, nid)
|
|
41
|
+
self._storage = storage
|
|
42
|
+
self._nid = nid
|
|
43
|
+
|
|
44
|
+
# Core properties
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def id(self) -> str:
|
|
48
|
+
"""Return the node identifier.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Node ID string.
|
|
52
|
+
"""
|
|
53
|
+
return self._nid
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def node_type(self) -> str:
|
|
57
|
+
"""Return the node type from PHP-Parser.
|
|
58
|
+
|
|
59
|
+
Returns the `nodeType` field from the stored JSON (e.g. "Stmt_Function").
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Node type string.
|
|
63
|
+
"""
|
|
64
|
+
value = self.get_property("nodeType")
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
return value
|
|
67
|
+
raise TypeError(f"Invalid nodeType for node {self._nid}: {value!r}")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def all_properties(self) -> dict[str, Any]:
|
|
71
|
+
"""Return all stored properties for this node.
|
|
72
|
+
|
|
73
|
+
Includes both subnodes (structural properties) and attributes (metadata).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dictionary containing all properties from PHP-Parser JSON.
|
|
77
|
+
"""
|
|
78
|
+
return self.properties or {}
|
|
79
|
+
|
|
80
|
+
# Dict-like access methods
|
|
81
|
+
|
|
82
|
+
def __getitem__(self, key: str) -> Any:
|
|
83
|
+
"""Access node properties using dict-like syntax.
|
|
84
|
+
|
|
85
|
+
Enables node["nodeType"], node["name"], node["startLine"], etc.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
key: Property name to access.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Property value.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
KeyError: If property doesn't exist.
|
|
95
|
+
"""
|
|
96
|
+
props = self.all_properties
|
|
97
|
+
if key not in props:
|
|
98
|
+
raise KeyError(f"Property '{key}' not found in node {self._nid}")
|
|
99
|
+
return props[key]
|
|
100
|
+
|
|
101
|
+
def __contains__(self, key: str) -> bool:
|
|
102
|
+
"""Check if property exists using 'in' operator.
|
|
103
|
+
|
|
104
|
+
Enables: if "name" in node: ...
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
key: Property name to check.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
True if property exists, False otherwise.
|
|
111
|
+
"""
|
|
112
|
+
return key in self.all_properties
|
|
113
|
+
|
|
114
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
115
|
+
"""Get property value with default fallback.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
key: Property name to access.
|
|
119
|
+
default: Default value if property doesn't exist.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Property value or default.
|
|
123
|
+
"""
|
|
124
|
+
return self.all_properties.get(key, default)
|
|
125
|
+
|
|
126
|
+
# PHP-Parser standard attributes (metadata properties)
|
|
127
|
+
|
|
128
|
+
def _int_property(self, name: str) -> int:
|
|
129
|
+
"""Get an integer property, coercing ASCII integer strings.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
name: Property name to retrieve.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Integer value.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
TypeError: If property is not an int or an ASCII integer
|
|
139
|
+
string (optional leading ``-``).
|
|
140
|
+
"""
|
|
141
|
+
value = self.get_property(name)
|
|
142
|
+
# bool is an int subtype but never a valid position value.
|
|
143
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
digits = value.removeprefix("-")
|
|
147
|
+
# int() alone also accepts non-ASCII digits, whitespace,
|
|
148
|
+
# "+", and "_" separators — none are position values.
|
|
149
|
+
if digits.isascii() and digits.isdigit():
|
|
150
|
+
return int(value)
|
|
151
|
+
raise TypeError(f"Invalid {name} for node {self._nid}: {value!r}")
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def start_line(self) -> int:
|
|
155
|
+
"""Get starting line number."""
|
|
156
|
+
return self._int_property("startLine")
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def end_line(self) -> int:
|
|
160
|
+
"""Get ending line number."""
|
|
161
|
+
return self._int_property("endLine")
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def start_file_pos(self) -> int:
|
|
165
|
+
"""Get starting file position (byte offset)."""
|
|
166
|
+
return self._int_property("startFilePos")
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def end_file_pos(self) -> int:
|
|
170
|
+
"""Get ending file position (byte offset)."""
|
|
171
|
+
return self._int_property("endFilePos")
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def start_token_pos(self) -> int:
|
|
175
|
+
"""Get starting token position."""
|
|
176
|
+
return self._int_property("startTokenPos")
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def end_token_pos(self) -> int:
|
|
180
|
+
"""Get ending token position."""
|
|
181
|
+
return self._int_property("endTokenPos")
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def comments(self) -> list[dict[str, Any]]:
|
|
185
|
+
"""Get comments associated with this node.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of PHP-Parser Comment JSON objects (may be empty).
|
|
189
|
+
"""
|
|
190
|
+
props = self.all_properties
|
|
191
|
+
if "comments" not in props:
|
|
192
|
+
# PHP-Parser omits the attribute when a node has no comments.
|
|
193
|
+
return []
|
|
194
|
+
value = props["comments"]
|
|
195
|
+
if isinstance(value, list):
|
|
196
|
+
return value
|
|
197
|
+
raise TypeError(f"Invalid comments for node {self._nid}: {value!r}")
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def relative_path(self) -> str | None:
|
|
201
|
+
"""Get the relative path of the file containing this node.
|
|
202
|
+
|
|
203
|
+
For File or Project nodes: returns their own relativePath property if set.
|
|
204
|
+
For other nodes: resolves the containing File node and returns its relativePath.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Relative path string, or None if not available.
|
|
208
|
+
"""
|
|
209
|
+
return self._path_property("relativePath")
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def absolute_path(self) -> str | None:
|
|
213
|
+
"""Get the absolute path of the file containing this node.
|
|
214
|
+
|
|
215
|
+
For File or Project nodes: returns their own absolutePath property if set.
|
|
216
|
+
For other nodes: resolves the containing File node and returns its absolutePath.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Absolute path string, or None if not available.
|
|
220
|
+
"""
|
|
221
|
+
return self._path_property("absolutePath")
|
|
222
|
+
|
|
223
|
+
def _path_property(self, prop_name: str) -> str | None:
|
|
224
|
+
# File and Project nodes carry the path themselves; every other
|
|
225
|
+
# node resolves it from its containing file via ID prefix convention.
|
|
226
|
+
if self.node_type in ("File", "Project"):
|
|
227
|
+
value = self.get_property(prop_name)
|
|
228
|
+
return value if isinstance(value, str) else None
|
|
229
|
+
return self._get_file_property(prop_name)
|
|
230
|
+
|
|
231
|
+
def _get_file_property(self, prop_name: str) -> str | None:
|
|
232
|
+
"""Get a path property from the containing file node via ID prefix convention.
|
|
233
|
+
|
|
234
|
+
Node IDs follow the pattern: file_hash_1, file_hash_2, etc.
|
|
235
|
+
This method extracts the file_hash prefix and retrieves the property from that file node.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
prop_name: Property name to retrieve ("relativePath" or "absolutePath").
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Property value from the file node, or None if not found.
|
|
242
|
+
"""
|
|
243
|
+
parsed = NodeId.parse(self._nid)
|
|
244
|
+
if parsed is None or not parsed.file_hash:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
file_id = parsed.file_hash
|
|
248
|
+
if not self._storage.contains_node(file_id):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
file_props = self._storage.get_node_props(file_id)
|
|
252
|
+
if not file_props or file_props.get("nodeType") != "File":
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
value = file_props.get(prop_name)
|
|
256
|
+
return value if isinstance(value, str) else None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""NodeId value object for the PHP AST node-ID scheme."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class NodeId:
|
|
10
|
+
"""Encapsulates the ``{file_hash}_{counter}`` node-ID scheme.
|
|
11
|
+
|
|
12
|
+
Single home for the ID convention used both when allocating IDs during
|
|
13
|
+
mapping and when parsing IDs back into file scope.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
file_hash: File-scope prefix; empty string for code-only parsing.
|
|
17
|
+
counter: Per-file increment.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
file_hash: str
|
|
21
|
+
counter: int
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def value(self) -> str:
|
|
25
|
+
"""Render the ID string for this node.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
``{file_hash}_{counter}`` when a file hash is set, else
|
|
29
|
+
``node_{counter}``.
|
|
30
|
+
"""
|
|
31
|
+
if self.file_hash:
|
|
32
|
+
return f"{self.file_hash}_{self.counter}"
|
|
33
|
+
return f"node_{self.counter}"
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def parse(cls, node_id: str) -> NodeId | None:
|
|
37
|
+
"""Split a stored ID into its prefix and counter.
|
|
38
|
+
|
|
39
|
+
Inverse of :pyattr:`value`. Splits at the last underscore.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
node_id: Node ID string.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
NodeId when the ID matches ``{prefix}_{digits}``; None otherwise.
|
|
46
|
+
"""
|
|
47
|
+
last_underscore = node_id.rfind("_")
|
|
48
|
+
if last_underscore == -1:
|
|
49
|
+
return None
|
|
50
|
+
counter = node_id[last_underscore + 1 :]
|
|
51
|
+
if not counter.isdigit():
|
|
52
|
+
return None
|
|
53
|
+
prefix = node_id[:last_underscore]
|
|
54
|
+
if prefix == "node":
|
|
55
|
+
return cls("", int(counter))
|
|
56
|
+
return cls(prefix, int(counter))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Mapping package: the single owner of the JSON<->graph contract.
|
|
2
|
+
|
|
3
|
+
Exposes the inverse pair ``build`` (forward: JSON->graph) and ``serialize``
|
|
4
|
+
(backward: graph->JSON) as module-level entry points, sharing one
|
|
5
|
+
field-classification rule so they stay inverse.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._build import build
|
|
11
|
+
from ._contract import EdgeSlot
|
|
12
|
+
from ._serialize import serialize, statement_ids
|
|
13
|
+
|
|
14
|
+
__all__ = ["build", "serialize", "statement_ids", "EdgeSlot"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Forward direction: PHP-Parser JSON -> populated AST graph.
|
|
2
|
+
|
|
3
|
+
Holds the stateful ``_Builder`` allocator behind the module-level ``build``
|
|
4
|
+
entry point, the forward public surface of the mapping package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cpg2py import Storage
|
|
12
|
+
|
|
13
|
+
from .._graph import AST, Modifier, NodeId
|
|
14
|
+
from .._types import PROJECT_ROOT_ID, FileRecord, ProjectLayout
|
|
15
|
+
from ._contract import (
|
|
16
|
+
ATTRIBUTE_KEYS_PROPERTY,
|
|
17
|
+
LIST_LENGTHS_PROPERTY,
|
|
18
|
+
PROJECT_SENTINEL_POSITIONS,
|
|
19
|
+
EdgeSlot,
|
|
20
|
+
_classify_field,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_FILE_END_DEFAULTS: dict[str, int] = {"endLine": 1, "endFilePos": 0, "endTokenPos": 0}
|
|
24
|
+
|
|
25
|
+
# Anchor node ID for code-only ASTs; the AST root default stays "project".
|
|
26
|
+
_CODE_ROOT_ID = "__code_root__"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build(json_trees: list[dict[str, object]], layout: ProjectLayout) -> AST:
|
|
30
|
+
"""Populate a fresh ``AST`` from PHP-Parser JSON trees.
|
|
31
|
+
|
|
32
|
+
Forward entry of the mapping package; inverse of ``serialize``.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
json_trees: Normalized list of root node dicts (code-only mode).
|
|
36
|
+
layout: Project/file structure and per-file hashes.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
AST with project -> file -> statement hierarchy, or a flat statement
|
|
40
|
+
list when ``layout`` carries no files.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: From ``Modifier.add_node`` on a duplicate ID.
|
|
44
|
+
"""
|
|
45
|
+
builder = _Builder()
|
|
46
|
+
if layout.project_path is None:
|
|
47
|
+
return builder.build_code(json_trees)
|
|
48
|
+
return builder.build_project(layout)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Builder:
|
|
52
|
+
"""Stateful JSON->graph builder with a per-file ID allocator.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
_next_index: Stateful per-file ID allocator counter.
|
|
56
|
+
_prefix: Current file-hash prefix; empty string for code-only parsing.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
"""Initialize a builder with a fresh allocator state."""
|
|
61
|
+
self._next_index = 1
|
|
62
|
+
self._prefix = ""
|
|
63
|
+
|
|
64
|
+
def build_code(self, json_trees: list[dict[str, object]]) -> AST:
|
|
65
|
+
"""Build statements anchored under a synthetic code-root node."""
|
|
66
|
+
modifier = Modifier(AST(Storage(), root_node_id=_CODE_ROOT_ID))
|
|
67
|
+
# A real anchor node makes top-level membership explicit: serialize
|
|
68
|
+
# exports the anchor's stmts edges. Inferring roots from missing
|
|
69
|
+
# parent edges would promote subtrees orphaned by remove_node.
|
|
70
|
+
modifier.add_node(_CODE_ROOT_ID, "Project", **PROJECT_SENTINEL_POSITIONS)
|
|
71
|
+
self._reset_scope("")
|
|
72
|
+
for idx, item in enumerate(json_trees):
|
|
73
|
+
self._process_node(modifier, item, EdgeSlot("stmts", idx), _CODE_ROOT_ID)
|
|
74
|
+
return modifier.ast
|
|
75
|
+
|
|
76
|
+
def build_project(self, layout: ProjectLayout) -> AST:
|
|
77
|
+
"""Build the project -> files -> statements hierarchy."""
|
|
78
|
+
project_path = layout.project_path
|
|
79
|
+
if project_path is None:
|
|
80
|
+
raise ValueError("ProjectLayout.project_path is required for build")
|
|
81
|
+
modifier = Modifier(AST(Storage()))
|
|
82
|
+
modifier.add_node(
|
|
83
|
+
PROJECT_ROOT_ID,
|
|
84
|
+
"Project",
|
|
85
|
+
absolutePath=str(project_path),
|
|
86
|
+
**PROJECT_SENTINEL_POSITIONS,
|
|
87
|
+
)
|
|
88
|
+
for record in layout.files:
|
|
89
|
+
self._add_file_node(modifier, record, project_path)
|
|
90
|
+
return modifier.ast
|
|
91
|
+
|
|
92
|
+
def _add_file_node(
|
|
93
|
+
self, modifier: Modifier, record: FileRecord, project_path: Path
|
|
94
|
+
) -> None:
|
|
95
|
+
# Create the file node, link it to project, process its statements.
|
|
96
|
+
end_pos = self._file_end_positions(record.statements)
|
|
97
|
+
modifier.add_node(
|
|
98
|
+
record.file_hash,
|
|
99
|
+
"File",
|
|
100
|
+
absolutePath=str(record.path),
|
|
101
|
+
relativePath=_relative_path(record.path, project_path),
|
|
102
|
+
startLine=1,
|
|
103
|
+
startFilePos=0,
|
|
104
|
+
startTokenPos=0,
|
|
105
|
+
**end_pos,
|
|
106
|
+
)
|
|
107
|
+
modifier.add_edge(PROJECT_ROOT_ID, record.file_hash, field="files")
|
|
108
|
+
self._reset_scope(record.file_hash)
|
|
109
|
+
for idx, item in enumerate(record.statements):
|
|
110
|
+
self._process_node(modifier, item, EdgeSlot("stmts", idx), record.file_hash)
|
|
111
|
+
|
|
112
|
+
def _process_node(
|
|
113
|
+
self,
|
|
114
|
+
modifier: Modifier,
|
|
115
|
+
node_data: object,
|
|
116
|
+
slot: EdgeSlot | None,
|
|
117
|
+
parent_id: str | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
# Convert one JSON node and its subtree into graph nodes and edges.
|
|
120
|
+
# Explicit-stack DFS: recursion would cap the AST depth at a
|
|
121
|
+
# fraction of the interpreter stack, failing trees json.loads
|
|
122
|
+
# itself delivered. Children are pushed reversed so IDs are
|
|
123
|
+
# allocated in the same pre-order as the recursive walk.
|
|
124
|
+
stack: list[tuple[object, EdgeSlot | None, str | None]] = [
|
|
125
|
+
(node_data, slot, parent_id)
|
|
126
|
+
]
|
|
127
|
+
while stack:
|
|
128
|
+
data, current_slot, parent = stack.pop()
|
|
129
|
+
if not isinstance(data, dict) or "nodeType" not in data:
|
|
130
|
+
continue
|
|
131
|
+
node_id = self._allocate_id()
|
|
132
|
+
properties, child_fields = self._split_fields(data)
|
|
133
|
+
node_type = properties.pop("nodeType")
|
|
134
|
+
if not isinstance(node_type, str):
|
|
135
|
+
continue
|
|
136
|
+
modifier.add_node(node_id, node_type, **properties)
|
|
137
|
+
if parent is not None and current_slot is not None:
|
|
138
|
+
self._link_child(modifier, parent, node_id, current_slot)
|
|
139
|
+
stack.extend(reversed(_child_work(child_fields, node_id)))
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _link_child(
|
|
143
|
+
modifier: Modifier, parent_id: str, node_id: str, slot: EdgeSlot
|
|
144
|
+
) -> None:
|
|
145
|
+
# Add the PARENT_OF edge carrying the slot metadata.
|
|
146
|
+
if slot.index is not None:
|
|
147
|
+
modifier.add_edge(parent_id, node_id, field=slot.field, index=slot.index)
|
|
148
|
+
else:
|
|
149
|
+
modifier.add_edge(parent_id, node_id, field=slot.field)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _split_fields(
|
|
153
|
+
node_data: dict[str, object],
|
|
154
|
+
) -> tuple[dict[str, object], list[tuple[str, object]]]:
|
|
155
|
+
# Partition fields into scalar properties and child-edge subnodes.
|
|
156
|
+
properties: dict[str, object] = {}
|
|
157
|
+
child_fields: list[tuple[str, object]] = []
|
|
158
|
+
trailing_lengths: dict[str, int] = {}
|
|
159
|
+
for key, value in node_data.items():
|
|
160
|
+
if not _classify_field(key, value):
|
|
161
|
+
_absorb_scalar(properties, key, value)
|
|
162
|
+
continue
|
|
163
|
+
child_fields.append((key, value))
|
|
164
|
+
# Trailing nulls carry no child edge; record the full length
|
|
165
|
+
# so serialize can rebuild them.
|
|
166
|
+
if isinstance(value, list) and value[-1] is None:
|
|
167
|
+
trailing_lengths[key] = len(value)
|
|
168
|
+
if trailing_lengths:
|
|
169
|
+
properties[LIST_LENGTHS_PROPERTY] = trailing_lengths
|
|
170
|
+
return properties, child_fields
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _file_end_positions(stmts: list[dict[str, object]]) -> dict[str, int]:
|
|
174
|
+
# Compute the file node's end positions from statement attributes.
|
|
175
|
+
ends = dict(_FILE_END_DEFAULTS)
|
|
176
|
+
for stmt in stmts:
|
|
177
|
+
attrs = stmt.get("attributes", {})
|
|
178
|
+
if not isinstance(attrs, dict):
|
|
179
|
+
continue
|
|
180
|
+
for key, current in list(ends.items()):
|
|
181
|
+
val = attrs.get(key)
|
|
182
|
+
if isinstance(val, int) and val > current:
|
|
183
|
+
ends[key] = val
|
|
184
|
+
return ends
|
|
185
|
+
|
|
186
|
+
def _reset_scope(self, prefix: str) -> None:
|
|
187
|
+
# Begin allocating IDs for a new file scope.
|
|
188
|
+
self._prefix = prefix
|
|
189
|
+
self._next_index = 1
|
|
190
|
+
|
|
191
|
+
def _allocate_id(self) -> str:
|
|
192
|
+
# Allocate the next unique node ID for the current scope.
|
|
193
|
+
node_id = NodeId(self._prefix, self._next_index)
|
|
194
|
+
self._next_index += 1
|
|
195
|
+
return node_id.value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _child_work(
|
|
199
|
+
child_fields: list[tuple[str, object]], parent_id: str
|
|
200
|
+
) -> list[tuple[object, EdgeSlot, str]]:
|
|
201
|
+
# Expand classified child fields into (data, slot, parent) work items.
|
|
202
|
+
items: list[tuple[object, EdgeSlot, str]] = []
|
|
203
|
+
for field_name, value in child_fields:
|
|
204
|
+
if isinstance(value, list):
|
|
205
|
+
for idx, item in enumerate(value):
|
|
206
|
+
items.append((item, EdgeSlot(field_name, idx), parent_id))
|
|
207
|
+
else:
|
|
208
|
+
items.append((value, EdgeSlot(field_name), parent_id))
|
|
209
|
+
return items
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _relative_path(file_path: Path, project_path: Path) -> str:
|
|
213
|
+
# Path of file relative to project, falling back to the file name.
|
|
214
|
+
try:
|
|
215
|
+
return str(file_path.relative_to(project_path))
|
|
216
|
+
except ValueError:
|
|
217
|
+
return file_path.name
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _absorb_scalar(properties: dict[str, object], key: str, value: object) -> None:
|
|
221
|
+
# Flatten an attributes bundle, recording its keys so serialize can
|
|
222
|
+
# re-file exactly these properties; else store the scalar directly.
|
|
223
|
+
if key == "attributes" and isinstance(value, dict):
|
|
224
|
+
properties[ATTRIBUTE_KEYS_PROPERTY] = list(value)
|
|
225
|
+
properties.update(value)
|
|
226
|
+
else:
|
|
227
|
+
properties[key] = value
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""JSON<->graph mapping contract used by both directions.
|
|
2
|
+
|
|
3
|
+
Holds the single field-classification rule and the edge-slot data holder so
|
|
4
|
+
``build`` and ``serialize`` stay inverse.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
# Graph property recording {field name: full list length} for child list
|
|
12
|
+
# fields ending in null (e.g. list($a, ,) destructuring skips). Trailing
|
|
13
|
+
# nulls carry no child edge, so serialize needs the recorded length to
|
|
14
|
+
# rebuild them. Written by build, consumed and omitted by serialize.
|
|
15
|
+
LIST_LENGTHS_PROPERTY = "pppyListLengths"
|
|
16
|
+
|
|
17
|
+
# Graph property recording the key list of a node's flattened attributes
|
|
18
|
+
# bundle. build absorbs attributes into plain properties; serialize needs
|
|
19
|
+
# the recorded keys to re-file exactly those properties back into the
|
|
20
|
+
# attributes dict — a fixed whitelist would misfile any attribute
|
|
21
|
+
# PHP-Parser adds (docLabel, rawValue, ...) as a top-level node field.
|
|
22
|
+
# Written by build, consumed and omitted by serialize.
|
|
23
|
+
ATTRIBUTE_KEYS_PROPERTY = "pppyAttributeKeys"
|
|
24
|
+
|
|
25
|
+
PROJECT_SENTINEL_POSITIONS: dict[str, int] = {
|
|
26
|
+
"startLine": -1,
|
|
27
|
+
"endLine": -1,
|
|
28
|
+
"startFilePos": -1,
|
|
29
|
+
"endFilePos": -1,
|
|
30
|
+
"startTokenPos": -1,
|
|
31
|
+
"endTokenPos": -1,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class EdgeSlot:
|
|
37
|
+
"""Structural metadata for one PARENT_OF edge.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
field: Subnode key on the parent (e.g. "stmts", "name").
|
|
41
|
+
index: Array position for ordered list fields; None for single-object
|
|
42
|
+
fields.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
field: str
|
|
46
|
+
index: int | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _classify_field(key: str, value: object) -> bool:
|
|
50
|
+
"""Decide whether a JSON field is a child-edge subnode.
|
|
51
|
+
|
|
52
|
+
The one authoritative rule used by both ``build`` and ``serialize``: a
|
|
53
|
+
value is a child-edge subnode when it is a node dict (has ``nodeType``)
|
|
54
|
+
or a list containing at least one node dict. List items may be null
|
|
55
|
+
(array destructuring skips), so every element is examined, not just the
|
|
56
|
+
first. The ``attributes`` key is always a scalar property bundle.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
key: JSON field key.
|
|
60
|
+
value: JSON field value.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True when the value is a child-edge subnode; False when scalar.
|
|
64
|
+
"""
|
|
65
|
+
if key == "attributes":
|
|
66
|
+
return False
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
return "nodeType" in value
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return any(isinstance(item, dict) and "nodeType" in item for item in value)
|
|
71
|
+
return False
|