php-parser-py 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- php_parser_py/__init__.py +86 -0
- php_parser_py/_ast.py +190 -0
- php_parser_py/_edge.py +130 -0
- php_parser_py/_node.py +208 -0
- php_parser_py/_parser.py +207 -0
- php_parser_py/_printer.py +52 -0
- php_parser_py/_resources.py +157 -0
- php_parser_py/_runner.py +239 -0
- php_parser_py/exceptions.py +44 -0
- php_parser_py/resources/php-parser-4.19.4.zip +0 -0
- php_parser_py/vendor/.extracted +1 -0
- php_parser_py/vendor/LICENSE.txt +29 -0
- php_parser_py/vendor/php-parser.phar +0 -0
- php_parser_py-0.1.1.dist-info/METADATA +190 -0
- php_parser_py-0.1.1.dist-info/RECORD +16 -0
- php_parser_py-0.1.1.dist-info/WHEEL +4 -0
php_parser_py/_parser.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Parser class for PHP code parsing."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from cpg2py import Storage
|
|
9
|
+
|
|
10
|
+
from php_parser_py._ast import AST
|
|
11
|
+
from php_parser_py._runner import Runner
|
|
12
|
+
from php_parser_py.exceptions import ParseError, RunnerError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Parser:
|
|
18
|
+
"""Parses PHP source code using PHP-Parser.
|
|
19
|
+
|
|
20
|
+
Invokes PHP-Parser to parse PHP code and converts the resulting JSON
|
|
21
|
+
to cpg2py Storage format for graph-based analysis.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
_runner: Runner instance for PHP-Parser invocation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
php_binary_path: Optional[Path] = None,
|
|
30
|
+
php_binary_url: Optional[str] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize Parser with Runner.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
php_binary_path: Optional path to local PHP binary.
|
|
36
|
+
php_binary_url: Optional URL to download PHP binary from.
|
|
37
|
+
"""
|
|
38
|
+
self._runner = Runner(
|
|
39
|
+
php_binary_path=php_binary_path,
|
|
40
|
+
php_binary_url=php_binary_url,
|
|
41
|
+
)
|
|
42
|
+
self._node_counter = 0
|
|
43
|
+
|
|
44
|
+
def parse(self, code: str, prefix: str = "") -> AST:
|
|
45
|
+
"""Parse PHP code into an AST.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
code: PHP source code to parse.
|
|
49
|
+
prefix: Optional prefix for node IDs. Useful for distinguishing
|
|
50
|
+
nodes from different files when merging multiple ASTs.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
AST instance containing parsed code.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ParseError: If code has syntax errors.
|
|
57
|
+
RunnerError: If PHP execution fails.
|
|
58
|
+
"""
|
|
59
|
+
# Reset node counter for each parse
|
|
60
|
+
self._node_counter = 0
|
|
61
|
+
self._node_id_prefix = prefix
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
json_data = self._runner.parse(code)
|
|
65
|
+
except RunnerError as e:
|
|
66
|
+
# Check if it's a parse error
|
|
67
|
+
if "Syntax error" in str(e):
|
|
68
|
+
raise ParseError("Syntax error in PHP code", line=1) from e
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
storage = self._json_to_storage(json_data)
|
|
72
|
+
return AST(storage)
|
|
73
|
+
|
|
74
|
+
def parse_file(self, path: str, prefix: str | None = None) -> AST:
|
|
75
|
+
"""Read and parse PHP file.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: File path string.
|
|
79
|
+
prefix: Optional prefix for node IDs. If not provided,
|
|
80
|
+
uses a hash of the file path (first 6 chars).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
AST instance containing parsed code structure.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ParseError: If PHP-Parser reports syntax error.
|
|
87
|
+
FileNotFoundError: If file does not exist.
|
|
88
|
+
"""
|
|
89
|
+
file_path = Path(path)
|
|
90
|
+
if not file_path.exists():
|
|
91
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
92
|
+
|
|
93
|
+
# Use file path hash as default prefix if none provided
|
|
94
|
+
if prefix is None:
|
|
95
|
+
# Generate a short hash from the file path
|
|
96
|
+
path_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:6]
|
|
97
|
+
prefix = path_hash
|
|
98
|
+
|
|
99
|
+
code = file_path.read_text(encoding="utf-8")
|
|
100
|
+
return self.parse(code, prefix=prefix)
|
|
101
|
+
|
|
102
|
+
def _json_to_storage(self, json_data: Any) -> Storage:
|
|
103
|
+
"""Convert PHP-Parser JSON to cpg2py Storage.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
json_data: JSON data from PHP-Parser.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Storage instance containing the AST graph.
|
|
110
|
+
"""
|
|
111
|
+
storage = Storage()
|
|
112
|
+
|
|
113
|
+
# Handle both single node and array of nodes
|
|
114
|
+
if isinstance(json_data, list):
|
|
115
|
+
for item in json_data:
|
|
116
|
+
self._process_node(storage, item, None, None, None)
|
|
117
|
+
else:
|
|
118
|
+
self._process_node(storage, json_data, None, None, None)
|
|
119
|
+
|
|
120
|
+
return storage
|
|
121
|
+
|
|
122
|
+
def _process_node(
|
|
123
|
+
self,
|
|
124
|
+
storage: Storage,
|
|
125
|
+
node_data: Any,
|
|
126
|
+
parent_id: str | None,
|
|
127
|
+
field_name: str | None,
|
|
128
|
+
index: int | None,
|
|
129
|
+
) -> str | None:
|
|
130
|
+
"""Process a single node and its children recursively.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
storage: Storage to populate.
|
|
134
|
+
node_data: JSON node data.
|
|
135
|
+
parent_id: Parent node ID if this is a child.
|
|
136
|
+
field_name: Field name in parent if this is a child.
|
|
137
|
+
index: Array index if this is an array element.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Node ID if a node was created, None otherwise.
|
|
141
|
+
"""
|
|
142
|
+
if node_data is None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
if not isinstance(node_data, dict):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# Only create nodes for objects with nodeType
|
|
149
|
+
if "nodeType" not in node_data:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Generate unique node ID
|
|
153
|
+
node_id = f"node_{self._node_counter}"
|
|
154
|
+
self._node_counter += 1
|
|
155
|
+
|
|
156
|
+
# Collect node properties
|
|
157
|
+
properties: dict[str, Any] = {}
|
|
158
|
+
child_fields: list[tuple[str, Any]] = []
|
|
159
|
+
|
|
160
|
+
for key, value in node_data.items():
|
|
161
|
+
if key == "attributes":
|
|
162
|
+
# Flatten attributes to node properties
|
|
163
|
+
if isinstance(value, dict):
|
|
164
|
+
properties.update(value)
|
|
165
|
+
elif isinstance(value, dict):
|
|
166
|
+
# Nested object - will create child node
|
|
167
|
+
child_fields.append((key, value))
|
|
168
|
+
elif isinstance(value, list):
|
|
169
|
+
# Check if it's an array of objects or scalars
|
|
170
|
+
if value and isinstance(value[0], dict):
|
|
171
|
+
# Array of objects - create child nodes
|
|
172
|
+
child_fields.append((key, value))
|
|
173
|
+
else:
|
|
174
|
+
# Array of scalars (strings, numbers, etc.) - save as property
|
|
175
|
+
properties[key] = value
|
|
176
|
+
else:
|
|
177
|
+
# Scalar property
|
|
178
|
+
properties[key] = value
|
|
179
|
+
|
|
180
|
+
# Add node to storage (cpg2py API: add_node takes only nid)
|
|
181
|
+
storage.add_node(node_id)
|
|
182
|
+
# Set properties separately
|
|
183
|
+
if properties:
|
|
184
|
+
storage.set_node_props(node_id, properties)
|
|
185
|
+
|
|
186
|
+
# Create edge from parent if applicable
|
|
187
|
+
if parent_id is not None and field_name is not None:
|
|
188
|
+
edge_props = {"field": field_name}
|
|
189
|
+
if index is not None:
|
|
190
|
+
edge_props["index"] = index
|
|
191
|
+
# add_edge takes edge_id as a tuple
|
|
192
|
+
edge_id = (parent_id, node_id, "PARENT_OF")
|
|
193
|
+
storage.add_edge(edge_id)
|
|
194
|
+
if edge_props:
|
|
195
|
+
storage.set_edge_props(edge_id, edge_props)
|
|
196
|
+
|
|
197
|
+
# Process child fields (only object arrays now)
|
|
198
|
+
for field_name, field_value in child_fields:
|
|
199
|
+
if isinstance(field_value, list):
|
|
200
|
+
# Array of child objects
|
|
201
|
+
for idx, item in enumerate(field_value):
|
|
202
|
+
self._process_node(storage, item, node_id, field_name, idx)
|
|
203
|
+
else:
|
|
204
|
+
# Single child object
|
|
205
|
+
self._process_node(storage, field_value, node_id, field_name, None)
|
|
206
|
+
|
|
207
|
+
return node_id
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""PrettyPrinter class for PHP code generation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from php_parser_py._ast import AST
|
|
8
|
+
from php_parser_py._runner import Runner
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PrettyPrinter:
|
|
14
|
+
"""Converts AST to PHP code using PHP-Parser's PrettyPrinter.
|
|
15
|
+
|
|
16
|
+
Reconstructs JSON from AST graph and invokes PHP-Parser to generate
|
|
17
|
+
formatted PHP source code.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
_runner: Runner instance for PHP-Parser invocation.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
php_binary_path: Optional[Path] = None,
|
|
26
|
+
php_binary_url: Optional[str] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize PrettyPrinter with Runner.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
php_binary_path: Optional path to local PHP binary.
|
|
32
|
+
php_binary_url: Optional URL to download PHP binary from.
|
|
33
|
+
"""
|
|
34
|
+
self._runner = Runner(
|
|
35
|
+
php_binary_path=php_binary_path,
|
|
36
|
+
php_binary_url=php_binary_url,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def print(self, ast: AST) -> str:
|
|
40
|
+
"""Generate PHP code from AST.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
ast: AST instance to convert to code.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Generated PHP source code string.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
RunnerError: If PHP-Parser execution fails.
|
|
50
|
+
"""
|
|
51
|
+
json_str = ast.to_json()
|
|
52
|
+
return self._runner.print(json_str)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource management for php-parser-py.
|
|
3
|
+
|
|
4
|
+
Handles extraction of bundled PHP-Parser zip file on first import.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import threading
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
# Global lock for thread-safe extraction
|
|
14
|
+
_extraction_lock = threading.Lock()
|
|
15
|
+
_extraction_done = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_vendor_path() -> Path:
|
|
19
|
+
"""Get the path where PHP-Parser should be extracted."""
|
|
20
|
+
return Path(__file__).parent / "vendor"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_resources_path() -> Path:
|
|
24
|
+
"""Get the path to the resources directory."""
|
|
25
|
+
# Resources are inside the package directory
|
|
26
|
+
return Path(__file__).parent / "resources"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_marker_file() -> Path:
|
|
30
|
+
"""Get the path to the extraction marker file."""
|
|
31
|
+
return get_vendor_path() / ".extracted"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def calculate_zip_hash(zip_path: Path) -> str:
|
|
35
|
+
"""Calculate SHA256 hash of the zip file."""
|
|
36
|
+
sha256_hash = hashlib.sha256()
|
|
37
|
+
with open(zip_path, "rb") as f:
|
|
38
|
+
for byte_block in iter(lambda: f.read(4096), b""):
|
|
39
|
+
sha256_hash.update(byte_block)
|
|
40
|
+
return sha256_hash.hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_already_extracted(zip_path: Path) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Check if PHP-Parser has already been extracted.
|
|
46
|
+
|
|
47
|
+
Returns True if the marker file exists and the zip hash matches.
|
|
48
|
+
"""
|
|
49
|
+
marker_file = get_marker_file()
|
|
50
|
+
if not marker_file.exists():
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
# Check if hash matches
|
|
54
|
+
try:
|
|
55
|
+
stored_hash = marker_file.read_text().strip()
|
|
56
|
+
current_hash = calculate_zip_hash(zip_path)
|
|
57
|
+
return stored_hash == current_hash
|
|
58
|
+
except Exception:
|
|
59
|
+
# If we can't read the marker or calculate hash, re-extract
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_php_parser(zip_path: Path, vendor_path: Path) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Extract PHP-Parser zip file to vendor directory.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
zip_path: Path to the php-parser zip file
|
|
69
|
+
vendor_path: Path where files should be extracted
|
|
70
|
+
"""
|
|
71
|
+
# Create vendor directory if it doesn't exist
|
|
72
|
+
vendor_path.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
# Extract the zip file
|
|
75
|
+
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
|
76
|
+
zip_ref.extractall(vendor_path)
|
|
77
|
+
|
|
78
|
+
# Create marker file with zip hash
|
|
79
|
+
zip_hash = calculate_zip_hash(zip_path)
|
|
80
|
+
marker_file = get_marker_file()
|
|
81
|
+
marker_file.write_text(zip_hash)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_php_parser_extracted() -> Path:
|
|
85
|
+
"""
|
|
86
|
+
Ensure PHP-Parser is extracted and ready to use.
|
|
87
|
+
|
|
88
|
+
This function is called on package import. It checks if PHP-Parser
|
|
89
|
+
has already been extracted, and if not, extracts it in a thread-safe manner.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Path to the vendor directory containing PHP-Parser
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
FileNotFoundError: If the php-parser zip file is not found
|
|
96
|
+
RuntimeError: If extraction fails
|
|
97
|
+
"""
|
|
98
|
+
global _extraction_done
|
|
99
|
+
|
|
100
|
+
# Fast path: already extracted in this process
|
|
101
|
+
if _extraction_done:
|
|
102
|
+
return get_vendor_path()
|
|
103
|
+
|
|
104
|
+
vendor_path = get_vendor_path()
|
|
105
|
+
resources_path = get_resources_path()
|
|
106
|
+
|
|
107
|
+
# Find the php-parser zip file
|
|
108
|
+
zip_files = list(resources_path.glob("php-parser-*.zip"))
|
|
109
|
+
if not zip_files:
|
|
110
|
+
raise FileNotFoundError(
|
|
111
|
+
f"PHP-Parser zip file not found in {resources_path}. "
|
|
112
|
+
f"Expected a file matching pattern 'php-parser-*.zip'"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
zip_path = zip_files[0]
|
|
116
|
+
|
|
117
|
+
# Check if already extracted (with marker file)
|
|
118
|
+
if is_already_extracted(zip_path):
|
|
119
|
+
_extraction_done = True
|
|
120
|
+
return vendor_path
|
|
121
|
+
|
|
122
|
+
# Thread-safe extraction
|
|
123
|
+
with _extraction_lock:
|
|
124
|
+
# Double-check after acquiring lock (another thread might have extracted)
|
|
125
|
+
if is_already_extracted(zip_path):
|
|
126
|
+
_extraction_done = True
|
|
127
|
+
return vendor_path
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Extract PHP-Parser
|
|
131
|
+
extract_php_parser(zip_path, vendor_path)
|
|
132
|
+
_extraction_done = True
|
|
133
|
+
return vendor_path
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"Failed to extract PHP-Parser from {zip_path}: {e}"
|
|
138
|
+
) from e
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_php_parser_path() -> Optional[Path]:
|
|
142
|
+
"""
|
|
143
|
+
Get the path to the extracted PHP-Parser directory.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Path to PHP-Parser, or None if not yet extracted
|
|
147
|
+
"""
|
|
148
|
+
vendor_path = get_vendor_path()
|
|
149
|
+
if not vendor_path.exists():
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Look for the php-parser directory inside vendor
|
|
153
|
+
php_parser_dirs = list(vendor_path.glob("php-parser-*"))
|
|
154
|
+
if php_parser_dirs:
|
|
155
|
+
return php_parser_dirs[0]
|
|
156
|
+
|
|
157
|
+
return None
|
php_parser_py/_runner.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Runner class for PHP-Parser invocation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from static_php_py import PHP
|
|
10
|
+
from static_php_py.exceptions import BinaryNotFoundError, DownloadError
|
|
11
|
+
|
|
12
|
+
from php_parser_py._resources import ensure_php_parser_extracted
|
|
13
|
+
from php_parser_py.exceptions import ParseError, RunnerError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Runner:
|
|
19
|
+
"""Manages PHP-Parser invocation via PHP binary.
|
|
20
|
+
|
|
21
|
+
Handles execution of PHP scripts for parsing and code generation,
|
|
22
|
+
communicating with PHP-Parser through subprocess stdin/stdout.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
_php_binary: Path to PHP binary.
|
|
26
|
+
_vendor_dir: Path to directory containing PHP-Parser PHAR.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
php_binary_path: Optional[Path] = None,
|
|
32
|
+
php_binary_url: Optional[str] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initialize Runner with PHP binary and PHP-Parser paths.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
php_binary_path: Optional path to local PHP binary.
|
|
38
|
+
php_binary_url: Optional URL to download PHP binary from.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
RunnerError: If PHP binary or PHP-Parser cannot be located.
|
|
42
|
+
"""
|
|
43
|
+
self._vendor_dir = ensure_php_parser_extracted()
|
|
44
|
+
|
|
45
|
+
# Determine PHP binary source with priority: custom path > custom URL > built-in
|
|
46
|
+
try:
|
|
47
|
+
if php_binary_path is not None:
|
|
48
|
+
# Use custom local PHP binary
|
|
49
|
+
php = PHP.local(php_binary_path)
|
|
50
|
+
elif php_binary_url is not None:
|
|
51
|
+
# Download PHP binary from custom URL
|
|
52
|
+
php = PHP.remote(php_binary_url)
|
|
53
|
+
else:
|
|
54
|
+
# Use built-in PHP binary from static-php-py
|
|
55
|
+
php = PHP.builtin()
|
|
56
|
+
|
|
57
|
+
self._php_binary = php.path()
|
|
58
|
+
|
|
59
|
+
except BinaryNotFoundError as e:
|
|
60
|
+
raise RunnerError(
|
|
61
|
+
f"PHP binary not found: {e}. "
|
|
62
|
+
"The built-in PHP binary may not be available for your platform. "
|
|
63
|
+
"Please provide a custom PHP binary path or URL.",
|
|
64
|
+
exit_code=1,
|
|
65
|
+
) from e
|
|
66
|
+
except DownloadError as e:
|
|
67
|
+
raise RunnerError(
|
|
68
|
+
f"Failed to download PHP binary: {e}",
|
|
69
|
+
exit_code=1,
|
|
70
|
+
) from e
|
|
71
|
+
|
|
72
|
+
if not self._php_binary.exists():
|
|
73
|
+
raise RunnerError(
|
|
74
|
+
f"PHP binary not found at {self._php_binary}", exit_code=1
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def execute(self, script: str, stdin: str = "") -> str:
|
|
78
|
+
"""Execute PHP script with optional stdin.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
script: PHP script code to execute.
|
|
82
|
+
stdin: Optional input to pass to script's stdin.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Script's stdout output.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
RunnerError: If PHP execution fails.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
[str(self._php_binary), "-r", script],
|
|
93
|
+
input=stdin,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
# Log complete error information
|
|
101
|
+
error_msg = f"PHP execution failed with exit code {result.returncode}"
|
|
102
|
+
if result.stderr:
|
|
103
|
+
logger.error(f"PHP stderr: {result.stderr}")
|
|
104
|
+
error_msg += f"\nStderr: {result.stderr}"
|
|
105
|
+
if result.stdout:
|
|
106
|
+
logger.error(f"PHP stdout: {result.stdout}")
|
|
107
|
+
error_msg += f"\nStdout: {result.stdout}"
|
|
108
|
+
|
|
109
|
+
raise RunnerError(
|
|
110
|
+
error_msg,
|
|
111
|
+
stderr=result.stderr,
|
|
112
|
+
exit_code=result.returncode,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return result.stdout
|
|
116
|
+
|
|
117
|
+
except FileNotFoundError as e:
|
|
118
|
+
error_msg = f"PHP binary not found: {self._php_binary}"
|
|
119
|
+
logger.error(error_msg)
|
|
120
|
+
raise RunnerError(
|
|
121
|
+
error_msg, stderr=str(e), exit_code=1
|
|
122
|
+
) from e
|
|
123
|
+
except Exception as e:
|
|
124
|
+
error_msg = f"Failed to execute PHP script: {e}"
|
|
125
|
+
logger.error(error_msg)
|
|
126
|
+
# Don't wrap the error if it's already a RunnerError
|
|
127
|
+
if isinstance(e, RunnerError):
|
|
128
|
+
raise
|
|
129
|
+
raise RunnerError(
|
|
130
|
+
error_msg, stderr=str(e), exit_code=1
|
|
131
|
+
) from e
|
|
132
|
+
|
|
133
|
+
def parse(self, code: str) -> dict[str, Any]:
|
|
134
|
+
"""Invoke PHP-Parser parse + JsonSerializer.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
code: PHP source code to parse.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Parsed JSON as dict.
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ParseError: If PHP-Parser reports syntax error.
|
|
144
|
+
RunnerError: If PHP execution fails.
|
|
145
|
+
"""
|
|
146
|
+
parse_script = self._build_parse_script()
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
output = self.execute(parse_script, code)
|
|
150
|
+
result = json.loads(output)
|
|
151
|
+
|
|
152
|
+
# Check for parse errors
|
|
153
|
+
if isinstance(result, dict) and "errors" in result:
|
|
154
|
+
errors = result["errors"]
|
|
155
|
+
if errors:
|
|
156
|
+
first_error = errors[0]
|
|
157
|
+
raise ParseError(
|
|
158
|
+
first_error.get("message", "Unknown parse error"),
|
|
159
|
+
first_error.get("line"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
except json.JSONDecodeError as e:
|
|
165
|
+
raise RunnerError(
|
|
166
|
+
f"Failed to decode PHP-Parser JSON output: {e}",
|
|
167
|
+
stderr=str(e),
|
|
168
|
+
exit_code=1,
|
|
169
|
+
) from e
|
|
170
|
+
|
|
171
|
+
def print(self, ast_json: str) -> str:
|
|
172
|
+
"""Invoke PHP-Parser JsonDecoder + PrettyPrinter.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
ast_json: AST JSON string from PHP-Parser format.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Generated PHP source code.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
RunnerError: If PHP execution fails.
|
|
182
|
+
"""
|
|
183
|
+
print_script = self._build_print_script()
|
|
184
|
+
return self.execute(print_script, ast_json)
|
|
185
|
+
|
|
186
|
+
def _build_parse_script(self) -> str:
|
|
187
|
+
"""Build PHP script for parsing."""
|
|
188
|
+
phar_path = self._vendor_dir / "php-parser.phar"
|
|
189
|
+
return f"""
|
|
190
|
+
error_reporting(E_ALL & ~E_DEPRECATED);
|
|
191
|
+
require_once 'phar://{phar_path}/vendor/autoload.php';
|
|
192
|
+
|
|
193
|
+
use PhpParser\\ParserFactory;
|
|
194
|
+
use PhpParser\\ErrorHandler\\Collecting;
|
|
195
|
+
|
|
196
|
+
$code = file_get_contents('php://stdin');
|
|
197
|
+
$errorHandler = new Collecting();
|
|
198
|
+
$parser = (new ParserFactory())->createForNewestSupportedVersion();
|
|
199
|
+
|
|
200
|
+
try {{
|
|
201
|
+
$stmts = $parser->parse($code, $errorHandler);
|
|
202
|
+
if ($errorHandler->hasErrors()) {{
|
|
203
|
+
$errors = array_map(fn($e) => [
|
|
204
|
+
'message' => $e->getMessage(),
|
|
205
|
+
'line' => $e->getStartLine()
|
|
206
|
+
], $errorHandler->getErrors());
|
|
207
|
+
echo json_encode(['errors' => $errors]);
|
|
208
|
+
exit(1);
|
|
209
|
+
}}
|
|
210
|
+
// PHP-Parser nodes implement JsonSerializable, so we can encode them directly
|
|
211
|
+
echo json_encode($stmts);
|
|
212
|
+
}} catch (Exception $e) {{
|
|
213
|
+
echo json_encode(['error' => $e->getMessage()]);
|
|
214
|
+
exit(1);
|
|
215
|
+
}}
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def _build_print_script(self) -> str:
|
|
219
|
+
"""Build PHP script for code generation."""
|
|
220
|
+
phar_path = self._vendor_dir / "php-parser.phar"
|
|
221
|
+
return f"""
|
|
222
|
+
error_reporting(E_ALL & ~E_DEPRECATED);
|
|
223
|
+
require_once 'phar://{phar_path}/vendor/autoload.php';
|
|
224
|
+
|
|
225
|
+
use PhpParser\\JsonDecoder;
|
|
226
|
+
use PhpParser\\PrettyPrinter\\Standard;
|
|
227
|
+
|
|
228
|
+
$json = file_get_contents('php://stdin');
|
|
229
|
+
|
|
230
|
+
try {{
|
|
231
|
+
$decoder = new JsonDecoder();
|
|
232
|
+
$stmts = $decoder->decode($json);
|
|
233
|
+
$printer = new Standard();
|
|
234
|
+
echo $printer->prettyPrintFile($stmts);
|
|
235
|
+
}} catch (Exception $e) {{
|
|
236
|
+
echo json_encode(['error' => $e->getMessage()]);
|
|
237
|
+
exit(1);
|
|
238
|
+
}}
|
|
239
|
+
"""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Exception classes for php-parser-py."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ParseError(Exception):
|
|
5
|
+
"""Raised when PHP-Parser reports a syntax error.
|
|
6
|
+
|
|
7
|
+
Attributes:
|
|
8
|
+
message: Error message from PHP-Parser.
|
|
9
|
+
line: Line number where the error occurred, if available.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, line: int | None = None) -> None:
|
|
13
|
+
"""Initialize ParseError.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message: Error message from PHP-Parser.
|
|
17
|
+
line: Line number where the error occurred, if available.
|
|
18
|
+
"""
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.message = message
|
|
21
|
+
self.line = line
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RunnerError(Exception):
|
|
25
|
+
"""Raised when PHP process execution fails.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
message: Error message describing the failure.
|
|
29
|
+
stderr: Standard error output from the PHP process.
|
|
30
|
+
exit_code: Exit code returned by the PHP process.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, stderr: str = "", exit_code: int = 1) -> None:
|
|
34
|
+
"""Initialize RunnerError.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
message: Error message describing the failure.
|
|
38
|
+
stderr: Standard error output from the PHP process.
|
|
39
|
+
exit_code: Exit code returned by the PHP process.
|
|
40
|
+
"""
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.message = message
|
|
43
|
+
self.stderr = stderr
|
|
44
|
+
self.exit_code = exit_code
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
343eb3b32948b6de28c0ca109eafb789dfd270c01711f5721f3cd18b4c0ab614
|