php-parser-py 1.0.0__tar.gz → 1.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: php-parser-py
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Python wrapper for PHP-Parser using cpg2py
5
5
  Author: samhsu-dev
6
6
  Author-email: samhsu-dev <yxu166@jh.edu>
@@ -52,7 +52,7 @@ nodes = parse_code(code)
52
52
  # Returns list of Node instances (no project/file structure)
53
53
 
54
54
  for node in nodes:
55
- print(f"Node type: {node.label}")
55
+ print(f"Node type: {node.node_type}")
56
56
  ```
57
57
 
58
58
  ### Parse PHP File (With Project Structure)
@@ -69,34 +69,40 @@ files = ast.files()
69
69
  print(f"Project has {len(files)} file(s)")
70
70
 
71
71
  # Get file containing a specific node
72
- stmt_node = ast.first_node(lambda n: n.label == "Stmt_Function")
72
+ stmt_node = ast.first_node(lambda n: n.node_type == "Stmt_Function")
73
73
  file_node = ast.get_file(stmt_node.id)
74
74
  print(f"Function is in file: {file_node.get_property('filePath')}")
75
75
  ```
76
76
 
77
- ### Parse Multiple Files (Project Structure)
77
+ ### Parse Project Directory
78
78
 
79
79
  ```python
80
80
  from php_parser_py import parse_project
81
+ from pathlib import Path
81
82
 
82
- # Parse multiple files into a single AST
83
- ast = parse_project(["file1.php", "file2.php", "file3.php"])
83
+ # Parse all PHP files in a project directory (recursively)
84
+ ast = parse_project("/path/to/project")
84
85
 
85
86
  # Access all files
86
87
  for file_node in ast.files():
87
88
  print(f"File: {file_node.get_property('filePath')}")
88
89
  # Get statements in this file
89
- for stmt in ast.children(file_node):
90
- print(f" Statement: {stmt.label}")
90
+ for stmt in ast.succ(file_node):
91
+ print(f" Statement: {stmt.node_type}")
91
92
 
92
93
  # Access project and file paths
93
- ast = parse_project(["file1.php", "subdir/file2.php"])
94
94
  project = ast.project_node
95
95
  print(f"Project root: {project.get_property('path')}")
96
96
 
97
97
  for file_node in ast.files():
98
98
  print(f"File relative path: {file_node.get_property('path')}")
99
99
  print(f"File absolute path: {file_node.get_property('filePath')}")
100
+
101
+ # Use custom file filter (e.g., include .php and .phtml files)
102
+ ast = parse_project(
103
+ "/path/to/project",
104
+ file_filter=lambda p: p.suffix in ['.php', '.phtml']
105
+ )
100
106
  ```
101
107
 
102
108
  ### Traverse AST
@@ -107,19 +113,19 @@ from php_parser_py import parse_file
107
113
  ast = parse_file("example.php")
108
114
 
109
115
  # Find all echo statements
110
- for echo in ast.nodes(lambda n: n.label == "Stmt_Echo"):
111
- print(f"Echo at line {echo.get_property('startLine')}")
116
+ for echo in ast.nodes(lambda n: n.node_type == "Stmt_Echo"):
117
+ print(f"Echo at line {echo.start_line}")
112
118
 
113
119
  # Get children of a node (via PARENT_OF edges)
114
120
  for child in ast.succ(some_node):
115
- print(f"Child type: {child.label}")
121
+ print(f"Child type: {child.node_type}")
116
122
 
117
123
  # Get all descendants
118
124
  for descendant in ast.descendants(some_node):
119
- print(f"Descendant: {descendant.label}")
125
+ print(f"Descendant: {descendant.node_type}")
120
126
 
121
127
  # Get file containing a node
122
- stmt_node = ast.first_node(lambda n: n.label == "Stmt_Function")
128
+ stmt_node = ast.first_node(lambda n: n.node_type == "Stmt_Function")
123
129
  file_node = ast.get_file(stmt_node.id)
124
130
  if file_node:
125
131
  print(f"Function is in: {file_node.get_property('filePath')}")
@@ -146,12 +152,12 @@ for file_path, code in generated.items():
146
152
  print(f"File: {file_path}")
147
153
  print(code)
148
154
 
149
- # Parse multiple files
150
- ast = parse_project(["file1.php", "file2.php"])
155
+ # Parse project directory (recursively finds all PHP files)
156
+ ast = parse_project("/path/to/project")
151
157
 
152
158
  # Generate code for all files
153
159
  generated = printer.print(ast)
154
- # Returns: {"file1.php": "...", "file2.php": "..."}
160
+ # Returns: {"src/file1.php": "...", "src/file2.php": "..."}
155
161
  ```
156
162
 
157
163
  ## Advanced Usage
@@ -180,7 +186,7 @@ ast = parser.parse("<?php echo 'hello';")
180
186
  - **`parse_code(code: str) -> list[Node]`**: Parse PHP code string into raw statement nodes (no project/file structure)
181
187
  - **`parse(code: str) -> AST`**: Parse PHP code string and return AST with project/file structure (backward compatibility)
182
188
  - **`parse_file(path: str) -> AST`**: Parse a single PHP file and return AST with project/file structure
183
- - **`parse_project(paths: list[str]) -> AST`**: Parse multiple PHP files into a single AST with project structure
189
+ - **`parse_project(project_path: str, file_filter: Callable[[Path], bool] = lambda p: p.suffix == '.php') -> AST`**: Parse all PHP files in a project directory into a single AST with project structure. `file_filter` function to customize which files are included (defaults to `.php` suffix check)
184
190
 
185
191
  ### Classes
186
192
 
@@ -200,10 +206,12 @@ ast = parser.parse("<?php echo 'hello';")
200
206
 
201
207
  - **`Node`**: Wraps a single AST node
202
208
  - `id`: Node identifier
203
- - `label`: Node type (e.g., "Stmt_Function")
204
- - `node_type`: Alias for label (PHP-Parser node type)
205
- - `properties`: All node properties
209
+ - `node_type`: Node type (e.g., "Stmt_Function") - returns `nodeType` property
210
+ - `all_properties`: All node properties as dict
206
211
  - `get_property(*names)`: Get property value
212
+ - `start_line`, `end_line`: Line number properties
213
+ - `__getitem__(key)`: Dict-like access (e.g., `node["name"]`)
214
+ - `get(key, default)`: Get property with default value
207
215
 
208
216
  - **`Parser`**: Parses PHP code
209
217
  - `__init__(php_binary_path=None, php_binary_url=None)`: Initialize parser
@@ -212,7 +220,7 @@ ast = parser.parse("<?php echo 'hello';")
212
220
  - `parse_code(code)`: Parse code string into list of nodes
213
221
  - `parse(code)`: Parse code string into AST (backward compatibility)
214
222
  - `parse_file(path)`: Parse file into AST with project/file structure
215
- - `parse_project(paths)`: Parse multiple files into AST with project structure
223
+ - `parse_project(project_path)`: Parse project directory into AST with project structure
216
224
 
217
225
  - **`PrettyPrinter`**: Generates PHP code
218
226
  - `__init__(php_binary_path=None, php_binary_url=None)`: Initialize printer
@@ -41,7 +41,7 @@ nodes = parse_code(code)
41
41
  # Returns list of Node instances (no project/file structure)
42
42
 
43
43
  for node in nodes:
44
- print(f"Node type: {node.label}")
44
+ print(f"Node type: {node.node_type}")
45
45
  ```
46
46
 
47
47
  ### Parse PHP File (With Project Structure)
@@ -58,34 +58,40 @@ files = ast.files()
58
58
  print(f"Project has {len(files)} file(s)")
59
59
 
60
60
  # Get file containing a specific node
61
- stmt_node = ast.first_node(lambda n: n.label == "Stmt_Function")
61
+ stmt_node = ast.first_node(lambda n: n.node_type == "Stmt_Function")
62
62
  file_node = ast.get_file(stmt_node.id)
63
63
  print(f"Function is in file: {file_node.get_property('filePath')}")
64
64
  ```
65
65
 
66
- ### Parse Multiple Files (Project Structure)
66
+ ### Parse Project Directory
67
67
 
68
68
  ```python
69
69
  from php_parser_py import parse_project
70
+ from pathlib import Path
70
71
 
71
- # Parse multiple files into a single AST
72
- ast = parse_project(["file1.php", "file2.php", "file3.php"])
72
+ # Parse all PHP files in a project directory (recursively)
73
+ ast = parse_project("/path/to/project")
73
74
 
74
75
  # Access all files
75
76
  for file_node in ast.files():
76
77
  print(f"File: {file_node.get_property('filePath')}")
77
78
  # Get statements in this file
78
- for stmt in ast.children(file_node):
79
- print(f" Statement: {stmt.label}")
79
+ for stmt in ast.succ(file_node):
80
+ print(f" Statement: {stmt.node_type}")
80
81
 
81
82
  # Access project and file paths
82
- ast = parse_project(["file1.php", "subdir/file2.php"])
83
83
  project = ast.project_node
84
84
  print(f"Project root: {project.get_property('path')}")
85
85
 
86
86
  for file_node in ast.files():
87
87
  print(f"File relative path: {file_node.get_property('path')}")
88
88
  print(f"File absolute path: {file_node.get_property('filePath')}")
89
+
90
+ # Use custom file filter (e.g., include .php and .phtml files)
91
+ ast = parse_project(
92
+ "/path/to/project",
93
+ file_filter=lambda p: p.suffix in ['.php', '.phtml']
94
+ )
89
95
  ```
90
96
 
91
97
  ### Traverse AST
@@ -96,19 +102,19 @@ from php_parser_py import parse_file
96
102
  ast = parse_file("example.php")
97
103
 
98
104
  # Find all echo statements
99
- for echo in ast.nodes(lambda n: n.label == "Stmt_Echo"):
100
- print(f"Echo at line {echo.get_property('startLine')}")
105
+ for echo in ast.nodes(lambda n: n.node_type == "Stmt_Echo"):
106
+ print(f"Echo at line {echo.start_line}")
101
107
 
102
108
  # Get children of a node (via PARENT_OF edges)
103
109
  for child in ast.succ(some_node):
104
- print(f"Child type: {child.label}")
110
+ print(f"Child type: {child.node_type}")
105
111
 
106
112
  # Get all descendants
107
113
  for descendant in ast.descendants(some_node):
108
- print(f"Descendant: {descendant.label}")
114
+ print(f"Descendant: {descendant.node_type}")
109
115
 
110
116
  # Get file containing a node
111
- stmt_node = ast.first_node(lambda n: n.label == "Stmt_Function")
117
+ stmt_node = ast.first_node(lambda n: n.node_type == "Stmt_Function")
112
118
  file_node = ast.get_file(stmt_node.id)
113
119
  if file_node:
114
120
  print(f"Function is in: {file_node.get_property('filePath')}")
@@ -135,12 +141,12 @@ for file_path, code in generated.items():
135
141
  print(f"File: {file_path}")
136
142
  print(code)
137
143
 
138
- # Parse multiple files
139
- ast = parse_project(["file1.php", "file2.php"])
144
+ # Parse project directory (recursively finds all PHP files)
145
+ ast = parse_project("/path/to/project")
140
146
 
141
147
  # Generate code for all files
142
148
  generated = printer.print(ast)
143
- # Returns: {"file1.php": "...", "file2.php": "..."}
149
+ # Returns: {"src/file1.php": "...", "src/file2.php": "..."}
144
150
  ```
145
151
 
146
152
  ## Advanced Usage
@@ -169,7 +175,7 @@ ast = parser.parse("<?php echo 'hello';")
169
175
  - **`parse_code(code: str) -> list[Node]`**: Parse PHP code string into raw statement nodes (no project/file structure)
170
176
  - **`parse(code: str) -> AST`**: Parse PHP code string and return AST with project/file structure (backward compatibility)
171
177
  - **`parse_file(path: str) -> AST`**: Parse a single PHP file and return AST with project/file structure
172
- - **`parse_project(paths: list[str]) -> AST`**: Parse multiple PHP files into a single AST with project structure
178
+ - **`parse_project(project_path: str, file_filter: Callable[[Path], bool] = lambda p: p.suffix == '.php') -> AST`**: Parse all PHP files in a project directory into a single AST with project structure. `file_filter` function to customize which files are included (defaults to `.php` suffix check)
173
179
 
174
180
  ### Classes
175
181
 
@@ -189,10 +195,12 @@ ast = parser.parse("<?php echo 'hello';")
189
195
 
190
196
  - **`Node`**: Wraps a single AST node
191
197
  - `id`: Node identifier
192
- - `label`: Node type (e.g., "Stmt_Function")
193
- - `node_type`: Alias for label (PHP-Parser node type)
194
- - `properties`: All node properties
198
+ - `node_type`: Node type (e.g., "Stmt_Function") - returns `nodeType` property
199
+ - `all_properties`: All node properties as dict
195
200
  - `get_property(*names)`: Get property value
201
+ - `start_line`, `end_line`: Line number properties
202
+ - `__getitem__(key)`: Dict-like access (e.g., `node["name"]`)
203
+ - `get(key, default)`: Get property with default value
196
204
 
197
205
  - **`Parser`**: Parses PHP code
198
206
  - `__init__(php_binary_path=None, php_binary_url=None)`: Initialize parser
@@ -201,7 +209,7 @@ ast = parser.parse("<?php echo 'hello';")
201
209
  - `parse_code(code)`: Parse code string into list of nodes
202
210
  - `parse(code)`: Parse code string into AST (backward compatibility)
203
211
  - `parse_file(path)`: Parse file into AST with project/file structure
204
- - `parse_project(paths)`: Parse multiple files into AST with project structure
212
+ - `parse_project(project_path)`: Parse project directory into AST with project structure
205
213
 
206
214
  - **`PrettyPrinter`**: Generates PHP code
207
215
  - `__init__(php_binary_path=None, php_binary_url=None)`: Initialize printer
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "php-parser-py"
3
- version = "1.0.0"
3
+ version = "1.1.1"
4
4
  description = "Python wrapper for PHP-Parser using cpg2py"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -25,5 +25,8 @@ source-include = ["resources/*.zip"]
25
25
 
26
26
  [dependency-groups]
27
27
  dev = [
28
+ "black>=26.1.0",
29
+ "isort>=7.0.0",
30
+ "pylint>=4.0.4",
28
31
  "pytest>=9.0.2",
29
32
  ]
@@ -5,6 +5,12 @@ This package provides a Python interface to PHP-Parser, enabling AST parsing
5
5
  and manipulation of PHP code.
6
6
  """
7
7
 
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+ from typing import Callable
13
+
8
14
  from php_parser_py._parser import Parser as _Parser
9
15
  from php_parser_py._printer import PrettyPrinter as _PrettyPrinter
10
16
  from php_parser_py._resources import ensure_php_parser_extracted
@@ -14,9 +20,9 @@ ensure_php_parser_extracted()
14
20
 
15
21
  # Import and expose public classes directly
16
22
  from php_parser_py._ast import AST
17
- from php_parser_py._node import Node
18
23
  from php_parser_py._edge import Edge
19
- from php_parser_py.exceptions import ParseError, RunnerError
24
+ from php_parser_py._exceptions import ParseError, RunnerError
25
+ from php_parser_py._node import Node
20
26
 
21
27
 
22
28
  class Parser(_Parser):
@@ -53,35 +59,6 @@ def parse_code(code: str) -> list[Node]:
53
59
  return parser.parse_code(code)
54
60
 
55
61
 
56
- def parse(code: str) -> AST:
57
- """Parse PHP code string into an AST (backward compatibility).
58
-
59
- This is a convenience function that creates a temporary project/file structure.
60
- For code-only parsing without project structure, use parse_code() instead.
61
-
62
- Args:
63
- code: PHP source code string.
64
-
65
- Returns:
66
- AST instance with project -> file -> statements hierarchy.
67
-
68
- Raises:
69
- ParseError: If PHP-Parser reports syntax error.
70
- """
71
- # Create a temporary file-like structure for backward compatibility
72
- import tempfile
73
- from pathlib import Path
74
-
75
- with tempfile.NamedTemporaryFile(mode='w', suffix='.php', delete=False) as f:
76
- f.write(code)
77
- temp_path = f.name
78
-
79
- try:
80
- return parse_file(temp_path)
81
- finally:
82
- Path(temp_path).unlink()
83
-
84
-
85
62
  def parse_file(path: str) -> AST:
86
63
  """Read and parse a single PHP file into an AST.
87
64
 
@@ -102,25 +79,30 @@ def parse_file(path: str) -> AST:
102
79
  return parser.parse_file(path)
103
80
 
104
81
 
105
- def parse_project(paths: list[str], project_path: str | None = None) -> AST:
106
- """Parse multiple PHP files into an AST.
82
+ def parse_project(
83
+ project_path: str,
84
+ file_filter: "Callable[[Path], bool]" = lambda p: p.suffix == ".php", # type: ignore
85
+ ) -> AST:
86
+ """Parse all PHP files in a project directory into an AST.
107
87
 
108
- Creates a project node and multiple file nodes. Each file node
109
- contains statements from its corresponding file.
88
+ Recursively traverses the project directory to find all PHP files,
89
+ then parses them into a single AST with project and file nodes.
110
90
 
111
91
  Args:
112
- paths: List of file path strings.
113
- project_path: Optional project root path. If not provided, computed as common parent.
92
+ project_path: Project root directory path.
93
+ file_filter: Function to filter files. Takes a Path and returns
94
+ True if the file should be parsed. Defaults to checking for `.php` suffix.
114
95
 
115
96
  Returns:
116
97
  AST instance with project -> files -> statements hierarchy.
117
98
 
118
99
  Raises:
119
100
  ParseError: If any file has syntax errors.
120
- FileNotFoundError: If any file does not exist.
101
+ FileNotFoundError: If project directory does not exist.
102
+ ValueError: If project_path is not a directory.
121
103
  """
122
104
  parser = Parser()
123
- return parser.parse_project(paths, project_path=project_path)
105
+ return parser.parse_project(project_path, file_filter=file_filter)
124
106
 
125
107
 
126
108
  __version__ = "0.1.0"
@@ -119,7 +119,9 @@ class AST(AbcGraphQuerier[Node, Edge]):
119
119
  # Sort by file path for consistent ordering
120
120
  return sorted(file_nodes, key=lambda n: n.get_property("filePath", ""))
121
121
 
122
- def get_file(self, node_id: str) -> Optional[Node]:
122
+ def get_file(
123
+ self, node_id: str
124
+ ) -> Optional[Node]: # pylint: disable=too-many-return-statements
123
125
  """Get the file node that contains the given node.
124
126
 
125
127
  Traverses upward from the given node via PARENT_OF edges until
@@ -213,12 +215,12 @@ class AST(AbcGraphQuerier[Node, Edge]):
213
215
  # Exclude project node if it exists
214
216
  all_nodes = set(self._storage.get_nodes())
215
217
  nodes_with_parents = set()
216
-
218
+
217
219
  for edge_id in self._storage.get_edges():
218
- from_id, to_id, edge_type = edge_id
220
+ _, to_id, edge_type = edge_id
219
221
  if edge_type == "PARENT_OF":
220
222
  nodes_with_parents.add(to_id)
221
-
223
+
222
224
  root_nodes = all_nodes - nodes_with_parents
223
225
  # Exclude project node if present
224
226
  root_nodes.discard(self._root_node_id)
@@ -253,10 +255,20 @@ class AST(AbcGraphQuerier[Node, Edge]):
253
255
 
254
256
  return sorted(statement_nodes, key=get_index)
255
257
 
256
- def _reconstruct_node(self, nid: str) -> dict[str, Any]:
257
- """Recursively reconstruct JSON object for a node."""
258
+ def _reconstruct_node( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
259
+ self, nid: str
260
+ ) -> dict[str, Any]:
261
+ """Recursively reconstruct JSON object for a node.
262
+
263
+ Args:
264
+ nid: Node ID to reconstruct.
265
+
266
+ Returns:
267
+ Dictionary representing the node in PHP-Parser JSON format.
268
+ """
258
269
  props = self._storage.get_node_props(nid)
259
270
  if props is None:
271
+ logger.warning("Node %s not found in storage, returning empty dict", nid)
260
272
  return {}
261
273
 
262
274
  result: dict[str, Any] = {}
@@ -348,10 +360,12 @@ class AST(AbcGraphQuerier[Node, Edge]):
348
360
 
349
361
  # Add default values for common optional fields that PHP-Parser expects
350
362
  if node_type and ("attrGroups" not in result):
351
- if (node_type.startswith("Stmt_") or
352
- node_type == "Param" or
353
- node_type.startswith("Expr_Closure") or
354
- node_type.startswith("Expr_ArrowFunction")):
363
+ if (
364
+ node_type.startswith("Stmt_")
365
+ or node_type == "Param"
366
+ or node_type.startswith("Expr_Closure")
367
+ or node_type.startswith("Expr_ArrowFunction")
368
+ ):
355
369
  result["attrGroups"] = []
356
-
370
+
357
371
  return result
@@ -20,7 +20,9 @@ class Edge(AbcEdgeQuerier):
20
20
  _edge_id: Tuple of (from_id, to_id, edge_type).
21
21
  """
22
22
 
23
- def __init__(self, graph: Storage, f_nid: str, t_nid: str, e_type: str = "PARENT_OF") -> None:
23
+ def __init__(
24
+ self, graph: Storage, f_nid: str, t_nid: str, e_type: str = "PARENT_OF"
25
+ ) -> None:
24
26
  """Initialize Edge with storage and edge identifiers.
25
27
 
26
28
  Args:
@@ -3,14 +3,14 @@
3
3
  import hashlib
4
4
  import logging
5
5
  from pathlib import Path
6
- from typing import Any, Optional
6
+ from typing import Any, Callable, Optional
7
7
 
8
8
  from cpg2py import Storage
9
9
 
10
10
  from php_parser_py._ast import AST
11
+ from php_parser_py._exceptions import ParseError, RunnerError
11
12
  from php_parser_py._node import Node
12
13
  from php_parser_py._runner import Runner
13
- from php_parser_py.exceptions import ParseError, RunnerError
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
@@ -21,7 +21,7 @@ class Parser:
21
21
  Provides three parsing methods:
22
22
  - parse_code: Parse code string, returns list of top-level statement nodes
23
23
  - parse_file: Parse single file, returns AST with project and file nodes
24
- - parse_project: Parse multiple files, returns AST with project and multiple file nodes
24
+ - parse_project: Parse project directory, returns AST with project and all PHP files
25
25
 
26
26
  Attributes:
27
27
  _runner: Runner instance for PHP-Parser invocation.
@@ -33,7 +33,7 @@ class Parser:
33
33
  php_binary_url: Optional[str] = None,
34
34
  ) -> None:
35
35
  """Initialize Parser with Runner.
36
-
36
+
37
37
  Args:
38
38
  php_binary_path: Optional path to local PHP binary.
39
39
  php_binary_url: Optional URL to download PHP binary from.
@@ -43,35 +43,6 @@ class Parser:
43
43
  php_binary_url=php_binary_url,
44
44
  )
45
45
 
46
- def parse(self, code: str) -> AST:
47
- """Parse PHP code string into an AST (backward compatibility).
48
-
49
- Creates a temporary project/file structure. For code-only parsing
50
- without project structure, use parse_code() instead.
51
-
52
- Args:
53
- code: PHP source code string.
54
-
55
- Returns:
56
- AST instance with project -> file -> statements hierarchy.
57
-
58
- Raises:
59
- ParseError: If code has syntax errors.
60
- RunnerError: If PHP execution fails.
61
- """
62
- import tempfile
63
- from pathlib import Path
64
-
65
- # Create temporary file
66
- with tempfile.NamedTemporaryFile(mode='w', suffix='.php', delete=False) as f:
67
- f.write(code)
68
- temp_path = f.name
69
-
70
- try:
71
- return self.parse_file(temp_path)
72
- finally:
73
- Path(temp_path).unlink()
74
-
75
46
  def parse_code(self, code: str) -> list[Node]:
76
47
  """Parse PHP code string into a list of top-level statement nodes.
77
48
 
@@ -105,7 +76,9 @@ class Parser:
105
76
  node_counter = [1] # Use list to allow mutation in recursive calls
106
77
 
107
78
  for item in json_data:
108
- node_id = self._process_node(storage, item, None, None, None, node_counter, "")
79
+ node_id = self._process_node(
80
+ storage, item, None, None, None, node_counter, ""
81
+ )
109
82
  if node_id:
110
83
  node_ids.append(node_id)
111
84
 
@@ -140,7 +113,7 @@ class Parser:
140
113
  file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
141
114
 
142
115
  code = file_path.read_text(encoding="utf-8")
143
-
116
+
144
117
  try:
145
118
  json_data = self._runner.parse(code)
146
119
  except RunnerError as e:
@@ -153,63 +126,69 @@ class Parser:
153
126
  json_data = [json_data]
154
127
 
155
128
  storage = self._create_project_structure(
156
- [(file_path, json_data)],
129
+ [(file_path, json_data)],
157
130
  [(file_path, file_hash)],
158
- project_path=project_path
131
+ project_path=project_path,
159
132
  )
160
133
  return AST(storage, root_node_id="project")
161
134
 
162
- def parse_project(self, paths: list[str], project_path: str | None = None) -> AST:
163
- """Parse multiple PHP files into an AST with project and file nodes.
135
+ def parse_project(
136
+ self,
137
+ project_path: str,
138
+ file_filter: Callable[[Path], bool] = lambda p: p.suffix == ".php",
139
+ ) -> AST:
140
+ """Parse all PHP files in a project directory into an AST.
164
141
 
165
- Creates a project node (ID: "project") and multiple file nodes.
142
+ Recursively traverses the project directory to find all PHP files,
143
+ then parses them into a single AST with project and file nodes.
166
144
  Each file node has ID based on its path hash.
167
- Project path is the common parent directory of all files, or the provided project_path.
168
145
 
169
146
  Args:
170
- paths: List of file path strings.
171
- project_path: Optional project root path. If not provided, computed as common parent.
147
+ project_path: Project root directory path.
148
+ file_filter: Function to filter files. Takes a Path and returns
149
+ True if the file should be parsed. Defaults to checking for `.php` suffix.
172
150
 
173
151
  Returns:
174
152
  AST instance with project -> files -> statements hierarchy.
175
153
 
176
154
  Raises:
177
155
  ParseError: If any file has syntax errors.
178
- FileNotFoundError: If any file does not exist.
156
+ FileNotFoundError: If project directory does not exist.
157
+ ValueError: If project_path is not a directory.
179
158
  """
159
+ project_path_obj = Path(project_path).resolve()
160
+
161
+ if not project_path_obj.exists():
162
+ raise FileNotFoundError(f"Project directory not found: {project_path}")
163
+
164
+ if not project_path_obj.is_dir():
165
+ raise ValueError(f"Project path is not a directory: {project_path}")
166
+
167
+ # Find all files recursively and filter
168
+ all_files = project_path_obj.rglob("*")
169
+ php_files = [f for f in all_files if f.is_file() and file_filter(f)]
170
+
171
+ if not php_files:
172
+ logger.warning(f"No PHP files found in project directory: {project_path}")
173
+ # Create empty project structure
174
+ storage = Storage()
175
+ project_id = "project"
176
+ storage.add_node(project_id)
177
+ storage.set_node_props(
178
+ project_id,
179
+ {
180
+ "nodeType": "Project",
181
+ "label": "Project",
182
+ "path": str(project_path_obj),
183
+ },
184
+ )
185
+ return AST(storage, root_node_id="project")
186
+
187
+ # Prepare file information
180
188
  file_infos = []
181
- resolved_paths = []
182
- for path in paths:
183
- file_path = Path(path).resolve()
184
- if not file_path.exists():
185
- raise FileNotFoundError(f"File not found: {path}")
189
+ for file_path in php_files:
186
190
  file_hash = hashlib.md5(str(file_path).encode()).hexdigest()[:8]
187
191
  file_infos.append((file_path, file_hash))
188
- resolved_paths.append(file_path)
189
-
190
- # Determine project path
191
- if project_path:
192
- project_path = Path(project_path).resolve()
193
- else:
194
- # Compute common parent directory
195
- if len(resolved_paths) == 1:
196
- project_path = resolved_paths[0].parent
197
- else:
198
- # Find common parent
199
- common_parts = None
200
- for file_path in resolved_paths:
201
- parts = file_path.parts
202
- if common_parts is None:
203
- common_parts = parts
204
- else:
205
- # Find common prefix
206
- min_len = min(len(common_parts), len(parts))
207
- common_parts = [p for i, p in enumerate(common_parts[:min_len]) if parts[i] == p]
208
- if common_parts:
209
- project_path = Path(*common_parts)
210
- else:
211
- # Fallback to first file's parent
212
- project_path = resolved_paths[0].parent
213
192
 
214
193
  # Parse all files
215
194
  all_json_data = []
@@ -225,14 +204,16 @@ class Parser:
225
204
  raise ParseError(f"Syntax error in {file_path}", line=1) from e
226
205
  raise
227
206
 
228
- storage = self._create_project_structure(all_json_data, file_infos, project_path=project_path)
207
+ storage = self._create_project_structure(
208
+ all_json_data, file_infos, project_path=project_path_obj
209
+ )
229
210
  return AST(storage, root_node_id="project")
230
211
 
231
212
  def _create_project_structure(
232
213
  self,
233
214
  files_data: list[tuple[Path, Any]],
234
215
  file_infos: list[tuple[Path, str]],
235
- project_path: Path
216
+ project_path: Path,
236
217
  ) -> Storage:
237
218
  """Create AST structure with project -> files -> statements hierarchy.
238
219
 
@@ -249,11 +230,10 @@ class Parser:
249
230
  # Create project node (fixed ID: "project")
250
231
  project_id = "project"
251
232
  storage.add_node(project_id)
252
- storage.set_node_props(project_id, {
253
- "nodeType": "Project",
254
- "label": "Project",
255
- "path": str(project_path)
256
- })
233
+ storage.set_node_props(
234
+ project_id,
235
+ {"nodeType": "Project", "label": "Project", "path": str(project_path)},
236
+ )
257
237
 
258
238
  # Create file nodes and process their statements
259
239
  for (file_path, file_hash), (_, json_data) in zip(file_infos, files_data):
@@ -267,12 +247,15 @@ class Parser:
267
247
  # Create file node (ID: hex hash)
268
248
  file_id = file_hash
269
249
  storage.add_node(file_id)
270
- storage.set_node_props(file_id, {
271
- "nodeType": "File",
272
- "label": "File",
273
- "filePath": str(file_path), # Absolute path
274
- "path": str(relative_path) # Relative path from project root
275
- })
250
+ storage.set_node_props(
251
+ file_id,
252
+ {
253
+ "nodeType": "File",
254
+ "label": "File",
255
+ "filePath": str(file_path), # Absolute path
256
+ "path": str(relative_path), # Relative path from project root
257
+ },
258
+ )
276
259
 
277
260
  # Link file to project
278
261
  edge_id = (project_id, file_id, "PARENT_OF")
@@ -291,7 +274,7 @@ class Parser:
291
274
 
292
275
  return storage
293
276
 
294
- def _process_node(
277
+ def _process_node( # pylint: disable=too-many-arguments,too-many-locals,too-many-branches
295
278
  self,
296
279
  storage: Storage,
297
280
  node_data: Any,
@@ -299,7 +282,7 @@ class Parser:
299
282
  field_name: str | None,
300
283
  index: int | None,
301
284
  node_counter: list[int],
302
- prefix: str
285
+ prefix: str,
303
286
  ) -> str | None:
304
287
  """Process a single node and its children recursively.
305
288
 
@@ -383,7 +366,13 @@ class Parser:
383
366
  else:
384
367
  # Single child object
385
368
  self._process_node(
386
- storage, field_value, node_id, field_name, None, node_counter, prefix
369
+ storage,
370
+ field_value,
371
+ node_id,
372
+ field_name,
373
+ None,
374
+ node_counter,
375
+ prefix,
387
376
  )
388
377
 
389
378
  return node_id
@@ -26,7 +26,7 @@ class PrettyPrinter:
26
26
  php_binary_url: Optional[str] = None,
27
27
  ) -> None:
28
28
  """Initialize PrettyPrinter with Runner.
29
-
29
+
30
30
  Args:
31
31
  php_binary_path: Optional path to local PHP binary.
32
32
  php_binary_url: Optional URL to download PHP binary from.
@@ -44,34 +44,64 @@ class PrettyPrinter:
44
44
 
45
45
  Returns:
46
46
  Dictionary mapping file paths to generated PHP source code strings.
47
- If AST has no file structure, returns a single entry with key "code"
48
- or empty string.
47
+ If AST has no file structure, returns a single entry with key ""
48
+ as the filename.
49
49
 
50
50
  Raises:
51
51
  RunnerError: If PHP-Parser execution fails.
52
52
  """
53
53
  file_nodes = ast.files()
54
-
54
+
55
55
  if not file_nodes:
56
56
  # No file structure - export all statements as a single code block
57
57
  json_str = ast.to_json()
58
58
  code = self._runner.print(json_str)
59
59
  return {"": code}
60
-
60
+
61
61
  # Generate code for each file
62
62
  result: dict[str, str] = {}
63
63
  for file_node in file_nodes:
64
64
  file_path = file_node.get_property("filePath", "")
65
65
  file_hash = file_node.id
66
-
66
+
67
67
  # Get JSON for this file only
68
68
  json_str = ast.to_json(file_hash=file_hash)
69
-
69
+
70
70
  # Generate code
71
71
  code = self._runner.print(json_str)
72
-
72
+
73
73
  # Use file path as key, or file hash if path not available
74
74
  key = file_path if file_path else file_hash
75
75
  result[key] = code
76
-
76
+
77
77
  return result
78
+
79
+ def print_file(self, ast: AST, relative_path: str) -> str:
80
+ """Generate PHP code string for a single file selected by relative path.
81
+
82
+ This method looks up the file node whose ``path`` property matches the
83
+ given relative path (as stored on file nodes), reconstructs JSON for
84
+ that file only, and returns the generated PHP source code string.
85
+
86
+ Args:
87
+ ast: AST instance containing project and file nodes.
88
+ relative_path: Relative file path stored in the ``path`` property
89
+ of the target file node (for example, ``"src/file.php"`` or
90
+ just ``"file.php"`` for single-file projects).
91
+
92
+ Returns:
93
+ Generated PHP source code string for the specified file.
94
+
95
+ Raises:
96
+ KeyError: If no file node with the given relative path exists
97
+ in the AST.
98
+ RunnerError: If PHP-Parser execution fails.
99
+ """
100
+ # Find file node by its relative path property
101
+ for file_node in ast.files():
102
+ if file_node.get_property("path") == relative_path:
103
+ file_hash = file_node.id
104
+ json_str = ast.to_json(file_hash=file_hash)
105
+ return self._runner.print(json_str)
106
+
107
+ raise KeyError(f"File with relative path '{relative_path}' not found in AST.")
@@ -12,7 +12,7 @@ from typing import Optional
12
12
 
13
13
  # Global lock for thread-safe extraction
14
14
  _extraction_lock = threading.Lock()
15
- _extraction_done = False
15
+ _extraction_done = False # pylint: disable=invalid-name
16
16
 
17
17
 
18
18
  def get_vendor_path() -> Path:
@@ -9,8 +9,8 @@ from typing import Any, Optional
9
9
  from static_php_py import PHP
10
10
  from static_php_py.exceptions import BinaryNotFoundError, DownloadError
11
11
 
12
+ from php_parser_py._exceptions import ParseError, RunnerError
12
13
  from php_parser_py._resources import ensure_php_parser_extracted
13
- from php_parser_py.exceptions import ParseError, RunnerError
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
@@ -105,7 +105,7 @@ class Runner:
105
105
  if result.stdout:
106
106
  logger.error(f"PHP stdout: {result.stdout}")
107
107
  error_msg += f"\nStdout: {result.stdout}"
108
-
108
+
109
109
  raise RunnerError(
110
110
  error_msg,
111
111
  stderr=result.stderr,
@@ -117,18 +117,14 @@ class Runner:
117
117
  except FileNotFoundError as e:
118
118
  error_msg = f"PHP binary not found: {self._php_binary}"
119
119
  logger.error(error_msg)
120
- raise RunnerError(
121
- error_msg, stderr=str(e), exit_code=1
122
- ) from e
120
+ raise RunnerError(error_msg, stderr=str(e), exit_code=1) from e
123
121
  except Exception as e:
124
122
  error_msg = f"Failed to execute PHP script: {e}"
125
123
  logger.error(error_msg)
126
124
  # Don't wrap the error if it's already a RunnerError
127
125
  if isinstance(e, RunnerError):
128
126
  raise
129
- raise RunnerError(
130
- error_msg, stderr=str(e), exit_code=1
131
- ) from e
127
+ raise RunnerError(error_msg, stderr=str(e), exit_code=1) from e
132
128
 
133
129
  def parse(self, code: str) -> dict[str, Any]:
134
130
  """Invoke PHP-Parser parse + JsonSerializer.