php-parser-py 1.2.0__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/PKG-INFO +20 -19
  2. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/README.md +19 -18
  3. php_parser_py-1.2.2/pyproject.toml +63 -0
  4. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/__init__.py +21 -31
  5. php_parser_py-1.2.2/src/php_parser_py/_ast.py +341 -0
  6. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_edge.py +9 -43
  7. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_exceptions.py +12 -0
  8. php_parser_py-1.2.2/src/php_parser_py/_modifier.py +140 -0
  9. php_parser_py-1.2.2/src/php_parser_py/_node.py +313 -0
  10. php_parser_py-1.2.2/src/php_parser_py/_parser.py +371 -0
  11. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_printer.py +2 -2
  12. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_resources.py +7 -14
  13. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_runner.py +5 -5
  14. php_parser_py-1.2.0/pyproject.toml +0 -32
  15. php_parser_py-1.2.0/src/php_parser_py/_ast.py +0 -361
  16. php_parser_py-1.2.0/src/php_parser_py/_node.py +0 -208
  17. php_parser_py-1.2.0/src/php_parser_py/_parser.py +0 -371
  18. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/resources/php-parser-4.19.4.zip +0 -0
  19. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/vendor/.extracted +0 -0
  20. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/vendor/LICENSE.txt +0 -0
  21. {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/vendor/php-parser.phar +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: php-parser-py
3
- Version: 1.2.0
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>
@@ -11,9 +11,11 @@ Description-Content-Type: text/markdown
11
11
 
12
12
  # php-parser-py
13
13
 
14
- Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
14
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
15
15
 
16
- - **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
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
+
18
+ - **Automated**: Uses [static-php-py](https://github.com/jhu-seclab-beluga/static-php-py) binaries (no local PHP required).
17
19
  - **Graph-Based**: AST nodes are stored in a queryable graph database.
18
20
  - **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
19
21
 
@@ -23,38 +25,37 @@ pip install php-parser-py
23
25
  ```
24
26
 
25
27
  ```python
26
- from php_parser_py import parse_file, parse_project
28
+ from php_parser_py import parse_file, Modifier
27
29
 
28
30
  # 1. Parse
29
31
  ast = parse_file("src/User.php")
30
- # ast = parse_project("src/")
31
-
32
- # 2. Traverse
33
- files = ast.file_nodes()
34
32
 
35
- # Find specific nodes
33
+ # 2. Query
36
34
  functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
37
-
38
- # Get properties (scalars)
39
35
  for func in functions:
40
36
  print(f"Function: {func['namespacedName']}")
41
-
42
- # Get child nodes (structure)
43
- for func in functions:
44
- # Use helper method or traverse edges
45
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")
46
47
  ```
47
48
 
48
49
  ## Documentation
49
50
 
50
51
  - **[Architecture & Design](docs/design.md)**
51
- Detailed design documentation covering internal architecture, class specifications, and design rationale.
52
+ Internal architecture, class specifications, and design rationale.
52
53
 
53
54
  - **[AST Structure Reference](docs/libs/php_parser_ast.md)**
54
- Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
55
+ Complete table of all node types, subnodes (children), and properties.
55
56
 
56
- - **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
57
- How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
57
+ - **[cpg2py Graph API](docs/libs/cpg2py.md)**
58
+ Querying (`nodes`, `succ`, `prev`)
58
59
 
59
60
  ## Requirements
60
61
  - Python ≥ 3.11
@@ -1,8 +1,10 @@
1
1
  # php-parser-py
2
2
 
3
- Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
3
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
4
4
 
5
- - **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
5
+ Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/samhsu-dev/cpg2py) graph framework.
6
+
7
+ - **Automated**: Uses [static-php-py](https://github.com/jhu-seclab-beluga/static-php-py) binaries (no local PHP required).
6
8
  - **Graph-Based**: AST nodes are stored in a queryable graph database.
7
9
  - **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
8
10
 
@@ -12,38 +14,37 @@ pip install php-parser-py
12
14
  ```
13
15
 
14
16
  ```python
15
- from php_parser_py import parse_file, parse_project
17
+ from php_parser_py import parse_file, Modifier
16
18
 
17
19
  # 1. Parse
18
20
  ast = parse_file("src/User.php")
19
- # ast = parse_project("src/")
20
-
21
- # 2. Traverse
22
- files = ast.file_nodes()
23
21
 
24
- # Find specific nodes
22
+ # 2. Query
25
23
  functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
26
-
27
- # Get properties (scalars)
28
24
  for func in functions:
29
25
  print(f"Function: {func['namespacedName']}")
30
-
31
- # Get child nodes (structure)
32
- for func in functions:
33
- # Use helper method or traverse edges
34
26
  params = [c for c in ast.succ(func) if c.node_type == "Param"]
27
+
28
+ # 3. Modify properties
29
+ func.set_property("analyzed", True)
30
+
31
+ # 4. Modify structure
32
+ modifier = Modifier(ast)
33
+ modifier.add_node("new_1", "Stmt_Break")
34
+ modifier.add_edge("parent_id", "new_1", field="stmts", index=0)
35
+ modifier.remove_node("obsolete_node_id")
35
36
  ```
36
37
 
37
38
  ## Documentation
38
39
 
39
40
  - **[Architecture & Design](docs/design.md)**
40
- Detailed design documentation covering internal architecture, class specifications, and design rationale.
41
+ Internal architecture, class specifications, and design rationale.
41
42
 
42
43
  - **[AST Structure Reference](docs/libs/php_parser_ast.md)**
43
- Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
44
+ Complete table of all node types, subnodes (children), and properties.
44
45
 
45
- - **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
46
- How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
46
+ - **[cpg2py Graph API](docs/libs/cpg2py.md)**
47
+ Querying (`nodes`, `succ`, `prev`)
47
48
 
48
49
  ## Requirements
49
50
  - Python ≥ 3.11
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "php-parser-py"
3
+ version = "1.2.2"
4
+ description = "Python wrapper for PHP-Parser using cpg2py"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "samhsu-dev", email = "yxu166@jh.edu" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "cpg2py>=1.2.0",
12
+ "static-php-py",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.9.26,<0.10.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [tool.uv]
20
+ package = true
21
+
22
+ [tool.uv.build-backend]
23
+ # Include resources directory in package data
24
+ source-include = ["resources/*.zip"]
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "black>=26.1.0",
29
+ "isort>=7.0.0",
30
+ "mypy>=1.13.0",
31
+ "pylint>=4.0.4",
32
+ "pytest>=9.0.2",
33
+ ]
34
+
35
+ [tool.pylint.messages_control]
36
+ disable = [
37
+ "wrong-import-position",
38
+ "too-many-arguments",
39
+ "too-many-positional-arguments",
40
+ "too-many-locals",
41
+ "too-many-branches",
42
+ "too-many-statements",
43
+ "invalid-name",
44
+ ]
45
+
46
+ [tool.mypy]
47
+ python_version = "3.11"
48
+ strict = true
49
+ warn_return_any = true
50
+ warn_unused_ignores = true
51
+ allow_subclassing_any = true
52
+
53
+ [[tool.mypy.overrides]]
54
+ module = ["cpg2py", "cpg2py.*", "static_php_py", "static_php_py.*"]
55
+ ignore_missing_imports = true
56
+
57
+ [tool.black]
58
+ line-length = 88
59
+ target-version = ["py311"]
60
+
61
+ [tool.isort]
62
+ profile = "black"
63
+ line_length = 88
@@ -5,40 +5,22 @@ This package provides a Python interface to PHP-Parser, enabling AST parsing
5
5
  and manipulation of PHP code.
6
6
  """
7
7
 
8
- from typing import TYPE_CHECKING
8
+ from pathlib import Path
9
+ from typing import Callable
9
10
 
10
- if TYPE_CHECKING:
11
- from pathlib import Path
12
- from typing import Callable
13
-
14
- from php_parser_py._parser import Parser as _Parser
15
- from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
16
- from php_parser_py._resources import ensure_php_parser_extracted
11
+ from ._resources import ensure_php_parser_extracted
17
12
 
18
13
  # Ensure PHP-Parser is extracted on first import
19
14
  ensure_php_parser_extracted()
20
15
 
21
16
  # Import and expose public classes directly
22
- from php_parser_py._ast import AST
23
- from php_parser_py._edge import Edge
24
- from php_parser_py._exceptions import ParseError, RunnerError
25
- from php_parser_py._node import Node
26
-
27
-
28
- class Parser(_Parser):
29
- """Parses PHP source code using PHP-Parser.
30
-
31
- Invokes PHP-Parser to parse PHP code and converts the resulting JSON
32
- to cpg2py Storage format for graph-based analysis.
33
- """
34
-
35
-
36
- class PrettyPrinter(_PrettyPrinter):
37
- """Converts AST to PHP code using PHP-Parser's PrettyPrinter.
38
-
39
- Reconstructs JSON from AST graph and invokes PHP-Parser to generate
40
- formatted PHP source code.
41
- """
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
42
24
 
43
25
 
44
26
  def parse_code(code: str) -> list[Node]:
@@ -79,9 +61,13 @@ def parse_file(path: str) -> AST:
79
61
  return parser.parse_file(path)
80
62
 
81
63
 
64
+ def _default_file_filter(path: Path) -> bool:
65
+ return path.suffix == ".php"
66
+
67
+
82
68
  def parse_project(
83
69
  project_path: str,
84
- file_filter: "Callable[[Path], bool]" = lambda p: p.suffix == ".php", # type: ignore
70
+ file_filter: Callable[[Path], bool] | None = None,
85
71
  ) -> AST:
86
72
  """Parse all PHP files in a project directory into an AST.
87
73
 
@@ -91,7 +77,7 @@ def parse_project(
91
77
  Args:
92
78
  project_path: Project root directory path.
93
79
  file_filter: Function to filter files. Takes a Path and returns
94
- True if the file should be parsed. Defaults to checking for `.php` suffix.
80
+ True if the file should be parsed. Defaults to .php suffix.
95
81
 
96
82
  Returns:
97
83
  AST instance with project -> files -> statements hierarchy.
@@ -101,6 +87,8 @@ def parse_project(
101
87
  FileNotFoundError: If project directory does not exist.
102
88
  ValueError: If project_path is not a directory.
103
89
  """
90
+ if file_filter is None:
91
+ file_filter = _default_file_filter
104
92
  parser = Parser()
105
93
  return parser.parse_project(project_path, file_filter=file_filter)
106
94
 
@@ -108,12 +96,14 @@ def parse_project(
108
96
  __version__ = "0.1.0"
109
97
  __all__ = [
110
98
  "AST",
111
- "Node",
112
99
  "Edge",
100
+ "Modifier",
101
+ "Node",
113
102
  "Parser",
114
103
  "PrettyPrinter",
115
104
  "ParseError",
116
105
  "RunnerError",
106
+ "NodeNotInFileError",
117
107
  "parse_code",
118
108
  "parse_file",
119
109
  "parse_project",
@@ -0,0 +1,341 @@
1
+ """AST representation for PHP code using cpg2py's graph framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from cpg2py import AbcGraphQuerier, Storage
10
+
11
+ from php_parser_py._edge import Edge
12
+ from php_parser_py._exceptions import NodeNotInFileError
13
+ from php_parser_py._node import Node
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class AST(AbcGraphQuerier[Node, Edge]):
19
+ """Represents a PHP Abstract Syntax Tree.
20
+
21
+ This class extends cpg2py's AbcGraphQuerier with generic type parameters
22
+ specifying that it works with Node and Edge types.
23
+
24
+ The AST structure follows a hierarchy:
25
+ - Project node (ID: "project") - root of the AST
26
+ - File nodes (ID: hex hash) - children of project
27
+ - Statement nodes (ID: {hex}_{counter}) - children of files
28
+
29
+ Type Parameters:
30
+ Node: The concrete node type for PHP AST nodes
31
+ Edge: The concrete edge type for parent-child relationships
32
+
33
+ Provides graph traversal methods, JSON reconstruction for code generation,
34
+ and AST merging capabilities. Uses cpg2py graph API (nodes, edges, succ,
35
+ prev, ancestors, descendants); storage is only used where the graph API
36
+ does not provide an alternative (node/edge existence and Node/Edge construction).
37
+
38
+ Attributes:
39
+ _root_node_id: ID of the root project node (always "project").
40
+ """
41
+
42
+ def __init__(self, storage: Storage, root_node_id: str = "project") -> None:
43
+ """Initialize AST with populated storage.
44
+
45
+ Args:
46
+ storage: cpg2py Storage containing AST nodes and edges.
47
+ root_node_id: ID of the root node. Defaults to "project".
48
+ """
49
+ super().__init__(storage)
50
+ self._root_node_id = root_node_id
51
+
52
+ def node(self, whose_id_is: str) -> Node:
53
+ """Return node wrapper by ID.
54
+
55
+ Args:
56
+ whose_id_is: Node ID string.
57
+
58
+ Returns:
59
+ Node instance for the given ID.
60
+
61
+ Raises:
62
+ KeyError: If the node ID is not in the graph.
63
+ """
64
+ if not self.storage.contains_node(whose_id_is):
65
+ raise KeyError(f"Node not found: {whose_id_is!r}")
66
+ return Node(self.storage, whose_id_is)
67
+
68
+ def edge(self, fid: str, tid: str, eid: str) -> Edge:
69
+ """Return edge wrapper by IDs.
70
+
71
+ Args:
72
+ fid: From node ID.
73
+ tid: To node ID.
74
+ eid: Edge type.
75
+
76
+ Returns:
77
+ Edge instance for the given (from, to, type).
78
+
79
+ Raises:
80
+ KeyError: If the edge is not in the graph.
81
+ """
82
+ edge_id = (fid, tid, eid)
83
+ if not self.storage.contains_edge(edge_id):
84
+ raise KeyError(f"Edge not found: {edge_id!r}")
85
+ return Edge(self.storage, fid, tid, eid)
86
+
87
+ def project_node(self) -> Node:
88
+ """Return the project node (root of the AST).
89
+
90
+ The project node has ID "project" and is always present when the AST
91
+ was created by parse_file or parse_project.
92
+
93
+ Returns:
94
+ Project Node instance.
95
+
96
+ Raises:
97
+ KeyError: If the root node is not in the graph.
98
+ """
99
+ if not self.storage.contains_node(self._root_node_id):
100
+ raise KeyError(f"Project node not found: {self._root_node_id!r}")
101
+ return Node(self.storage, self._root_node_id)
102
+
103
+ def file_nodes(self) -> list[Node]:
104
+ """Return all file nodes in the project.
105
+
106
+ Returns:
107
+ List of File Node instances.
108
+ """
109
+ try:
110
+ project = self.project_node()
111
+ except KeyError:
112
+ return []
113
+
114
+ file_nodes = [n for n in self.succ(project) if n.node_type == "File"]
115
+ return sorted(file_nodes, key=lambda n: n.get("absolutePath", ""))
116
+
117
+ def get_file_node(self, node_id: str) -> Node:
118
+ """Get the file node that contains the given node.
119
+
120
+ Uses the AST ID convention: file node ID is the file hash (e.g. 8-char hex);
121
+ nodes inside that file have ID ``file_hash + "_" + increment`` (e.g. ``a1b2c3d4_1``).
122
+ Resolves by structure first, then falls back to ancestors() traversal.
123
+
124
+ Args:
125
+ node_id: ID of any node in the AST.
126
+
127
+ Returns:
128
+ File Node instance containing the given node.
129
+
130
+ Raises:
131
+ KeyError: If the node ID is not in the graph.
132
+ NodeNotInFileError: If the node is not under any file (e.g. project node).
133
+ """
134
+ node = self.node(node_id)
135
+ if node.node_type == "File":
136
+ return node
137
+
138
+ if node_id == self._root_node_id:
139
+ raise NodeNotInFileError(node_id, "Project node has no containing file.")
140
+
141
+ result = self._try_file_by_id_prefix(node_id)
142
+ if result is not None:
143
+ return result
144
+
145
+ return self._find_file_ancestor(node)
146
+
147
+ def _try_file_by_id_prefix(self, node_id: str) -> Node | None:
148
+ """Try to find file node by ID prefix convention.
149
+
150
+ If node_id is like "hash_123", attempts to get file node with ID "hash".
151
+ Returns None if ID doesn't match convention or file not found.
152
+ """
153
+ if "_" not in node_id:
154
+ return None
155
+
156
+ prefix, rest = node_id.split("_", 1)
157
+ if not rest.isdigit():
158
+ return None
159
+
160
+ try:
161
+ candidate = self.node(prefix)
162
+ if candidate.node_type == "File":
163
+ return candidate
164
+ except KeyError:
165
+ pass
166
+
167
+ return None
168
+
169
+ def _find_file_ancestor(self, node: Node) -> Node:
170
+ """Find first File ancestor of a node via traversal.
171
+
172
+ Raises NodeNotInFileError if no File ancestor exists.
173
+ """
174
+ for ancestor in self.ancestors(node):
175
+ if ancestor.node_type == "File":
176
+ result: Node = ancestor
177
+ return result
178
+
179
+ raise NodeNotInFileError(node.id, "No File node among ancestors.")
180
+
181
+ def to_json(self, file_hash: str | None = None) -> str:
182
+ """Reconstruct PHP-Parser JSON from Storage for code generation.
183
+
184
+ If file_hash is provided, exports only that file's statements.
185
+ Otherwise, exports all statements from all files, or root nodes if no file structure exists.
186
+
187
+ Args:
188
+ file_hash: Optional file hash to export only that file.
189
+
190
+ Returns:
191
+ JSON string compatible with PHP-Parser's JsonDecoder.
192
+ """
193
+ if file_hash:
194
+ # Export single file (node() raises KeyError if file_hash not in graph)
195
+ self.node(file_hash)
196
+ top_level_nodes = self._get_file_statements(file_hash)
197
+ else:
198
+ # Try to export from file structure first
199
+ file_nodes = self.file_nodes()
200
+ if file_nodes:
201
+ # Export all files (flattened)
202
+ top_level_nodes = []
203
+ for file_node in file_nodes:
204
+ top_level_nodes.extend(self._get_file_statements(file_node.id))
205
+ else:
206
+ # No file structure - find root nodes via graph API (no incoming PARENT_OF)
207
+ all_nodes = {n.id for n in self.nodes()}
208
+ nodes_with_parents = {
209
+ e.to_nid for e in self.edges() if e.type == "PARENT_OF"
210
+ }
211
+ root_nodes = all_nodes - nodes_with_parents
212
+ root_nodes.discard(self._root_node_id)
213
+ top_level_nodes = sorted(root_nodes)
214
+
215
+ result = [self._reconstruct_node(nid) for nid in top_level_nodes]
216
+ return json.dumps(result)
217
+
218
+ def _get_file_statements(self, file_hash: str) -> list[str]:
219
+ """Get top-level statement node IDs for a file (direct children with edge field \"stmts\").
220
+
221
+ Uses succ() traversal; statement IDs follow the convention file_hash_1, file_hash_2, ...
222
+ """
223
+ file_node = self.node(file_hash)
224
+ stmts_with_index = []
225
+ for child in self.succ(file_node):
226
+ edge = self.edge(file_hash, child.id, "PARENT_OF")
227
+ if edge.get("field") == "stmts":
228
+ idx = edge.get("index")
229
+ stmts_with_index.append((999999 if idx is None else idx, child.id))
230
+ stmts_with_index.sort(key=lambda t: t[0])
231
+ return [nid for _, nid in stmts_with_index]
232
+
233
+ def _reconstruct_node(self, nid: str) -> dict[str, Any]:
234
+ """Recursively reconstruct JSON object for a node.
235
+
236
+ Args:
237
+ nid: Node ID to reconstruct.
238
+
239
+ Returns:
240
+ Dictionary representing the node in PHP-Parser JSON format.
241
+ """
242
+ node = self.node(nid)
243
+ props = node.all_properties
244
+ result: dict[str, Any] = {"nodeType": node.node_type}
245
+
246
+ attributes = self._extract_attributes(props)
247
+ if attributes:
248
+ result["attributes"] = attributes
249
+
250
+ self._add_non_attribute_props(result, props)
251
+ self._reconstruct_child_fields(result, nid)
252
+ self._add_default_attrs(result, node.node_type)
253
+
254
+ return result
255
+
256
+ def _extract_attributes(self, props: dict[str, Any]) -> dict[str, Any]:
257
+ """Extract metadata attributes from node properties.
258
+
259
+ Returns dict with position and comment metadata.
260
+ """
261
+ attr_keys = {
262
+ "startLine",
263
+ "endLine",
264
+ "startFilePos",
265
+ "endFilePos",
266
+ "startTokenPos",
267
+ "endTokenPos",
268
+ "kind",
269
+ "comments",
270
+ }
271
+ return {k: v for k, v in props.items() if k in attr_keys}
272
+
273
+ def _add_non_attribute_props(
274
+ self, result: dict[str, Any], props: dict[str, Any]
275
+ ) -> None:
276
+ """Add non-attribute, non-nodeType properties to result."""
277
+ attr_keys = {
278
+ "nodeType",
279
+ "startLine",
280
+ "endLine",
281
+ "startFilePos",
282
+ "endFilePos",
283
+ "startTokenPos",
284
+ "endTokenPos",
285
+ "kind",
286
+ "comments",
287
+ }
288
+ for key, value in props.items():
289
+ if key not in attr_keys:
290
+ result[key] = value
291
+
292
+ def _reconstruct_child_fields(self, result: dict[str, Any], nid: str) -> None:
293
+ """Reconstruct and add child fields to result.
294
+
295
+ Child nodes are fetched via succ() and indexed by edge properties.
296
+ """
297
+ node = self.node(nid)
298
+ child_fields: dict[str, dict[int, Any]] = {}
299
+
300
+ for child in self.succ(node):
301
+ try:
302
+ edge = self.edge(nid, child.id, "PARENT_OF")
303
+ except KeyError:
304
+ continue
305
+
306
+ field_name = edge.get("field")
307
+ if field_name is None:
308
+ continue
309
+
310
+ child_json = self._reconstruct_node(child.id)
311
+ index = edge.get("index")
312
+
313
+ if field_name not in child_fields:
314
+ child_fields[field_name] = {}
315
+
316
+ if index is not None:
317
+ child_fields[field_name][index] = child_json
318
+ else:
319
+ result[field_name] = child_json
320
+
321
+ for field_name, indexed_children in child_fields.items():
322
+ if indexed_children:
323
+ max_index = max(indexed_children.keys())
324
+ array = [None] * (max_index + 1)
325
+ for idx, child in indexed_children.items():
326
+ array[idx] = child
327
+ result[field_name] = array
328
+
329
+ def _add_default_attrs(self, result: dict[str, Any], node_type: str) -> None:
330
+ """Add default attrGroups if not already present.
331
+
332
+ PHP-Parser expects attrGroups on certain node types.
333
+ """
334
+ if "attrGroups" in result:
335
+ return
336
+
337
+ if any(
338
+ node_type.startswith(prefix)
339
+ for prefix in ("Stmt_", "Expr_Closure", "Expr_ArrowFunction")
340
+ ) or node_type == "Param":
341
+ result["attrGroups"] = []