php-parser-py 1.2.1__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.1 → php_parser_py-1.2.2}/PKG-INFO +18 -19
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/README.md +17 -18
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/pyproject.toml +1 -1
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/__init__.py +11 -25
- php_parser_py-1.2.2/src/php_parser_py/_modifier.py +140 -0
- php_parser_py-1.2.2/src/php_parser_py/_parser.py +371 -0
- php_parser_py-1.2.1/src/php_parser_py/_parser.py +0 -402
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/_ast.py +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/_edge.py +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/_exceptions.py +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/_node.py +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/_printer.py +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/_resources.py +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/_runner.py +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/resources/php-parser-4.19.4.zip +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/vendor/.extracted +0 -0
- {php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/vendor/LICENSE.txt +0 -0
- {php_parser_py-1.2.1 → 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>
|
|
@@ -13,9 +13,9 @@ Description-Content-Type: text/markdown
|
|
|
13
13
|
|
|
14
14
|
[](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
|
|
15
15
|
|
|
16
|
-
Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/
|
|
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
17
|
|
|
18
|
-
- **Automated**: Uses [static-php-
|
|
18
|
+
- **Automated**: Uses [static-php-py](https://github.com/jhu-seclab-beluga/static-php-py) binaries (no local PHP required).
|
|
19
19
|
- **Graph-Based**: AST nodes are stored in a queryable graph database.
|
|
20
20
|
- **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
|
|
21
21
|
|
|
@@ -25,38 +25,37 @@ pip install php-parser-py
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
```python
|
|
28
|
-
from php_parser_py import parse_file,
|
|
28
|
+
from php_parser_py import parse_file, Modifier
|
|
29
29
|
|
|
30
30
|
# 1. Parse
|
|
31
31
|
ast = parse_file("src/User.php")
|
|
32
|
-
# ast = parse_project("src/")
|
|
33
32
|
|
|
34
|
-
# 2.
|
|
35
|
-
files = ast.file_nodes()
|
|
36
|
-
|
|
37
|
-
# Find specific nodes
|
|
33
|
+
# 2. Query
|
|
38
34
|
functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
|
|
39
|
-
|
|
40
|
-
# Get properties (scalars)
|
|
41
35
|
for func in functions:
|
|
42
36
|
print(f"Function: {func['namespacedName']}")
|
|
43
|
-
|
|
44
|
-
# Get child nodes (structure)
|
|
45
|
-
for func in functions:
|
|
46
|
-
# Use helper method or traverse edges
|
|
47
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")
|
|
48
47
|
```
|
|
49
48
|
|
|
50
49
|
## Documentation
|
|
51
50
|
|
|
52
51
|
- **[Architecture & Design](docs/design.md)**
|
|
53
|
-
|
|
52
|
+
Internal architecture, class specifications, and design rationale.
|
|
54
53
|
|
|
55
54
|
- **[AST Structure Reference](docs/libs/php_parser_ast.md)**
|
|
56
|
-
Complete table of all node types, subnodes (children), and properties.
|
|
55
|
+
Complete table of all node types, subnodes (children), and properties.
|
|
57
56
|
|
|
58
|
-
- **[Graph
|
|
59
|
-
|
|
57
|
+
- **[cpg2py Graph API](docs/libs/cpg2py.md)**
|
|
58
|
+
Querying (`nodes`, `succ`, `prev`)
|
|
60
59
|
|
|
61
60
|
## Requirements
|
|
62
61
|
- Python ≥ 3.11
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://deepwiki.com/jhu-seclab-beluga/php-parser-py)
|
|
4
4
|
|
|
5
|
-
Python wrapper for [nikic/PHP-Parser](https://github.com/nikic/PHP-Parser) built on the [cpg2py](https://github.com/
|
|
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
6
|
|
|
7
|
-
- **Automated**: Uses [static-php-
|
|
7
|
+
- **Automated**: Uses [static-php-py](https://github.com/jhu-seclab-beluga/static-php-py) binaries (no local PHP required).
|
|
8
8
|
- **Graph-Based**: AST nodes are stored in a queryable graph database.
|
|
9
9
|
- **Accurate**: Full support for PHP-Parser 4.19.4 AST structure.
|
|
10
10
|
|
|
@@ -14,38 +14,37 @@ pip install php-parser-py
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
```python
|
|
17
|
-
from php_parser_py import parse_file,
|
|
17
|
+
from php_parser_py import parse_file, Modifier
|
|
18
18
|
|
|
19
19
|
# 1. Parse
|
|
20
20
|
ast = parse_file("src/User.php")
|
|
21
|
-
# ast = parse_project("src/")
|
|
22
21
|
|
|
23
|
-
# 2.
|
|
24
|
-
files = ast.file_nodes()
|
|
25
|
-
|
|
26
|
-
# Find specific nodes
|
|
22
|
+
# 2. Query
|
|
27
23
|
functions = ast.nodes(lambda n: n.node_type == "Stmt_Function")
|
|
28
|
-
|
|
29
|
-
# Get properties (scalars)
|
|
30
24
|
for func in functions:
|
|
31
25
|
print(f"Function: {func['namespacedName']}")
|
|
32
|
-
|
|
33
|
-
# Get child nodes (structure)
|
|
34
|
-
for func in functions:
|
|
35
|
-
# Use helper method or traverse edges
|
|
36
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")
|
|
37
36
|
```
|
|
38
37
|
|
|
39
38
|
## Documentation
|
|
40
39
|
|
|
41
40
|
- **[Architecture & Design](docs/design.md)**
|
|
42
|
-
|
|
41
|
+
Internal architecture, class specifications, and design rationale.
|
|
43
42
|
|
|
44
43
|
- **[AST Structure Reference](docs/libs/php_parser_ast.md)**
|
|
45
|
-
Complete table of all node types, subnodes (children), and properties.
|
|
44
|
+
Complete table of all node types, subnodes (children), and properties.
|
|
46
45
|
|
|
47
|
-
- **[Graph
|
|
48
|
-
|
|
46
|
+
- **[cpg2py Graph API](docs/libs/cpg2py.md)**
|
|
47
|
+
Querying (`nodes`, `succ`, `prev`)
|
|
49
48
|
|
|
50
49
|
## Requirements
|
|
51
50
|
- Python ≥ 3.11
|
|
@@ -8,34 +8,19 @@ and manipulation of PHP code.
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Callable
|
|
10
10
|
|
|
11
|
-
from
|
|
12
|
-
from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
|
|
13
|
-
from php_parser_py._resources import ensure_php_parser_extracted
|
|
11
|
+
from ._resources import ensure_php_parser_extracted
|
|
14
12
|
|
|
15
13
|
# Ensure PHP-Parser is extracted on first import
|
|
16
14
|
ensure_php_parser_extracted()
|
|
17
15
|
|
|
18
16
|
# Import and expose public classes directly
|
|
19
|
-
from
|
|
20
|
-
from
|
|
21
|
-
from
|
|
22
|
-
from
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"""Parses PHP source code using PHP-Parser.
|
|
27
|
-
|
|
28
|
-
Invokes PHP-Parser to parse PHP code and converts the resulting JSON
|
|
29
|
-
to cpg2py Storage format for graph-based analysis.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class PrettyPrinter(_PrettyPrinter):
|
|
34
|
-
"""Converts AST to PHP code using PHP-Parser's PrettyPrinter.
|
|
35
|
-
|
|
36
|
-
Reconstructs JSON from AST graph and invokes PHP-Parser to generate
|
|
37
|
-
formatted PHP source code.
|
|
38
|
-
"""
|
|
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
|
|
39
24
|
|
|
40
25
|
|
|
41
26
|
def parse_code(code: str) -> list[Node]:
|
|
@@ -92,7 +77,7 @@ def parse_project(
|
|
|
92
77
|
Args:
|
|
93
78
|
project_path: Project root directory path.
|
|
94
79
|
file_filter: Function to filter files. Takes a Path and returns
|
|
95
|
-
True if the file should be parsed. Defaults to
|
|
80
|
+
True if the file should be parsed. Defaults to .php suffix.
|
|
96
81
|
|
|
97
82
|
Returns:
|
|
98
83
|
AST instance with project -> files -> statements hierarchy.
|
|
@@ -111,8 +96,9 @@ def parse_project(
|
|
|
111
96
|
__version__ = "0.1.0"
|
|
112
97
|
__all__ = [
|
|
113
98
|
"AST",
|
|
114
|
-
"Node",
|
|
115
99
|
"Edge",
|
|
100
|
+
"Modifier",
|
|
101
|
+
"Node",
|
|
116
102
|
"Parser",
|
|
117
103
|
"PrettyPrinter",
|
|
118
104
|
"ParseError",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Modifier class for AST graph mutation operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from ._edge import Edge
|
|
9
|
+
from ._node import Node
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ._ast import AST
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Modifier:
|
|
18
|
+
"""Encapsulates all graph mutation operations for an AST.
|
|
19
|
+
|
|
20
|
+
The only approved way to add, remove, or structurally modify nodes and
|
|
21
|
+
edges in the AST graph. Callers must never use Storage directly.
|
|
22
|
+
|
|
23
|
+
AST (AbcGraphQuerier) is a query interface; graph mutation is a separate
|
|
24
|
+
concern handled by this class. Mutations are immediately visible through
|
|
25
|
+
the wrapped AST's query methods.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
_ast: The AST instance being modified.
|
|
29
|
+
_storage: Reference to the AST's internal Storage.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, ast: AST) -> None:
|
|
33
|
+
"""Wrap an existing AST instance for mutation.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
ast: AST instance to modify.
|
|
37
|
+
"""
|
|
38
|
+
self._ast = ast
|
|
39
|
+
self._storage = ast.storage
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def ast(self) -> AST:
|
|
43
|
+
"""Return the underlying AST instance."""
|
|
44
|
+
return self._ast
|
|
45
|
+
|
|
46
|
+
# -- Node Operations --
|
|
47
|
+
|
|
48
|
+
def add_node(self, node_id: str, node_type: str, **props: object) -> Node:
|
|
49
|
+
"""Create a new node in the graph with the given type and properties.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
node_id: Unique node ID string.
|
|
53
|
+
node_type: PHP-Parser node type (e.g. "Stmt_Break").
|
|
54
|
+
**props: Additional node properties to set.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Node instance for the newly created node.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If node_id already exists in the graph.
|
|
61
|
+
"""
|
|
62
|
+
if self._storage.contains_node(node_id):
|
|
63
|
+
raise ValueError(f"Node already exists: {node_id!r}")
|
|
64
|
+
|
|
65
|
+
self._storage.add_node(node_id)
|
|
66
|
+
all_props: dict[str, object] = {"nodeType": node_type, **props}
|
|
67
|
+
self._storage.set_node_props(node_id, all_props)
|
|
68
|
+
return Node(self._storage, node_id)
|
|
69
|
+
|
|
70
|
+
def remove_node(self, node_id: str) -> None:
|
|
71
|
+
"""Remove a node and all its connected edges from the graph.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
node_id: Node ID to remove.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
KeyError: If node_id is not in the graph.
|
|
78
|
+
"""
|
|
79
|
+
if not self._storage.contains_node(node_id):
|
|
80
|
+
raise KeyError(f"Node not found: {node_id!r}")
|
|
81
|
+
self._storage.remove_node(node_id)
|
|
82
|
+
|
|
83
|
+
# -- Edge Operations --
|
|
84
|
+
|
|
85
|
+
def add_edge(
|
|
86
|
+
self,
|
|
87
|
+
from_id: str,
|
|
88
|
+
to_id: str,
|
|
89
|
+
edge_type: str = "PARENT_OF",
|
|
90
|
+
**props: object,
|
|
91
|
+
) -> Edge:
|
|
92
|
+
"""Create a new edge between two existing nodes.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
from_id: Source node ID.
|
|
96
|
+
to_id: Target node ID.
|
|
97
|
+
edge_type: Edge type. Defaults to "PARENT_OF".
|
|
98
|
+
**props: Edge properties (e.g. field="stmts", index=0).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Edge instance for the newly created edge.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
KeyError: If either node does not exist.
|
|
105
|
+
ValueError: If edge already exists.
|
|
106
|
+
"""
|
|
107
|
+
if not self._storage.contains_node(from_id):
|
|
108
|
+
raise KeyError(f"Source node not found: {from_id!r}")
|
|
109
|
+
if not self._storage.contains_node(to_id):
|
|
110
|
+
raise KeyError(f"Target node not found: {to_id!r}")
|
|
111
|
+
|
|
112
|
+
edge_id = (from_id, to_id, edge_type)
|
|
113
|
+
if self._storage.contains_edge(edge_id):
|
|
114
|
+
raise ValueError(f"Edge already exists: {edge_id!r}")
|
|
115
|
+
|
|
116
|
+
self._storage.add_edge(edge_id)
|
|
117
|
+
if props:
|
|
118
|
+
self._storage.set_edge_props(edge_id, props)
|
|
119
|
+
return Edge(self._storage, from_id, to_id, edge_type)
|
|
120
|
+
|
|
121
|
+
def remove_edge(
|
|
122
|
+
self,
|
|
123
|
+
from_id: str,
|
|
124
|
+
to_id: str,
|
|
125
|
+
edge_type: str = "PARENT_OF",
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Remove an edge from the graph.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
from_id: Source node ID.
|
|
131
|
+
to_id: Target node ID.
|
|
132
|
+
edge_type: Edge type. Defaults to "PARENT_OF".
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
KeyError: If edge is not in the graph.
|
|
136
|
+
"""
|
|
137
|
+
edge_id = (from_id, to_id, edge_type)
|
|
138
|
+
if not self._storage.contains_edge(edge_id):
|
|
139
|
+
raise KeyError(f"Edge not found: {edge_id!r}")
|
|
140
|
+
self._storage.remove_edge(edge_id)
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Parser class for PHP code parsing."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from cpg2py import Storage
|
|
9
|
+
from static_php_py import PHP
|
|
10
|
+
|
|
11
|
+
from ._ast import AST
|
|
12
|
+
from ._exceptions import ParseError, RunnerError
|
|
13
|
+
from ._modifier import Modifier
|
|
14
|
+
from ._node import Node
|
|
15
|
+
from ._runner import Runner
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Parser:
|
|
21
|
+
"""Parses PHP source code using PHP-Parser.
|
|
22
|
+
|
|
23
|
+
Uses Modifier for all AST construction. Parser never calls Storage
|
|
24
|
+
directly — all node/edge creation goes through Modifier.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
_runner: Runner instance for PHP-Parser invocation.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, php: Optional[PHP] = None) -> None:
|
|
31
|
+
"""Initialize Parser with Runner.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
php: Optional PHP instance. If not provided, uses builtin PHP.
|
|
35
|
+
"""
|
|
36
|
+
self._runner = Runner(php=php)
|
|
37
|
+
|
|
38
|
+
def parse_code(self, code: str) -> list[Node]:
|
|
39
|
+
"""Parse PHP code string into a list of top-level statement nodes.
|
|
40
|
+
|
|
41
|
+
Does not create project or file nodes. Returns raw statement nodes
|
|
42
|
+
that can be used for analysis or inserted into other AST structures.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
code: PHP source code to parse.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of Node instances representing top-level statements.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ParseError: If code has syntax errors.
|
|
52
|
+
RunnerError: If PHP execution fails.
|
|
53
|
+
"""
|
|
54
|
+
json_data = self._parse_php(code)
|
|
55
|
+
node_list = self._normalize_json(json_data)
|
|
56
|
+
|
|
57
|
+
modifier = Modifier(AST(Storage(), root_node_id="__code_root__"))
|
|
58
|
+
node_counter = [1]
|
|
59
|
+
node_ids: list[str] = []
|
|
60
|
+
|
|
61
|
+
for item in node_list:
|
|
62
|
+
node_id = self._process_node(
|
|
63
|
+
modifier, item, None, None, None, node_counter, ""
|
|
64
|
+
)
|
|
65
|
+
if node_id:
|
|
66
|
+
node_ids.append(node_id)
|
|
67
|
+
|
|
68
|
+
return [modifier.ast.node(nid) for nid in node_ids]
|
|
69
|
+
|
|
70
|
+
def parse_file(self, path: str) -> AST:
|
|
71
|
+
"""Parse a single PHP file into an AST with project and file nodes.
|
|
72
|
+
|
|
73
|
+
Creates a project node (ID: "project") and a file node (ID: hex
|
|
74
|
+
hash of path). File node contains all statements from the file.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
path: File path string.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
AST instance with project -> file -> statements hierarchy.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ParseError: If PHP-Parser reports syntax error.
|
|
84
|
+
FileNotFoundError: If file does not exist.
|
|
85
|
+
"""
|
|
86
|
+
file_path = Path(path).resolve()
|
|
87
|
+
if not file_path.exists():
|
|
88
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
89
|
+
|
|
90
|
+
project_path = file_path.parent
|
|
91
|
+
file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
|
|
92
|
+
code = file_path.read_text(encoding="utf-8")
|
|
93
|
+
json_data = self._parse_php(code)
|
|
94
|
+
file_list = self._normalize_json(json_data)
|
|
95
|
+
|
|
96
|
+
modifier = self._build_project_structure(
|
|
97
|
+
[(file_path, file_list)],
|
|
98
|
+
[(file_path, file_hash)],
|
|
99
|
+
project_path=project_path,
|
|
100
|
+
)
|
|
101
|
+
return modifier.ast
|
|
102
|
+
|
|
103
|
+
def parse_project(
|
|
104
|
+
self,
|
|
105
|
+
project_path: str,
|
|
106
|
+
file_filter: Callable[[Path], bool] = lambda p: p.suffix == ".php",
|
|
107
|
+
) -> AST:
|
|
108
|
+
"""Parse all PHP files in a project directory into an AST.
|
|
109
|
+
|
|
110
|
+
Recursively traverses the project directory to find all PHP files,
|
|
111
|
+
then parses them into a single AST with project and file nodes.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
project_path: Project root directory path.
|
|
115
|
+
file_filter: Function to filter files. Takes a Path and returns
|
|
116
|
+
True if the file should be parsed. Defaults to .php suffix.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
AST instance with project -> files -> statements hierarchy.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ParseError: If any file has syntax errors.
|
|
123
|
+
FileNotFoundError: If project directory does not exist.
|
|
124
|
+
ValueError: If project_path is not a directory.
|
|
125
|
+
"""
|
|
126
|
+
project_path_obj = Path(project_path).resolve()
|
|
127
|
+
|
|
128
|
+
if not project_path_obj.exists():
|
|
129
|
+
raise FileNotFoundError(f"Project directory not found: {project_path}")
|
|
130
|
+
if not project_path_obj.is_dir():
|
|
131
|
+
raise ValueError(f"Project path is not a directory: {project_path}")
|
|
132
|
+
|
|
133
|
+
php_files = [
|
|
134
|
+
f for f in project_path_obj.rglob("*") if f.is_file() and file_filter(f)
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
if not php_files:
|
|
138
|
+
logger.warning("No PHP files found in project directory: %s", project_path)
|
|
139
|
+
return self._build_empty_project(project_path_obj)
|
|
140
|
+
|
|
141
|
+
file_infos = [
|
|
142
|
+
(fp, hashlib.md5(str(fp).encode()).hexdigest()[:8]) for fp in php_files
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
all_json_data = []
|
|
146
|
+
for file_path, _ in file_infos:
|
|
147
|
+
code = file_path.read_text(encoding="utf-8")
|
|
148
|
+
json_data = self._parse_php(code, source=str(file_path))
|
|
149
|
+
all_json_data.append((file_path, self._normalize_json(json_data)))
|
|
150
|
+
|
|
151
|
+
modifier = self._build_project_structure(
|
|
152
|
+
all_json_data, file_infos, project_path=project_path_obj
|
|
153
|
+
)
|
|
154
|
+
return modifier.ast
|
|
155
|
+
|
|
156
|
+
# -- Internal helpers --
|
|
157
|
+
|
|
158
|
+
def _parse_php(self, code: str, source: str = "input") -> object:
|
|
159
|
+
# Invoke PHP-Parser and translate RunnerError into ParseError.
|
|
160
|
+
try:
|
|
161
|
+
return self._runner.parse(code)
|
|
162
|
+
except RunnerError as e:
|
|
163
|
+
if "Syntax error" in str(e):
|
|
164
|
+
raise ParseError(f"Syntax error in {source}", line=1) from e
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _normalize_json(json_data: object) -> list[dict[str, object]]:
|
|
169
|
+
# Ensure JSON data is always a list of node dicts.
|
|
170
|
+
if isinstance(json_data, list):
|
|
171
|
+
return json_data
|
|
172
|
+
if isinstance(json_data, dict):
|
|
173
|
+
return [json_data]
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
def _build_empty_project(self, project_path: Path) -> AST:
|
|
177
|
+
# Create a project-only AST with no files.
|
|
178
|
+
modifier = Modifier(AST(Storage()))
|
|
179
|
+
modifier.add_node(
|
|
180
|
+
"project",
|
|
181
|
+
"Project",
|
|
182
|
+
absolutePath=str(project_path),
|
|
183
|
+
startLine=-1,
|
|
184
|
+
endLine=-1,
|
|
185
|
+
startFilePos=-1,
|
|
186
|
+
endFilePos=-1,
|
|
187
|
+
startTokenPos=-1,
|
|
188
|
+
endTokenPos=-1,
|
|
189
|
+
)
|
|
190
|
+
return modifier.ast
|
|
191
|
+
|
|
192
|
+
def _build_project_structure(
|
|
193
|
+
self,
|
|
194
|
+
files_data: list[tuple[Path, list[dict[str, object]]]],
|
|
195
|
+
file_infos: list[tuple[Path, str]],
|
|
196
|
+
project_path: Path,
|
|
197
|
+
) -> Modifier:
|
|
198
|
+
# Build project -> files -> statements hierarchy via Modifier.
|
|
199
|
+
modifier = Modifier(AST(Storage()))
|
|
200
|
+
|
|
201
|
+
modifier.add_node("project", "Project", absolutePath=str(project_path))
|
|
202
|
+
|
|
203
|
+
for (file_path, file_hash), (_, json_data) in zip(file_infos, files_data):
|
|
204
|
+
self._add_file_node(modifier, file_path, file_hash, json_data, project_path)
|
|
205
|
+
|
|
206
|
+
# Set sentinel position values on project node.
|
|
207
|
+
project_node = modifier.ast.node("project")
|
|
208
|
+
project_node.set_properties(
|
|
209
|
+
{
|
|
210
|
+
"startLine": -1,
|
|
211
|
+
"endLine": -1,
|
|
212
|
+
"startFilePos": -1,
|
|
213
|
+
"endFilePos": -1,
|
|
214
|
+
"startTokenPos": -1,
|
|
215
|
+
"endTokenPos": -1,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return modifier
|
|
220
|
+
|
|
221
|
+
def _add_file_node(
|
|
222
|
+
self,
|
|
223
|
+
modifier: Modifier,
|
|
224
|
+
file_path: Path,
|
|
225
|
+
file_hash: str,
|
|
226
|
+
stmt_list: list[dict[str, object]],
|
|
227
|
+
project_path: Path,
|
|
228
|
+
) -> None:
|
|
229
|
+
# Create file node, link to project, process statements.
|
|
230
|
+
try:
|
|
231
|
+
relative_path = file_path.relative_to(project_path)
|
|
232
|
+
except ValueError:
|
|
233
|
+
relative_path = Path(file_path.name)
|
|
234
|
+
|
|
235
|
+
end_pos = self._compute_file_end_positions(stmt_list)
|
|
236
|
+
|
|
237
|
+
modifier.add_node(
|
|
238
|
+
file_hash,
|
|
239
|
+
"File",
|
|
240
|
+
absolutePath=str(file_path),
|
|
241
|
+
relativePath=str(relative_path),
|
|
242
|
+
startLine=1,
|
|
243
|
+
endLine=end_pos["endLine"],
|
|
244
|
+
startFilePos=0,
|
|
245
|
+
endFilePos=end_pos["endFilePos"],
|
|
246
|
+
startTokenPos=0,
|
|
247
|
+
endTokenPos=end_pos["endTokenPos"],
|
|
248
|
+
)
|
|
249
|
+
modifier.add_edge("project", file_hash, field="files")
|
|
250
|
+
|
|
251
|
+
node_counter = [1]
|
|
252
|
+
for idx, item in enumerate(stmt_list):
|
|
253
|
+
self._process_node(
|
|
254
|
+
modifier, item, file_hash, "stmts", idx, node_counter, file_hash
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _compute_file_end_positions(
|
|
259
|
+
stmt_list: list[dict[str, object]],
|
|
260
|
+
) -> dict[str, int]:
|
|
261
|
+
# Compute file end positions from statement attributes.
|
|
262
|
+
end_line = 1
|
|
263
|
+
end_file_pos = 0
|
|
264
|
+
end_token_pos = 0
|
|
265
|
+
|
|
266
|
+
for stmt in stmt_list:
|
|
267
|
+
attrs = stmt.get("attributes", {})
|
|
268
|
+
if not isinstance(attrs, dict):
|
|
269
|
+
continue
|
|
270
|
+
val = attrs.get("endLine")
|
|
271
|
+
if isinstance(val, int) and val > end_line:
|
|
272
|
+
end_line = val
|
|
273
|
+
val = attrs.get("endFilePos")
|
|
274
|
+
if isinstance(val, int) and val > end_file_pos:
|
|
275
|
+
end_file_pos = val
|
|
276
|
+
val = attrs.get("endTokenPos")
|
|
277
|
+
if isinstance(val, int) and val > end_token_pos:
|
|
278
|
+
end_token_pos = val
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
"endLine": end_line,
|
|
282
|
+
"endFilePos": end_file_pos,
|
|
283
|
+
"endTokenPos": end_token_pos,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def _process_node(
|
|
287
|
+
self,
|
|
288
|
+
modifier: Modifier,
|
|
289
|
+
node_data: object,
|
|
290
|
+
parent_id: str | None,
|
|
291
|
+
field_name: str | None,
|
|
292
|
+
index: int | None,
|
|
293
|
+
node_counter: list[int],
|
|
294
|
+
prefix: str,
|
|
295
|
+
) -> str | None:
|
|
296
|
+
# Recursively convert a PHP-Parser JSON node into graph nodes/edges.
|
|
297
|
+
if not isinstance(node_data, dict) or "nodeType" not in node_data:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
node_id = self._generate_node_id(node_counter, prefix)
|
|
301
|
+
properties, child_fields = self._extract_node_data(node_data)
|
|
302
|
+
|
|
303
|
+
node_type_val = properties.pop("nodeType")
|
|
304
|
+
if not isinstance(node_type_val, str):
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
modifier.add_node(node_id, node_type_val, **properties)
|
|
308
|
+
|
|
309
|
+
if parent_id is not None and field_name is not None:
|
|
310
|
+
if index is not None:
|
|
311
|
+
modifier.add_edge(parent_id, node_id, field=field_name, index=index)
|
|
312
|
+
else:
|
|
313
|
+
modifier.add_edge(parent_id, node_id, field=field_name)
|
|
314
|
+
|
|
315
|
+
self._process_children(modifier, child_fields, node_id, node_counter, prefix)
|
|
316
|
+
return node_id
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _generate_node_id(node_counter: list[int], prefix: str) -> str:
|
|
320
|
+
# Generate unique node ID from counter and prefix.
|
|
321
|
+
current = node_counter[0]
|
|
322
|
+
node_counter[0] += 1
|
|
323
|
+
if prefix:
|
|
324
|
+
return f"{prefix}_{current}"
|
|
325
|
+
return f"node_{current}"
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _extract_node_data(
|
|
329
|
+
node_data: dict[str, object],
|
|
330
|
+
) -> tuple[dict[str, object], list[tuple[str, object]]]:
|
|
331
|
+
# Separate scalar properties from child fields.
|
|
332
|
+
properties: dict[str, object] = {}
|
|
333
|
+
child_fields: list[tuple[str, object]] = []
|
|
334
|
+
|
|
335
|
+
for key, value in node_data.items():
|
|
336
|
+
if key == "attributes" and isinstance(value, dict):
|
|
337
|
+
properties.update(value)
|
|
338
|
+
elif isinstance(value, dict):
|
|
339
|
+
child_fields.append((key, value))
|
|
340
|
+
elif isinstance(value, list) and value and isinstance(value[0], dict):
|
|
341
|
+
child_fields.append((key, value))
|
|
342
|
+
else:
|
|
343
|
+
properties[key] = value
|
|
344
|
+
|
|
345
|
+
return properties, child_fields
|
|
346
|
+
|
|
347
|
+
def _process_children(
|
|
348
|
+
self,
|
|
349
|
+
modifier: Modifier,
|
|
350
|
+
child_fields: list[tuple[str, object]],
|
|
351
|
+
parent_id: str,
|
|
352
|
+
node_counter: list[int],
|
|
353
|
+
prefix: str,
|
|
354
|
+
) -> None:
|
|
355
|
+
# Process child fields recursively.
|
|
356
|
+
for child_key, child_value in child_fields:
|
|
357
|
+
if isinstance(child_value, list):
|
|
358
|
+
for idx, item in enumerate(child_value):
|
|
359
|
+
self._process_node(
|
|
360
|
+
modifier, item, parent_id, child_key, idx, node_counter, prefix
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
self._process_node(
|
|
364
|
+
modifier,
|
|
365
|
+
child_value,
|
|
366
|
+
parent_id,
|
|
367
|
+
child_key,
|
|
368
|
+
None,
|
|
369
|
+
node_counter,
|
|
370
|
+
prefix,
|
|
371
|
+
)
|
|
@@ -1,402 +0,0 @@
|
|
|
1
|
-
"""Parser class for PHP code parsing."""
|
|
2
|
-
|
|
3
|
-
import hashlib
|
|
4
|
-
import logging
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Any, Callable, Optional
|
|
7
|
-
|
|
8
|
-
from cpg2py import Storage
|
|
9
|
-
from static_php_py import PHP
|
|
10
|
-
|
|
11
|
-
from php_parser_py._ast import AST
|
|
12
|
-
from php_parser_py._exceptions import ParseError, RunnerError
|
|
13
|
-
from php_parser_py._node import Node
|
|
14
|
-
from php_parser_py._runner import Runner
|
|
15
|
-
|
|
16
|
-
logger = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class Parser:
|
|
20
|
-
"""Parses PHP source code using PHP-Parser.
|
|
21
|
-
|
|
22
|
-
Provides three parsing methods:
|
|
23
|
-
- parse_code: Parse code string, returns list of top-level statement nodes
|
|
24
|
-
- parse_file: Parse single file, returns AST with project and file nodes
|
|
25
|
-
- parse_project: Parse project directory, returns AST with project and all PHP files
|
|
26
|
-
|
|
27
|
-
Attributes:
|
|
28
|
-
_runner: Runner instance for PHP-Parser invocation.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
def __init__(self, php: Optional[PHP] = None) -> None:
|
|
32
|
-
"""Initialize Parser with Runner.
|
|
33
|
-
|
|
34
|
-
Args:
|
|
35
|
-
php: Optional PHP instance. If not provided, uses builtin PHP.
|
|
36
|
-
"""
|
|
37
|
-
self._runner = Runner(php=php)
|
|
38
|
-
|
|
39
|
-
def parse_code(self, code: str) -> list[Node]:
|
|
40
|
-
"""Parse PHP code string into a list of top-level statement nodes.
|
|
41
|
-
|
|
42
|
-
Does not create project or file nodes. Returns raw statement nodes
|
|
43
|
-
that can be used for analysis or inserted into other AST structures.
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
code: PHP source code to parse.
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
List of Node instances representing top-level statements.
|
|
50
|
-
|
|
51
|
-
Raises:
|
|
52
|
-
ParseError: If code has syntax errors.
|
|
53
|
-
RunnerError: If PHP execution fails.
|
|
54
|
-
"""
|
|
55
|
-
try:
|
|
56
|
-
json_data = self._runner.parse(code)
|
|
57
|
-
except RunnerError as e:
|
|
58
|
-
if "Syntax error" in str(e):
|
|
59
|
-
raise ParseError("Syntax error in PHP code", line=1) from e
|
|
60
|
-
raise
|
|
61
|
-
|
|
62
|
-
# Normalize to list
|
|
63
|
-
node_list: list[dict[str, Any]] = (
|
|
64
|
-
[json_data] if not isinstance(json_data, list) else json_data
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
# Create temporary storage to convert JSON to nodes
|
|
68
|
-
storage = Storage()
|
|
69
|
-
node_ids: list[str] = []
|
|
70
|
-
node_counter = [1] # Use list to allow mutation in recursive calls
|
|
71
|
-
|
|
72
|
-
for item in node_list:
|
|
73
|
-
node_id = self._process_node(
|
|
74
|
-
storage, item, None, None, None, node_counter, ""
|
|
75
|
-
)
|
|
76
|
-
if node_id:
|
|
77
|
-
node_ids.append(node_id)
|
|
78
|
-
|
|
79
|
-
# Return Node instances
|
|
80
|
-
return [Node(storage, nid) for nid in node_ids]
|
|
81
|
-
|
|
82
|
-
def parse_file(self, path: str) -> AST:
|
|
83
|
-
"""Parse a single PHP file into an AST with project and file nodes.
|
|
84
|
-
|
|
85
|
-
Creates a project node (ID: "project") and a file node (ID: hex hash of path).
|
|
86
|
-
File node contains all statements from the file.
|
|
87
|
-
Project path is set to the file's directory.
|
|
88
|
-
|
|
89
|
-
Args:
|
|
90
|
-
path: File path string.
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
AST instance with project -> file -> statements hierarchy.
|
|
94
|
-
|
|
95
|
-
Raises:
|
|
96
|
-
ParseError: If PHP-Parser reports syntax error.
|
|
97
|
-
FileNotFoundError: If file does not exist.
|
|
98
|
-
"""
|
|
99
|
-
file_path = Path(path).resolve()
|
|
100
|
-
if not file_path.exists():
|
|
101
|
-
raise FileNotFoundError(f"File not found: {path}")
|
|
102
|
-
|
|
103
|
-
# Project path is the file's directory
|
|
104
|
-
project_path = file_path.parent
|
|
105
|
-
|
|
106
|
-
# Generate file hash (hex)
|
|
107
|
-
file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
|
|
108
|
-
|
|
109
|
-
code = file_path.read_text(encoding="utf-8")
|
|
110
|
-
|
|
111
|
-
try:
|
|
112
|
-
json_data = self._runner.parse(code)
|
|
113
|
-
except RunnerError as e:
|
|
114
|
-
if "Syntax error" in str(e):
|
|
115
|
-
raise ParseError("Syntax error in PHP code", line=1) from e
|
|
116
|
-
raise
|
|
117
|
-
|
|
118
|
-
file_list: list[dict[str, Any]] = (
|
|
119
|
-
[json_data] if not isinstance(json_data, list) else json_data
|
|
120
|
-
)
|
|
121
|
-
storage = self._create_project_structure(
|
|
122
|
-
[(file_path, file_list)],
|
|
123
|
-
[(file_path, file_hash)],
|
|
124
|
-
project_path=project_path,
|
|
125
|
-
)
|
|
126
|
-
return AST(storage, root_node_id="project")
|
|
127
|
-
|
|
128
|
-
def parse_project(
|
|
129
|
-
self,
|
|
130
|
-
project_path: str,
|
|
131
|
-
file_filter: Callable[[Path], bool] = lambda p: p.suffix == ".php",
|
|
132
|
-
) -> AST:
|
|
133
|
-
"""Parse all PHP files in a project directory into an AST.
|
|
134
|
-
|
|
135
|
-
Recursively traverses the project directory to find all PHP files,
|
|
136
|
-
then parses them into a single AST with project and file nodes.
|
|
137
|
-
Each file node has ID based on its path hash.
|
|
138
|
-
|
|
139
|
-
Args:
|
|
140
|
-
project_path: Project root directory path.
|
|
141
|
-
file_filter: Function to filter files. Takes a Path and returns
|
|
142
|
-
True if the file should be parsed. Defaults to checking for `.php` suffix.
|
|
143
|
-
|
|
144
|
-
Returns:
|
|
145
|
-
AST instance with project -> files -> statements hierarchy.
|
|
146
|
-
|
|
147
|
-
Raises:
|
|
148
|
-
ParseError: If any file has syntax errors.
|
|
149
|
-
FileNotFoundError: If project directory does not exist.
|
|
150
|
-
ValueError: If project_path is not a directory.
|
|
151
|
-
"""
|
|
152
|
-
project_path_obj = Path(project_path).resolve()
|
|
153
|
-
|
|
154
|
-
if not project_path_obj.exists():
|
|
155
|
-
raise FileNotFoundError(f"Project directory not found: {project_path}")
|
|
156
|
-
|
|
157
|
-
if not project_path_obj.is_dir():
|
|
158
|
-
raise ValueError(f"Project path is not a directory: {project_path}")
|
|
159
|
-
|
|
160
|
-
# Find all files recursively and filter
|
|
161
|
-
all_files = project_path_obj.rglob("*")
|
|
162
|
-
php_files = [f for f in all_files if f.is_file() and file_filter(f)]
|
|
163
|
-
|
|
164
|
-
if not php_files:
|
|
165
|
-
logger.warning("No PHP files found in project directory: %s", project_path)
|
|
166
|
-
# Create empty project structure
|
|
167
|
-
storage = Storage()
|
|
168
|
-
project_id = "project"
|
|
169
|
-
storage.add_node(project_id)
|
|
170
|
-
storage.set_node_props(
|
|
171
|
-
project_id,
|
|
172
|
-
{"nodeType": "Project", "absolutePath": str(project_path)},
|
|
173
|
-
)
|
|
174
|
-
return AST(storage, root_node_id="project")
|
|
175
|
-
|
|
176
|
-
# Prepare file information
|
|
177
|
-
file_infos = []
|
|
178
|
-
for file_path in php_files:
|
|
179
|
-
file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
|
|
180
|
-
file_infos.append((file_path, file_hash))
|
|
181
|
-
|
|
182
|
-
# Parse all files
|
|
183
|
-
all_json_data = []
|
|
184
|
-
for file_path, _ in file_infos:
|
|
185
|
-
code = file_path.read_text(encoding="utf-8")
|
|
186
|
-
try:
|
|
187
|
-
json_data = self._runner.parse(code)
|
|
188
|
-
node_list: list[dict[str, Any]] = (
|
|
189
|
-
[json_data] if not isinstance(json_data, list) else json_data
|
|
190
|
-
)
|
|
191
|
-
all_json_data.append((file_path, node_list))
|
|
192
|
-
except RunnerError as e:
|
|
193
|
-
if "Syntax error" in str(e):
|
|
194
|
-
raise ParseError(f"Syntax error in {file_path}", line=1) from e
|
|
195
|
-
raise
|
|
196
|
-
|
|
197
|
-
storage = self._create_project_structure(
|
|
198
|
-
all_json_data, file_infos, project_path=project_path_obj
|
|
199
|
-
)
|
|
200
|
-
return AST(storage, root_node_id="project")
|
|
201
|
-
|
|
202
|
-
def _create_project_structure(
|
|
203
|
-
self,
|
|
204
|
-
files_data: list[tuple[Path, Any]],
|
|
205
|
-
file_infos: list[tuple[Path, str]],
|
|
206
|
-
project_path: Path,
|
|
207
|
-
) -> Storage:
|
|
208
|
-
"""Create AST structure with project -> files -> statements hierarchy.
|
|
209
|
-
|
|
210
|
-
Args:
|
|
211
|
-
files_data: List of (file_path, json_data) tuples.
|
|
212
|
-
file_infos: List of (file_path, file_hash) tuples.
|
|
213
|
-
project_path: Project root directory path.
|
|
214
|
-
|
|
215
|
-
Returns:
|
|
216
|
-
Storage instance with complete project structure.
|
|
217
|
-
"""
|
|
218
|
-
storage = Storage()
|
|
219
|
-
|
|
220
|
-
# Create project node (fixed ID: "project")
|
|
221
|
-
project_id = "project"
|
|
222
|
-
storage.add_node(project_id)
|
|
223
|
-
storage.set_node_props(
|
|
224
|
-
project_id,
|
|
225
|
-
{"nodeType": "Project", "absolutePath": str(project_path)},
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
# Create file nodes and process their statements
|
|
229
|
-
for (file_path, file_hash), (_, json_data) in zip(file_infos, files_data):
|
|
230
|
-
# Calculate relative path from project root
|
|
231
|
-
try:
|
|
232
|
-
relative_path = file_path.relative_to(project_path)
|
|
233
|
-
except ValueError:
|
|
234
|
-
relative_path = Path(file_path.name)
|
|
235
|
-
|
|
236
|
-
# Normalize and inspect statements to compute file position range
|
|
237
|
-
stmt_list: list[dict[str, Any]] = (
|
|
238
|
-
[json_data] if not isinstance(json_data, list) else json_data
|
|
239
|
-
)
|
|
240
|
-
file_end_line = 1
|
|
241
|
-
file_end_file_pos = 0
|
|
242
|
-
file_end_token_pos = 0
|
|
243
|
-
for stmt in stmt_list:
|
|
244
|
-
attributes = stmt.get("attributes", {})
|
|
245
|
-
end_line = attributes.get("endLine")
|
|
246
|
-
if isinstance(end_line, int) and end_line > file_end_line:
|
|
247
|
-
file_end_line = end_line
|
|
248
|
-
end_file_pos = attributes.get("endFilePos")
|
|
249
|
-
if isinstance(end_file_pos, int) and end_file_pos > file_end_file_pos:
|
|
250
|
-
file_end_file_pos = end_file_pos
|
|
251
|
-
end_token_pos = attributes.get("endTokenPos")
|
|
252
|
-
if isinstance(end_token_pos, int) and end_token_pos > file_end_token_pos:
|
|
253
|
-
file_end_token_pos = end_token_pos
|
|
254
|
-
|
|
255
|
-
# Create file node (ID: hex hash)
|
|
256
|
-
file_id = file_hash
|
|
257
|
-
storage.add_node(file_id)
|
|
258
|
-
storage.set_node_props(
|
|
259
|
-
file_id,
|
|
260
|
-
{
|
|
261
|
-
"nodeType": "File",
|
|
262
|
-
"absolutePath": str(file_path),
|
|
263
|
-
"relativePath": str(relative_path),
|
|
264
|
-
"startLine": 1,
|
|
265
|
-
"endLine": file_end_line,
|
|
266
|
-
"startFilePos": 0,
|
|
267
|
-
"endFilePos": file_end_file_pos,
|
|
268
|
-
"startTokenPos": 0,
|
|
269
|
-
"endTokenPos": file_end_token_pos,
|
|
270
|
-
},
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
# Link file to project
|
|
274
|
-
edge_id = (project_id, file_id, "PARENT_OF")
|
|
275
|
-
storage.add_edge(edge_id)
|
|
276
|
-
storage.set_edge_props(edge_id, {"field": "files"})
|
|
277
|
-
|
|
278
|
-
# Process statements in file (node IDs: {hex}_1, {hex}_2, ...)
|
|
279
|
-
node_counter = [1]
|
|
280
|
-
for idx, item in enumerate(stmt_list):
|
|
281
|
-
self._process_node(
|
|
282
|
-
storage, item, file_id, "stmts", idx, node_counter, file_hash
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
# Project node does not correspond to a concrete source range.
|
|
286
|
-
# Use sentinel values for all position attributes.
|
|
287
|
-
storage.set_node_props(
|
|
288
|
-
project_id,
|
|
289
|
-
{
|
|
290
|
-
"nodeType": "Project",
|
|
291
|
-
"absolutePath": str(project_path),
|
|
292
|
-
"startLine": -1,
|
|
293
|
-
"endLine": -1,
|
|
294
|
-
"startFilePos": -1,
|
|
295
|
-
"endFilePos": -1,
|
|
296
|
-
"startTokenPos": -1,
|
|
297
|
-
"endTokenPos": -1,
|
|
298
|
-
},
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
return storage
|
|
302
|
-
|
|
303
|
-
def _process_node(
|
|
304
|
-
self,
|
|
305
|
-
storage: Storage,
|
|
306
|
-
node_data: Any,
|
|
307
|
-
parent_id: str | None,
|
|
308
|
-
field_name: str | None,
|
|
309
|
-
index: int | None,
|
|
310
|
-
node_counter: list[int],
|
|
311
|
-
prefix: str,
|
|
312
|
-
) -> str | None:
|
|
313
|
-
"""Process a single node and its children recursively.
|
|
314
|
-
|
|
315
|
-
Args:
|
|
316
|
-
storage: Storage to populate.
|
|
317
|
-
node_data: JSON node data.
|
|
318
|
-
parent_id: Parent node ID if this is a child.
|
|
319
|
-
field_name: Field name in parent if this is a child.
|
|
320
|
-
index: Array index if this is an array element.
|
|
321
|
-
node_counter: Mutable list with single int counter for generating node IDs.
|
|
322
|
-
prefix: Prefix for node IDs (file hash for file nodes, empty for code nodes).
|
|
323
|
-
|
|
324
|
-
Returns:
|
|
325
|
-
Node ID if a node was created, None otherwise.
|
|
326
|
-
"""
|
|
327
|
-
if node_data is None:
|
|
328
|
-
return None
|
|
329
|
-
|
|
330
|
-
if not isinstance(node_data, dict):
|
|
331
|
-
return None
|
|
332
|
-
|
|
333
|
-
# Only create nodes for objects with nodeType
|
|
334
|
-
if "nodeType" not in node_data:
|
|
335
|
-
return None
|
|
336
|
-
|
|
337
|
-
# Generate unique node ID
|
|
338
|
-
current_counter = node_counter[0]
|
|
339
|
-
if prefix:
|
|
340
|
-
node_id = f"{prefix}_{current_counter}"
|
|
341
|
-
else:
|
|
342
|
-
node_id = f"node_{current_counter}"
|
|
343
|
-
node_counter[0] += 1
|
|
344
|
-
|
|
345
|
-
# Collect node properties
|
|
346
|
-
properties: dict[str, Any] = {}
|
|
347
|
-
child_fields: list[tuple[str, Any]] = []
|
|
348
|
-
|
|
349
|
-
for key, value in node_data.items():
|
|
350
|
-
if key == "attributes":
|
|
351
|
-
# Flatten attributes to node properties
|
|
352
|
-
if isinstance(value, dict):
|
|
353
|
-
properties.update(value)
|
|
354
|
-
elif isinstance(value, dict):
|
|
355
|
-
# Nested object - will create child node
|
|
356
|
-
child_fields.append((key, value))
|
|
357
|
-
elif isinstance(value, list):
|
|
358
|
-
# Check if it's an array of objects or scalars
|
|
359
|
-
if value and isinstance(value[0], dict):
|
|
360
|
-
# Array of objects - create child nodes
|
|
361
|
-
child_fields.append((key, value))
|
|
362
|
-
else:
|
|
363
|
-
# Array of scalars - save as property
|
|
364
|
-
properties[key] = value
|
|
365
|
-
else:
|
|
366
|
-
# Scalar property
|
|
367
|
-
properties[key] = value
|
|
368
|
-
|
|
369
|
-
# Add node to storage
|
|
370
|
-
storage.add_node(node_id)
|
|
371
|
-
if properties:
|
|
372
|
-
storage.set_node_props(node_id, properties)
|
|
373
|
-
|
|
374
|
-
# Create edge from parent if applicable
|
|
375
|
-
if parent_id is not None and field_name is not None:
|
|
376
|
-
edge_props: dict[str, str | int] = {"field": field_name}
|
|
377
|
-
if index is not None:
|
|
378
|
-
edge_props["index"] = index
|
|
379
|
-
edge_id = (parent_id, node_id, "PARENT_OF")
|
|
380
|
-
storage.add_edge(edge_id)
|
|
381
|
-
if edge_props:
|
|
382
|
-
storage.set_edge_props(edge_id, edge_props)
|
|
383
|
-
|
|
384
|
-
# Process child fields recursively
|
|
385
|
-
for child_key, child_value in child_fields:
|
|
386
|
-
if isinstance(child_value, list):
|
|
387
|
-
for idx, item in enumerate(child_value):
|
|
388
|
-
self._process_node(
|
|
389
|
-
storage, item, node_id, child_key, idx, node_counter, prefix
|
|
390
|
-
)
|
|
391
|
-
else:
|
|
392
|
-
self._process_node(
|
|
393
|
-
storage,
|
|
394
|
-
child_value,
|
|
395
|
-
node_id,
|
|
396
|
-
child_key,
|
|
397
|
-
None,
|
|
398
|
-
node_counter,
|
|
399
|
-
prefix,
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
return node_id
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{php_parser_py-1.2.1 → php_parser_py-1.2.2}/src/php_parser_py/resources/php-parser-4.19.4.zip
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|