php-parser-py 1.2.4__py3-none-win_amd64.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 +33 -0
- php_parser_py/_exceptions.py +58 -0
- php_parser_py/_graph/__init__.py +19 -0
- php_parser_py/_graph/_ast.py +227 -0
- php_parser_py/_graph/_edge.py +102 -0
- php_parser_py/_graph/_modifier.py +143 -0
- php_parser_py/_graph/_node.py +256 -0
- php_parser_py/_graph/_node_id.py +56 -0
- php_parser_py/_mapping/__init__.py +14 -0
- php_parser_py/_mapping/_build.py +227 -0
- php_parser_py/_mapping/_contract.py +71 -0
- php_parser_py/_mapping/_serialize.py +220 -0
- php_parser_py/_parser.py +311 -0
- php_parser_py/_printer.py +147 -0
- php_parser_py/_resources/ExtendedPrinter.php +212 -0
- php_parser_py/_resources/parse.php +112 -0
- php_parser_py/_resources/php +0 -0
- php_parser_py/_resources/php-parser-5.7.0.tar.gz +0 -0
- php_parser_py/_resources/php.exe +0 -0
- php_parser_py/_resources/print.php +150 -0
- php_parser_py/_resources/transport.php +195 -0
- php_parser_py/_runtime/__init__.py +11 -0
- php_parser_py/_runtime/_binary.py +113 -0
- php_parser_py/_runtime/_bundle.py +164 -0
- php_parser_py/_runtime/_runner.py +258 -0
- php_parser_py/_transport.py +104 -0
- php_parser_py/_types.py +87 -0
- php_parser_py/py.typed +0 -0
- php_parser_py-1.2.4.dist-info/METADATA +100 -0
- php_parser_py-1.2.4.dist-info/RECORD +32 -0
- php_parser_py-1.2.4.dist-info/WHEEL +5 -0
- php_parser_py-1.2.4.dist-info/licenses/LICENSE +339 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Backward direction: AST graph -> PHP-Parser JSON.
|
|
2
|
+
|
|
3
|
+
Stateless reconstruction behind the module-level ``serialize`` entry point,
|
|
4
|
+
the backward public surface of the mapping package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .._graph import AST, Edge
|
|
13
|
+
from .._transport import encode_values
|
|
14
|
+
from .._types import PARENT_OF_EDGE
|
|
15
|
+
from ._contract import ATTRIBUTE_KEYS_PROPERTY, LIST_LENGTHS_PROPERTY
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parent_of(edge: Edge) -> bool:
|
|
19
|
+
# succ() follows every edge type; reconstruction walks only the tree,
|
|
20
|
+
# so caller-added analysis edges (DATA_FLOW, ...) must not surface
|
|
21
|
+
# children twice or drag in nodes that are not tree children.
|
|
22
|
+
return edge.type == PARENT_OF_EDGE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _edge_index(edge: Edge) -> int | None:
|
|
26
|
+
# A bool or negative index would silently misplace or overwrite a
|
|
27
|
+
# sibling in _fill_arrays; any other non-int breaks ordering. One
|
|
28
|
+
# validation point for both _file_statements and _reconstruct_children.
|
|
29
|
+
index = edge.get("index")
|
|
30
|
+
if index is None:
|
|
31
|
+
return None
|
|
32
|
+
if isinstance(index, int) and not isinstance(index, bool) and index >= 0:
|
|
33
|
+
return index
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Edge index must be a non-negative int, got {index!r} on edge "
|
|
36
|
+
f"{edge.from_nid!r} -> {edge.to_nid!r}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def serialize(ast: AST, file_hash: str | None = None) -> str:
|
|
41
|
+
"""Reconstruct PHP-Parser JSON from an ``AST``.
|
|
42
|
+
|
|
43
|
+
Backward entry of the mapping package; inverse of ``build``.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
ast: Populated AST to export.
|
|
47
|
+
file_hash: When set, exports only that file's statements; otherwise
|
|
48
|
+
exports all files, or the code root's statements when no file
|
|
49
|
+
structure exists.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
JSON string compatible with PHP-Parser's JsonDecoder.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
KeyError: If ``file_hash`` — or, lacking file structure, the code
|
|
56
|
+
root node — is not in ``ast``.
|
|
57
|
+
ValueError: If two children of one node occupy the same
|
|
58
|
+
(field, index) slot, or PARENT_OF edges do not form a tree
|
|
59
|
+
(cycle or shared subtree).
|
|
60
|
+
"""
|
|
61
|
+
top_level = _top_level_ids(ast, file_hash)
|
|
62
|
+
result = [_reconstruct_node(ast, nid) for nid in top_level]
|
|
63
|
+
# Marker-wrap surrogate-bearing strings and non-finite floats:
|
|
64
|
+
# json.dumps would emit lone-surrogate escapes or Infinity/NaN tokens
|
|
65
|
+
# that PHP's json_decode rejects. allow_nan=False turns any value the
|
|
66
|
+
# codec missed into a loud error instead of invalid JSON.
|
|
67
|
+
return json.dumps(encode_values(result), allow_nan=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _top_level_ids(ast: AST, file_hash: str | None) -> list[str]:
|
|
71
|
+
# Resolve the root node IDs to export. Top-level membership is always
|
|
72
|
+
# an explicit stmts edge from an anchor (file node or code root);
|
|
73
|
+
# subtrees detached by remove_node are never exported.
|
|
74
|
+
if file_hash:
|
|
75
|
+
return statement_ids(ast, file_hash)
|
|
76
|
+
file_nodes = ast.file_nodes()
|
|
77
|
+
if file_nodes:
|
|
78
|
+
collected: list[str] = []
|
|
79
|
+
for file_node in file_nodes:
|
|
80
|
+
collected.extend(statement_ids(ast, file_node.id))
|
|
81
|
+
return collected
|
|
82
|
+
return statement_ids(ast, ast.project_root_id)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def statement_ids(ast: AST, parent_id: str) -> list[str]:
|
|
86
|
+
"""Top-level statement IDs anchored under a file or code-root node.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
ast: Graph to query.
|
|
90
|
+
parent_id: File node ID, or the code root ID for code-only ASTs.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Targets of the anchor's ``stmts`` PARENT_OF edges, ordered by
|
|
94
|
+
edge index.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
KeyError: If ``parent_id`` is not in ``ast``.
|
|
98
|
+
ValueError: If an edge carries an invalid index.
|
|
99
|
+
"""
|
|
100
|
+
anchor = ast.node(parent_id)
|
|
101
|
+
ordered: list[tuple[tuple[int, int], str]] = []
|
|
102
|
+
for child in ast.succ(anchor, _parent_of):
|
|
103
|
+
edge = ast.edge(parent_id, child.id, PARENT_OF_EDGE)
|
|
104
|
+
if edge.get("field") != "stmts":
|
|
105
|
+
continue
|
|
106
|
+
ordered.append((_sort_key(_edge_index(edge)), child.id))
|
|
107
|
+
ordered.sort(key=lambda pair: pair[0])
|
|
108
|
+
return [nid for _, nid in ordered]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _reconstruct_node(ast: AST, nid: str) -> dict[str, Any]:
|
|
112
|
+
# Rebuild one node's JSON subtree, inverse of _process_node.
|
|
113
|
+
# Explicit-stack DFS: recursion would cap the AST depth at a fraction
|
|
114
|
+
# of the interpreter stack, failing trees json.dumps itself handles.
|
|
115
|
+
root = _node_result(ast, nid)
|
|
116
|
+
visited = {nid}
|
|
117
|
+
stack: list[tuple[str, dict[str, Any]]] = [(nid, root)]
|
|
118
|
+
while stack:
|
|
119
|
+
current_id, result = stack.pop()
|
|
120
|
+
for child_id, child_json in _reconstruct_children(ast, result, current_id):
|
|
121
|
+
# A node reached twice means a PARENT_OF cycle or a shared
|
|
122
|
+
# subtree; without this guard a cycle would loop the walk
|
|
123
|
+
# forever.
|
|
124
|
+
if child_id in visited:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Node {child_id!r} reached twice via {current_id!r}; "
|
|
127
|
+
"PARENT_OF edges must form a tree"
|
|
128
|
+
)
|
|
129
|
+
visited.add(child_id)
|
|
130
|
+
stack.append((child_id, child_json))
|
|
131
|
+
return root
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _node_result(ast: AST, nid: str) -> dict[str, Any]:
|
|
135
|
+
# Rebuild one node's own JSON object, children left for the caller.
|
|
136
|
+
node = ast.node(nid)
|
|
137
|
+
props = node.all_properties
|
|
138
|
+
attr_keys: list[str] = props.get(ATTRIBUTE_KEYS_PROPERTY, [])
|
|
139
|
+
result: dict[str, Any] = {"nodeType": node.node_type}
|
|
140
|
+
result["attributes"] = {k: props[k] for k in attr_keys}
|
|
141
|
+
_add_scalar_props(result, props, attr_keys)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _add_scalar_props(
|
|
146
|
+
result: dict[str, Any], props: dict[str, Any], attr_keys: list[str]
|
|
147
|
+
) -> None:
|
|
148
|
+
# Copy non-attribute scalar properties onto the result. The list-length
|
|
149
|
+
# and attribute-key records are build bookkeeping, consumed by
|
|
150
|
+
# _fill_arrays and _reconstruct_node instead.
|
|
151
|
+
skip = set(attr_keys) | {"nodeType", LIST_LENGTHS_PROPERTY, ATTRIBUTE_KEYS_PROPERTY}
|
|
152
|
+
for key, value in props.items():
|
|
153
|
+
if key not in skip:
|
|
154
|
+
result[key] = value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _reconstruct_children(
|
|
158
|
+
ast: AST, result: dict[str, Any], nid: str
|
|
159
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
160
|
+
# Reassemble subnode fields and ordered arrays from PARENT_OF edges,
|
|
161
|
+
# returning (child id, child result) pairs for the caller to descend.
|
|
162
|
+
node = ast.node(nid)
|
|
163
|
+
arrays: dict[str, dict[int, Any]] = {}
|
|
164
|
+
seen: set[tuple[str, int | None]] = set()
|
|
165
|
+
descend: list[tuple[str, dict[str, Any]]] = []
|
|
166
|
+
for child in ast.succ(node, _parent_of):
|
|
167
|
+
edge = ast.edge(nid, child.id, PARENT_OF_EDGE)
|
|
168
|
+
field_name = edge.get("field")
|
|
169
|
+
if field_name is None:
|
|
170
|
+
continue
|
|
171
|
+
index = _edge_index(edge)
|
|
172
|
+
# Two children on one slot means the graph is corrupt; a silent
|
|
173
|
+
# overwrite would drop whichever node iterates first.
|
|
174
|
+
if (field_name, index) in seen:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Duplicate child for field {field_name!r} index {index} on node {nid}"
|
|
177
|
+
)
|
|
178
|
+
seen.add((field_name, index))
|
|
179
|
+
child_json = _node_result(ast, child.id)
|
|
180
|
+
descend.append((child.id, child_json))
|
|
181
|
+
if index is None:
|
|
182
|
+
result[field_name] = child_json
|
|
183
|
+
else:
|
|
184
|
+
arrays.setdefault(field_name, {})[index] = child_json
|
|
185
|
+
_fill_arrays(result, arrays, node.all_properties.get(LIST_LENGTHS_PROPERTY, {}))
|
|
186
|
+
_add_default_attrs(result, node.node_type)
|
|
187
|
+
return descend
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _add_default_attrs(result: dict[str, Any], node_type: str) -> None:
|
|
191
|
+
# PHP-Parser expects attrGroups on certain node types.
|
|
192
|
+
if "attrGroups" in result:
|
|
193
|
+
return
|
|
194
|
+
prefixes = ("Stmt_", "Expr_Closure", "Expr_ArrowFunction")
|
|
195
|
+
if node_type == "Param" or any(node_type.startswith(p) for p in prefixes):
|
|
196
|
+
result["attrGroups"] = []
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _sort_key(index: int | None) -> tuple[int, int]:
|
|
200
|
+
# Single-object subnodes (index None) sort after indexed ones.
|
|
201
|
+
if index is None:
|
|
202
|
+
return (1, 0)
|
|
203
|
+
return (0, index)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _fill_arrays(
|
|
207
|
+
result: dict[str, Any],
|
|
208
|
+
arrays: dict[str, dict[int, Any]],
|
|
209
|
+
lengths: dict[str, int],
|
|
210
|
+
) -> None:
|
|
211
|
+
# Expand indexed child maps into dense ordered lists; `lengths`
|
|
212
|
+
# restores trailing null items, which carry no child edge.
|
|
213
|
+
for field_name, indexed in arrays.items():
|
|
214
|
+
if not indexed:
|
|
215
|
+
continue
|
|
216
|
+
length = max(max(indexed) + 1, lengths.get(field_name, 0))
|
|
217
|
+
ordered: list[Any] = [None] * length
|
|
218
|
+
for idx, child in indexed.items():
|
|
219
|
+
ordered[idx] = child
|
|
220
|
+
result[field_name] = ordered
|
php_parser_py/_parser.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Parsing entry points: Parser class and module-level parse functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from static_php_py import PHP
|
|
12
|
+
|
|
13
|
+
from ._exceptions import ParseError, RunnerError
|
|
14
|
+
from ._graph import AST, Node
|
|
15
|
+
from ._mapping import build, statement_ids
|
|
16
|
+
from ._runtime import Runner
|
|
17
|
+
from ._types import FileRecord, ProjectLayout
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Suffixes that conventionally carry PHP source: the canonical .php,
|
|
23
|
+
# template and legacy interpreter registrations (.phtml, .php3-.php7),
|
|
24
|
+
# and include-style sources used by legacy and Drupal projects. A
|
|
25
|
+
# non-PHP match costs one skip warning; a missing suffix silently
|
|
26
|
+
# drops source files, so the set leans inclusive.
|
|
27
|
+
_PHP_SUFFIXES = frozenset(
|
|
28
|
+
{
|
|
29
|
+
".php",
|
|
30
|
+
".phtml",
|
|
31
|
+
".php3",
|
|
32
|
+
".php4",
|
|
33
|
+
".php5",
|
|
34
|
+
".php7",
|
|
35
|
+
".inc",
|
|
36
|
+
".module",
|
|
37
|
+
".install",
|
|
38
|
+
".theme",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_file_filter(path: Path) -> bool:
|
|
44
|
+
# Default project filter: PHP source files by suffix.
|
|
45
|
+
return path.suffix.lower() in _PHP_SUFFIXES
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_encoding(encoding: str | None) -> None:
|
|
49
|
+
# Fail on an unknown codec name before any file is read.
|
|
50
|
+
if encoding is not None:
|
|
51
|
+
codecs.lookup(encoding)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _file_id(path: Path) -> str:
|
|
55
|
+
# 64-bit path-derived file node ID. SHA-256 stays available on
|
|
56
|
+
# FIPS-restricted builds where MD5 raises; surrogateescape keeps
|
|
57
|
+
# undecodable filesystem bytes hashable.
|
|
58
|
+
raw = str(path).encode("utf-8", "surrogateescape")
|
|
59
|
+
return hashlib.sha256(raw).hexdigest()[:16]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Parser:
|
|
63
|
+
"""Parses PHP source code using PHP-Parser.
|
|
64
|
+
|
|
65
|
+
Delegates all JSON->graph construction to ``build``. Parser never calls
|
|
66
|
+
Storage or Modifier directly.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
_runner: Runner instance for PHP-Parser invocation.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
php: PHP | None = None,
|
|
75
|
+
ini_settings: Mapping[str, str] | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initialize Parser with Runner.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
php: Optional PHP instance. If not provided, uses bundled PHP.
|
|
81
|
+
ini_settings: Optional PHP ini overrides forwarded to the Runner.
|
|
82
|
+
Default None inherits the binary's environment.
|
|
83
|
+
"""
|
|
84
|
+
self._runner = Runner(php=php, ini_settings=ini_settings)
|
|
85
|
+
|
|
86
|
+
def parse_code(self, code: str) -> list[Node]:
|
|
87
|
+
"""Parse PHP code string into a list of top-level statement nodes.
|
|
88
|
+
|
|
89
|
+
Does not create file nodes; statements are anchored under a
|
|
90
|
+
synthetic code root node. Returns raw statement nodes that can be
|
|
91
|
+
used for analysis or inserted into other AST structures.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
code: PHP source code to parse.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of Node instances representing top-level statements.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ParseError: If code has syntax errors.
|
|
101
|
+
RunnerError: If PHP execution fails.
|
|
102
|
+
"""
|
|
103
|
+
node_list = self._runner.parse(code)
|
|
104
|
+
ast = build(node_list, ProjectLayout(project_path=None))
|
|
105
|
+
return [ast.node(nid) for nid in statement_ids(ast, ast.project_root_id)]
|
|
106
|
+
|
|
107
|
+
def parse_file(self, path: str, encoding: str | None = None) -> AST:
|
|
108
|
+
"""Parse a single PHP file into an AST with project and file nodes.
|
|
109
|
+
|
|
110
|
+
Creates a project node (ID: "project") and a file node (ID: hex
|
|
111
|
+
hash of path). File node contains all statements from the file.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
path: File path string.
|
|
115
|
+
encoding: Optional Python codec name declaring the file's byte
|
|
116
|
+
encoding; the file is transcoded before parsing. Default
|
|
117
|
+
None passes source bytes through unchanged.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
AST instance with project -> file -> statements hierarchy.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ParseError: If PHP-Parser reports syntax error.
|
|
124
|
+
FileNotFoundError: If file does not exist.
|
|
125
|
+
ValueError: If path is not a regular file.
|
|
126
|
+
LookupError: If ``encoding`` names no Python codec.
|
|
127
|
+
UnicodeDecodeError: If the file's bytes do not decode under the
|
|
128
|
+
declared ``encoding``.
|
|
129
|
+
"""
|
|
130
|
+
_validate_encoding(encoding)
|
|
131
|
+
file_path = Path(path).resolve()
|
|
132
|
+
if not file_path.exists():
|
|
133
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
134
|
+
if not file_path.is_file():
|
|
135
|
+
raise ValueError(f"Path is not a file: {path}")
|
|
136
|
+
|
|
137
|
+
record = self._read_file(file_path, encoding)
|
|
138
|
+
layout = ProjectLayout(project_path=file_path.parent, files=[record])
|
|
139
|
+
return build([], layout)
|
|
140
|
+
|
|
141
|
+
def parse_project(
|
|
142
|
+
self,
|
|
143
|
+
project_path: str,
|
|
144
|
+
file_filter: Callable[[Path], bool] = _default_file_filter,
|
|
145
|
+
encoding: str | None = None,
|
|
146
|
+
) -> AST:
|
|
147
|
+
"""Parse all PHP files in a project directory into an AST.
|
|
148
|
+
|
|
149
|
+
Recursively traverses the project directory to find all PHP files,
|
|
150
|
+
then parses them into a single AST with project and file nodes.
|
|
151
|
+
Files that fail to read or to parse are skipped with a warning.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
project_path: Project root directory path.
|
|
155
|
+
file_filter: Function to filter files. Takes a Path and returns
|
|
156
|
+
True if the file should be parsed. Defaults to common PHP
|
|
157
|
+
source suffixes (.php, .phtml, legacy and Drupal-style).
|
|
158
|
+
encoding: Optional Python codec name declaring every file's byte
|
|
159
|
+
encoding; each file is transcoded before parsing. A file that
|
|
160
|
+
does not decode is skipped with a warning. Default None
|
|
161
|
+
passes source bytes through unchanged.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
AST instance with project -> files -> statements hierarchy.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
FileNotFoundError: If project directory does not exist.
|
|
168
|
+
ValueError: If project_path is not a directory, or if two files
|
|
169
|
+
hash to the same file ID.
|
|
170
|
+
LookupError: If ``encoding`` names no Python codec.
|
|
171
|
+
"""
|
|
172
|
+
_validate_encoding(encoding)
|
|
173
|
+
project_path_obj = Path(project_path).resolve()
|
|
174
|
+
|
|
175
|
+
if not project_path_obj.exists():
|
|
176
|
+
raise FileNotFoundError(f"Project directory not found: {project_path}")
|
|
177
|
+
if not project_path_obj.is_dir():
|
|
178
|
+
raise ValueError(f"Project path is not a directory: {project_path}")
|
|
179
|
+
|
|
180
|
+
php_files = [
|
|
181
|
+
f for f in project_path_obj.rglob("*") if f.is_file() and file_filter(f)
|
|
182
|
+
]
|
|
183
|
+
if not php_files:
|
|
184
|
+
logger.warning("No PHP files found in project directory: %s", project_path)
|
|
185
|
+
|
|
186
|
+
records = self._read_files(php_files, encoding)
|
|
187
|
+
layout = ProjectLayout(project_path=project_path_obj, files=records)
|
|
188
|
+
return build([], layout)
|
|
189
|
+
|
|
190
|
+
# -- Internal helpers --
|
|
191
|
+
|
|
192
|
+
def _read_files(self, paths: list[Path], encoding: str | None) -> list[FileRecord]:
|
|
193
|
+
# Read and parse each file, skipping any that fail to read, to
|
|
194
|
+
# parse, or to decode under a declared encoding — one bad file
|
|
195
|
+
# must not abort the whole project. A file-ID collision would
|
|
196
|
+
# corrupt the graph (two files, one node), so it fails loudly.
|
|
197
|
+
records: list[FileRecord] = []
|
|
198
|
+
seen: dict[str, Path] = {}
|
|
199
|
+
for path in paths:
|
|
200
|
+
try:
|
|
201
|
+
record = self._read_file(path, encoding)
|
|
202
|
+
except (ParseError, RunnerError, UnicodeDecodeError, OSError) as exc:
|
|
203
|
+
logger.warning("Skipping unparseable file %s: %s", path, exc)
|
|
204
|
+
continue
|
|
205
|
+
first = seen.setdefault(record.file_hash, path)
|
|
206
|
+
if first != path:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"File ID collision: {first} and {path} both hash to "
|
|
209
|
+
f"{record.file_hash}"
|
|
210
|
+
)
|
|
211
|
+
records.append(record)
|
|
212
|
+
return records
|
|
213
|
+
|
|
214
|
+
def _read_file(self, path: Path, encoding: str | None) -> FileRecord:
|
|
215
|
+
# Parse one file into a FileRecord. Without a declared encoding,
|
|
216
|
+
# surrogateescape keeps non-UTF-8 source bytes intact;
|
|
217
|
+
# errors="replace" would silently corrupt them.
|
|
218
|
+
if encoding is None:
|
|
219
|
+
code = path.read_bytes().decode("utf-8", "surrogateescape")
|
|
220
|
+
else:
|
|
221
|
+
code = path.read_bytes().decode(encoding)
|
|
222
|
+
statements = self._runner.parse(code)
|
|
223
|
+
return FileRecord(path=path, file_hash=_file_id(path), statements=statements)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def parse_code(code: str) -> list[Node]:
|
|
227
|
+
"""Parse PHP code string into a list of top-level statement nodes.
|
|
228
|
+
|
|
229
|
+
Does not create file nodes; statements are anchored under a synthetic
|
|
230
|
+
code root node. Returns raw statement nodes.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
code: PHP source code string.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
List of Node instances representing top-level statements.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ParseError: If PHP-Parser reports syntax error.
|
|
240
|
+
"""
|
|
241
|
+
parser = Parser()
|
|
242
|
+
return parser.parse_code(code)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse_file(path: str, encoding: str | None = None) -> AST:
|
|
246
|
+
"""Read and parse a single PHP file into an AST.
|
|
247
|
+
|
|
248
|
+
Creates a project node and a file node. The file node contains
|
|
249
|
+
all statements from the file.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
path: File path string.
|
|
253
|
+
encoding: Optional Python codec name declaring the file's byte
|
|
254
|
+
encoding; the file is transcoded before parsing. Default None
|
|
255
|
+
passes source bytes through unchanged.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
AST instance with project -> file -> statements hierarchy.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
ParseError: If PHP-Parser reports syntax error.
|
|
262
|
+
FileNotFoundError: If file does not exist.
|
|
263
|
+
ValueError: If path is not a regular file.
|
|
264
|
+
LookupError: If ``encoding`` names no Python codec.
|
|
265
|
+
UnicodeDecodeError: If the file's bytes do not decode under the
|
|
266
|
+
declared ``encoding``.
|
|
267
|
+
"""
|
|
268
|
+
parser = Parser()
|
|
269
|
+
return parser.parse_file(path, encoding=encoding)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def parse_project(
|
|
273
|
+
project_path: str,
|
|
274
|
+
file_filter: Callable[[Path], bool] | None = None,
|
|
275
|
+
ini_settings: Mapping[str, str] | None = None,
|
|
276
|
+
encoding: str | None = None,
|
|
277
|
+
) -> AST:
|
|
278
|
+
"""Parse all PHP files in a project directory into an AST.
|
|
279
|
+
|
|
280
|
+
Recursively traverses the project directory to find all PHP files,
|
|
281
|
+
then parses them into a single AST with project and file nodes.
|
|
282
|
+
Files that fail to read or to parse are skipped with a warning.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
project_path: Project root directory path.
|
|
286
|
+
file_filter: Function to filter files. Takes a Path and returns
|
|
287
|
+
True if the file should be parsed. Defaults to common PHP
|
|
288
|
+
source suffixes (.php, .phtml, legacy and Drupal-style).
|
|
289
|
+
ini_settings: Optional PHP ini overrides (e.g. ``memory_limit``)
|
|
290
|
+
applied to every PHP invocation. Default None inherits the
|
|
291
|
+
binary's environment.
|
|
292
|
+
encoding: Optional Python codec name declaring every file's byte
|
|
293
|
+
encoding; each file is transcoded before parsing. A file that
|
|
294
|
+
does not decode is skipped with a warning. Default None passes
|
|
295
|
+
source bytes through unchanged.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
AST instance with project -> files -> statements hierarchy.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
FileNotFoundError: If project directory does not exist.
|
|
302
|
+
ValueError: If project_path is not a directory, or if two files
|
|
303
|
+
hash to the same file ID.
|
|
304
|
+
LookupError: If ``encoding`` names no Python codec.
|
|
305
|
+
"""
|
|
306
|
+
if file_filter is None:
|
|
307
|
+
file_filter = _default_file_filter
|
|
308
|
+
parser = Parser(ini_settings=ini_settings)
|
|
309
|
+
return parser.parse_project(
|
|
310
|
+
project_path, file_filter=file_filter, encoding=encoding
|
|
311
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Printer Protocol and its two strategies: StandardPrinter, ExtendedPrinter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from static_php_py import PHP
|
|
10
|
+
|
|
11
|
+
from ._graph import AST, Node
|
|
12
|
+
from ._mapping import serialize
|
|
13
|
+
from ._runtime import Runner
|
|
14
|
+
from ._types import PrintMode, PrintRequest
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_STANDARD_CLASS = "PhpParser\\PrettyPrinter\\Standard"
|
|
19
|
+
_EXTENDED_CLASS = "Blv\\ExtendedPrinter"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class Printer(Protocol):
|
|
24
|
+
"""Structural contract for converting an AST to PHP source per file."""
|
|
25
|
+
|
|
26
|
+
def print(self, ast: AST) -> dict[str, str]:
|
|
27
|
+
"""Full-rewrite print of every file in the AST.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
ast: AST instance, possibly multi-file.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Mapping of file path to generated PHP source. An AST with no file
|
|
34
|
+
structure yields a single entry keyed "".
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
RunnerError: On PHP-Parser failure.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def print_preserving(
|
|
41
|
+
self, ast: AST, origin_source: Mapping[str, str]
|
|
42
|
+
) -> dict[str, str]:
|
|
43
|
+
"""Format-preserving print; reprint only changed or new nodes.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
ast: Modified AST instance.
|
|
47
|
+
origin_source: Mapping of each file key to its original source.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Mapping of file path to generated PHP source.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
RunnerError: On PHP-Parser failure.
|
|
54
|
+
KeyError: If an AST file key is absent from origin_source.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _PrinterBase:
|
|
59
|
+
"""Shared print orchestration parameterized by PHP printer class."""
|
|
60
|
+
|
|
61
|
+
_php_class: str
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
php: PHP | None = None,
|
|
66
|
+
ini_settings: Mapping[str, str] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Initialize with a Runner.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
php: Optional PHP instance. Defaults to the bundled binary.
|
|
72
|
+
ini_settings: Optional PHP ini overrides forwarded to the Runner.
|
|
73
|
+
Default None inherits the binary's environment.
|
|
74
|
+
"""
|
|
75
|
+
self._runner = Runner(php=php, ini_settings=ini_settings)
|
|
76
|
+
|
|
77
|
+
def print(self, ast: AST) -> dict[str, str]:
|
|
78
|
+
"""Full-rewrite print of every file in the AST."""
|
|
79
|
+
return {
|
|
80
|
+
key: self._runner.print(
|
|
81
|
+
PrintRequest(
|
|
82
|
+
php_class=self._php_class,
|
|
83
|
+
mode=PrintMode.FULL_REWRITE,
|
|
84
|
+
stmts_json=json_str,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
for key, json_str in self._file_statements(ast).items()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def print_preserving(
|
|
91
|
+
self, ast: AST, origin_source: Mapping[str, str]
|
|
92
|
+
) -> dict[str, str]:
|
|
93
|
+
"""Format-preserving print; reprint only changed or new nodes."""
|
|
94
|
+
result: dict[str, str] = {}
|
|
95
|
+
for key, json_str in self._file_statements(ast).items():
|
|
96
|
+
if key not in origin_source:
|
|
97
|
+
raise KeyError(f"No origin source for AST file {key!r}.")
|
|
98
|
+
result[key] = self._runner.print(
|
|
99
|
+
PrintRequest(
|
|
100
|
+
php_class=self._php_class,
|
|
101
|
+
mode=PrintMode.FORMAT_PRESERVING,
|
|
102
|
+
stmts_json=json_str,
|
|
103
|
+
orig_source=origin_source[key],
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _file_statements(ast: AST) -> dict[str, str]:
|
|
110
|
+
"""Map each file key to its statement JSON.
|
|
111
|
+
|
|
112
|
+
An AST with no file structure maps the single key "" to all
|
|
113
|
+
statements.
|
|
114
|
+
"""
|
|
115
|
+
file_nodes = ast.file_nodes()
|
|
116
|
+
if not file_nodes:
|
|
117
|
+
return {"": serialize(ast)}
|
|
118
|
+
return {
|
|
119
|
+
_PrinterBase._file_key(file_node): serialize(ast, file_hash=file_node.id)
|
|
120
|
+
for file_node in file_nodes
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _file_key(file_node: Node) -> str:
|
|
125
|
+
"""Key a file by its absolute path, falling back to its node id."""
|
|
126
|
+
path = file_node.get_property("absolutePath", "")
|
|
127
|
+
return str(path) if path else file_node.id
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class StandardPrinter(_PrinterBase):
|
|
131
|
+
"""Stock PHP-Parser printing via PrettyPrinter\\Standard.
|
|
132
|
+
|
|
133
|
+
Baseline behavior with no printer-bug fixes.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
_php_class = _STANDARD_CLASS
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ExtendedPrinter(_PrinterBase):
|
|
140
|
+
"""PHP-Parser printing with selective printer-bug fixes.
|
|
141
|
+
|
|
142
|
+
Instantiates Blv\\ExtendedPrinter, a userland class extending Standard.
|
|
143
|
+
Overrides affect only reprinted nodes, so an unmodified round-trip is
|
|
144
|
+
inert.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
_php_class = _EXTENDED_CLASS
|