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.
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/PKG-INFO +20 -19
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/README.md +19 -18
- php_parser_py-1.2.2/pyproject.toml +63 -0
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/__init__.py +21 -31
- php_parser_py-1.2.2/src/php_parser_py/_ast.py +341 -0
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_edge.py +9 -43
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_exceptions.py +12 -0
- php_parser_py-1.2.2/src/php_parser_py/_modifier.py +140 -0
- php_parser_py-1.2.2/src/php_parser_py/_node.py +313 -0
- php_parser_py-1.2.2/src/php_parser_py/_parser.py +371 -0
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_printer.py +2 -2
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_resources.py +7 -14
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/_runner.py +5 -5
- php_parser_py-1.2.0/pyproject.toml +0 -32
- php_parser_py-1.2.0/src/php_parser_py/_ast.py +0 -361
- php_parser_py-1.2.0/src/php_parser_py/_node.py +0 -208
- php_parser_py-1.2.0/src/php_parser_py/_parser.py +0 -371
- {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
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/vendor/.extracted +0 -0
- {php_parser_py-1.2.0 → php_parser_py-1.2.2}/src/php_parser_py/vendor/LICENSE.txt +0 -0
- {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.
|
|
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
|
-
|
|
14
|
+
[](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
|
|
15
15
|
|
|
16
|
-
|
|
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,
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
55
|
+
Complete table of all node types, subnodes (children), and properties.
|
|
55
56
|
|
|
56
|
-
- **[Graph
|
|
57
|
-
|
|
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
|
-
|
|
3
|
+
[](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
|
|
4
4
|
|
|
5
|
-
|
|
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,
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
44
|
+
Complete table of all node types, subnodes (children), and properties.
|
|
44
45
|
|
|
45
|
-
- **[Graph
|
|
46
|
-
|
|
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
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
9
10
|
|
|
10
|
-
|
|
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
|
|
23
|
-
from
|
|
24
|
-
from
|
|
25
|
-
from
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
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
|
|
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"] = []
|