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.
@@ -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