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.
@@ -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
@@ -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