php-parser-py 1.1.1__tar.gz → 1.2.1__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.
- php_parser_py-1.2.1/PKG-INFO +63 -0
- php_parser_py-1.2.1/README.md +52 -0
- php_parser_py-1.2.1/pyproject.toml +63 -0
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/__init__.py +11 -7
- php_parser_py-1.2.1/src/php_parser_py/_ast.py +341 -0
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/_edge.py +9 -43
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/_exceptions.py +12 -0
- php_parser_py-1.2.1/src/php_parser_py/_node.py +313 -0
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/_parser.py +73 -49
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/_printer.py +9 -11
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/_resources.py +7 -14
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/_runner.py +11 -27
- php_parser_py-1.1.1/PKG-INFO +0 -285
- php_parser_py-1.1.1/README.md +0 -274
- php_parser_py-1.1.1/pyproject.toml +0 -32
- php_parser_py-1.1.1/src/php_parser_py/_ast.py +0 -371
- php_parser_py-1.1.1/src/php_parser_py/_node.py +0 -208
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/resources/php-parser-4.19.4.zip +0 -0
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/vendor/.extracted +0 -0
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/vendor/LICENSE.txt +0 -0
- {php_parser_py-1.1.1 → php_parser_py-1.2.1}/src/php_parser_py/vendor/php-parser.phar +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: php-parser-py
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: Python wrapper for PHP-Parser using cpg2py
|
|
5
|
+
Author: samhsu-dev
|
|
6
|
+
Author-email: samhsu-dev <yxu166@jh.edu>
|
|
7
|
+
Requires-Dist: cpg2py>=1.2.0
|
|
8
|
+
Requires-Dist: static-php-py
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# php-parser-py
|
|
13
|
+
|
|
14
|
+
[](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
|
|
15
|
+
|
|
16
|
+
Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
|
|
17
|
+
|
|
18
|
+
- **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
|
|
19
|
+
- **Graph-Based**: AST nodes are stored in a queryable graph database.
|
|
20
|
+
- **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
```bash
|
|
24
|
+
pip install php-parser-py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from php_parser_py import parse_file, parse_project
|
|
29
|
+
|
|
30
|
+
# 1. Parse
|
|
31
|
+
ast = parse_file("src/User.php")
|
|
32
|
+
# ast = parse_project("src/")
|
|
33
|
+
|
|
34
|
+
# 2. Traverse
|
|
35
|
+
files = ast.file_nodes()
|
|
36
|
+
|
|
37
|
+
# Find specific nodes
|
|
38
|
+
functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
|
|
39
|
+
|
|
40
|
+
# Get properties (scalars)
|
|
41
|
+
for func in functions:
|
|
42
|
+
print(f"Function: {func['namespacedName']}")
|
|
43
|
+
|
|
44
|
+
# Get child nodes (structure)
|
|
45
|
+
for func in functions:
|
|
46
|
+
# Use helper method or traverse edges
|
|
47
|
+
params = [c for c in ast.succ(func) if c.node_type == "Param"]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Documentation
|
|
51
|
+
|
|
52
|
+
- **[Architecture & Design](docs/design.md)**
|
|
53
|
+
Detailed design documentation covering internal architecture, class specifications, and design rationale.
|
|
54
|
+
|
|
55
|
+
- **[AST Structure Reference](docs/libs/php_parser_ast.md)**
|
|
56
|
+
Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
|
|
57
|
+
|
|
58
|
+
- **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
|
|
59
|
+
How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
- Python ≥ 3.11
|
|
63
|
+
- (Optional) Custom PHP binary can be configured via `Parser` constructor.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# php-parser-py
|
|
2
|
+
|
|
3
|
+
[](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
|
|
4
|
+
|
|
5
|
+
Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/jhu-seclab/cpg2py) graph framework.
|
|
6
|
+
|
|
7
|
+
- **Automated**: Uses [static-php-cli](https://github.com/static-php-cli/static-php-cli) binaries (no local PHP required).
|
|
8
|
+
- **Graph-Based**: AST nodes are stored in a queryable graph database.
|
|
9
|
+
- **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
```bash
|
|
13
|
+
pip install php-parser-py
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from php_parser_py import parse_file, parse_project
|
|
18
|
+
|
|
19
|
+
# 1. Parse
|
|
20
|
+
ast = parse_file("src/User.php")
|
|
21
|
+
# ast = parse_project("src/")
|
|
22
|
+
|
|
23
|
+
# 2. Traverse
|
|
24
|
+
files = ast.file_nodes()
|
|
25
|
+
|
|
26
|
+
# Find specific nodes
|
|
27
|
+
functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
|
|
28
|
+
|
|
29
|
+
# Get properties (scalars)
|
|
30
|
+
for func in functions:
|
|
31
|
+
print(f"Function: {func['namespacedName']}")
|
|
32
|
+
|
|
33
|
+
# Get child nodes (structure)
|
|
34
|
+
for func in functions:
|
|
35
|
+
# Use helper method or traverse edges
|
|
36
|
+
params = [c for c in ast.succ(func) if c.node_type == "Param"]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Documentation
|
|
40
|
+
|
|
41
|
+
- **[Architecture & Design](docs/design.md)**
|
|
42
|
+
Detailed design documentation covering internal architecture, class specifications, and design rationale.
|
|
43
|
+
|
|
44
|
+
- **[AST Structure Reference](docs/libs/php_parser_ast.md)**
|
|
45
|
+
Complete table of all node types, subnodes (children), and properties. Verified against PHP-Parser source.
|
|
46
|
+
|
|
47
|
+
- **[Graph Traversal Guide](docs/libs/cpg2py_traversal.md)**
|
|
48
|
+
How to navigate the graph (`succ`, `prev`), access properties, and modify the graph (`set_property`).
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
- Python ≥ 3.11
|
|
52
|
+
- (Optional) Custom PHP binary can be configured via `Parser` constructor.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "php-parser-py"
|
|
3
|
+
version = "1.2.1"
|
|
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,11 +5,8 @@ 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
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
13
10
|
|
|
14
11
|
from php_parser_py._parser import Parser as _Parser
|
|
15
12
|
from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
|
|
@@ -21,7 +18,7 @@ ensure_php_parser_extracted()
|
|
|
21
18
|
# Import and expose public classes directly
|
|
22
19
|
from php_parser_py._ast import AST
|
|
23
20
|
from php_parser_py._edge import Edge
|
|
24
|
-
from php_parser_py._exceptions import ParseError, RunnerError
|
|
21
|
+
from php_parser_py._exceptions import NodeNotInFileError, ParseError, RunnerError
|
|
25
22
|
from php_parser_py._node import Node
|
|
26
23
|
|
|
27
24
|
|
|
@@ -79,9 +76,13 @@ def parse_file(path: str) -> AST:
|
|
|
79
76
|
return parser.parse_file(path)
|
|
80
77
|
|
|
81
78
|
|
|
79
|
+
def _default_file_filter(path: Path) -> bool:
|
|
80
|
+
return path.suffix == ".php"
|
|
81
|
+
|
|
82
|
+
|
|
82
83
|
def parse_project(
|
|
83
84
|
project_path: str,
|
|
84
|
-
file_filter:
|
|
85
|
+
file_filter: Callable[[Path], bool] | None = None,
|
|
85
86
|
) -> AST:
|
|
86
87
|
"""Parse all PHP files in a project directory into an AST.
|
|
87
88
|
|
|
@@ -101,6 +102,8 @@ def parse_project(
|
|
|
101
102
|
FileNotFoundError: If project directory does not exist.
|
|
102
103
|
ValueError: If project_path is not a directory.
|
|
103
104
|
"""
|
|
105
|
+
if file_filter is None:
|
|
106
|
+
file_filter = _default_file_filter
|
|
104
107
|
parser = Parser()
|
|
105
108
|
return parser.parse_project(project_path, file_filter=file_filter)
|
|
106
109
|
|
|
@@ -114,6 +117,7 @@ __all__ = [
|
|
|
114
117
|
"PrettyPrinter",
|
|
115
118
|
"ParseError",
|
|
116
119
|
"RunnerError",
|
|
120
|
+
"NodeNotInFileError",
|
|
117
121
|
"parse_code",
|
|
118
122
|
"parse_file",
|
|
119
123
|
"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"] = []
|
|
@@ -6,14 +6,14 @@ from cpg2py import AbcEdgeQuerier, Storage
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class Edge(AbcEdgeQuerier):
|
|
9
|
-
"""Represents an edge between AST nodes.
|
|
9
|
+
"""Represents an edge between AST nodes with generic property access.
|
|
10
10
|
|
|
11
|
-
Extends cpg2py's AbcEdgeQuerier
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
Extends cpg2py's AbcEdgeQuerier. Edge type is fixed (e.g. "PARENT_OF");
|
|
12
|
+
other data is stored as arbitrary properties via Storage. For PARENT_OF
|
|
13
|
+
edges in our PHP AST mapping, we use properties "field" and "index" to
|
|
14
|
+
represent the PHP-Parser subnode key and array position—access them via
|
|
15
|
+
edge.get("field"), edge.get("index") or edge["field"], edge["index"];
|
|
16
|
+
they are not special-cased on this class.
|
|
17
17
|
|
|
18
18
|
Attributes:
|
|
19
19
|
_storage: cpg2py Storage instance containing edge data.
|
|
@@ -39,45 +39,11 @@ class Edge(AbcEdgeQuerier):
|
|
|
39
39
|
|
|
40
40
|
@property
|
|
41
41
|
def type(self) -> str:
|
|
42
|
-
"""Return the edge type.
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
Edge type string (e.g., "PARENT_OF").
|
|
46
|
-
"""
|
|
42
|
+
"""Return the edge type (e.g. "PARENT_OF")."""
|
|
47
43
|
return self._edge_id[2]
|
|
48
44
|
|
|
49
45
|
@property
|
|
50
|
-
def
|
|
51
|
-
"""Return the field name for this edge.
|
|
52
|
-
|
|
53
|
-
The field name indicates which subnode property this edge represents
|
|
54
|
-
(e.g., "name", "params", "stmts").
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
Field name string or None.
|
|
58
|
-
"""
|
|
59
|
-
props = self._storage.get_edge_props(self._edge_id)
|
|
60
|
-
if props is None:
|
|
61
|
-
return None
|
|
62
|
-
return props.get("field")
|
|
63
|
-
|
|
64
|
-
@property
|
|
65
|
-
def index(self) -> int | None:
|
|
66
|
-
"""Return the array index for ordered child relationships.
|
|
67
|
-
|
|
68
|
-
For edges representing array elements in PHP-Parser JSON, this property
|
|
69
|
-
contains the index position to maintain ordering.
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
Integer index or None if not an array element.
|
|
73
|
-
"""
|
|
74
|
-
props = self._storage.get_edge_props(self._edge_id)
|
|
75
|
-
if props is None:
|
|
76
|
-
return None
|
|
77
|
-
return props.get("index")
|
|
78
|
-
|
|
79
|
-
@property
|
|
80
|
-
def all_properties(self) -> dict:
|
|
46
|
+
def all_properties(self) -> dict[str, Any]:
|
|
81
47
|
"""Return all edge properties.
|
|
82
48
|
|
|
83
49
|
Returns:
|
|
@@ -21,6 +21,18 @@ class ParseError(Exception):
|
|
|
21
21
|
self.line = line
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class NodeNotInFileError(Exception):
|
|
25
|
+
"""Raised when a node has no containing file (e.g. project node or orphan).
|
|
26
|
+
|
|
27
|
+
Use when get_file(node_id) is called and the node is not under any file node.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, node_id: str, message: str | None = None) -> None:
|
|
31
|
+
self.node_id = node_id
|
|
32
|
+
self.message = message or f"Node {node_id!r} is not under any file node."
|
|
33
|
+
super().__init__(self.message)
|
|
34
|
+
|
|
35
|
+
|
|
24
36
|
class RunnerError(Exception):
|
|
25
37
|
"""Raised when PHP process execution fails.
|
|
26
38
|
|