gd-tools-cli 0.1.0__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.
@@ -0,0 +1,377 @@
1
+ """Coverage plan generator module.
2
+
3
+ Parses GDScript source files using gdtoolkit's Lark parser, walks
4
+ the resulting AST to identify trackable statements and branch points,
5
+ and emits an instrumentation plan JSON file.
6
+
7
+ The generated plan is consumed by the GDScript runtime tracker
8
+ (Track 10) and pre/post-run hooks (Track 11) for code coverage
9
+ instrumentation.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path, PurePath
18
+
19
+ from gdtoolkit.parser import parser as gd_parser
20
+ from lark import Tree
21
+ from lark.visitors import Visitor
22
+
23
+ from gd_tools.errors import CoveragePlanError
24
+ from gd_tools.file_discovery import discover_gd_files
25
+
26
+ # --- Data structures (FR-1) ---
27
+
28
+
29
+ @dataclass
30
+ class LinePlan:
31
+ """A single trackable point in a GDScript file.
32
+
33
+ Attributes:
34
+ line: 1-indexed line number in the source file.
35
+ id: Unique identifier within the file (sequential 0-indexed).
36
+ type: Either "statement" or "branch".
37
+ branch_type: Branch type string if type is "branch", else None.
38
+ """
39
+
40
+ line: int
41
+ id: int
42
+ type: str
43
+ branch_type: str | None = None
44
+
45
+
46
+ @dataclass
47
+ class FilePlan:
48
+ """Per-file entry in a coverage plan.
49
+
50
+ Attributes:
51
+ file_id: Sequential 0-indexed file identifier.
52
+ path: Godot resource path with ``res://`` prefix.
53
+ source_hash: SHA-256 hash prefixed with ``sha256:``.
54
+ lines: List of trackable points in this file.
55
+ """
56
+
57
+ file_id: int
58
+ path: str
59
+ source_hash: str
60
+ lines: list[LinePlan] = field(default_factory=list)
61
+
62
+
63
+ @dataclass
64
+ class CoveragePlan:
65
+ """Top-level container for a coverage plan.
66
+
67
+ Attributes:
68
+ version: Schema version (currently 1).
69
+ generated_by: Name of the tool that generated this plan.
70
+ files: List of per-file plans.
71
+ """
72
+
73
+ version: int
74
+ generated_by: str
75
+ files: list[FilePlan] = field(default_factory=list)
76
+
77
+
78
+ # --- JSON I/O (FR-5) ---
79
+
80
+
81
+ def write_plan_json(plan: CoveragePlan, output_path: str) -> None:
82
+ """Serialize a :class:`CoveragePlan` to a JSON file.
83
+
84
+ Args:
85
+ plan: The coverage plan to serialize.
86
+ output_path: Path to the output JSON file.
87
+ """
88
+ data = {
89
+ "version": plan.version,
90
+ "generated_by": plan.generated_by,
91
+ "files": [
92
+ {
93
+ "file_id": fp.file_id,
94
+ "path": fp.path,
95
+ "source_hash": fp.source_hash,
96
+ "lines": [
97
+ {
98
+ "line": lp.line,
99
+ "id": lp.id,
100
+ "type": lp.type,
101
+ "branch_type": lp.branch_type,
102
+ }
103
+ for lp in fp.lines
104
+ ],
105
+ }
106
+ for fp in plan.files
107
+ ],
108
+ }
109
+ Path(output_path).write_text(json.dumps(data, indent=2), encoding="utf-8")
110
+
111
+
112
+ def read_plan_json(path: str) -> CoveragePlan:
113
+ """Deserialize a JSON file to a :class:`CoveragePlan` object.
114
+
115
+ Args:
116
+ path: Path to the JSON plan file.
117
+
118
+ Returns:
119
+ The deserialized :class:`CoveragePlan`.
120
+
121
+ Raises:
122
+ CoveragePlanError: If the file is missing, contains invalid
123
+ JSON, or has a schema mismatch.
124
+ """
125
+ plan_path = Path(path)
126
+ if not plan_path.exists():
127
+ raise CoveragePlanError(f"Plan file not found: {path}")
128
+
129
+ try:
130
+ data = json.loads(plan_path.read_text(encoding="utf-8"))
131
+ except json.JSONDecodeError as exc:
132
+ raise CoveragePlanError(f"Invalid JSON in plan file: {exc}") from exc
133
+
134
+ if not isinstance(data, dict):
135
+ raise CoveragePlanError("Plan JSON must be a JSON object")
136
+
137
+ version = data.get("version")
138
+ if version != 1:
139
+ raise CoveragePlanError(
140
+ f"Unsupported plan version: {version} (expected 1)"
141
+ )
142
+
143
+ if "generated_by" not in data:
144
+ raise CoveragePlanError("Missing required field: generated_by")
145
+
146
+ if "files" not in data:
147
+ raise CoveragePlanError("Missing required field: files")
148
+
149
+ files_data = data["files"]
150
+ if not isinstance(files_data, list):
151
+ raise CoveragePlanError("Plan 'files' field must be a list")
152
+
153
+ files: list[FilePlan] = []
154
+ for fdata in files_data:
155
+ if not isinstance(fdata, dict):
156
+ raise CoveragePlanError("Each file entry must be a JSON object")
157
+ for required in ("file_id", "path", "source_hash"):
158
+ if required not in fdata:
159
+ raise CoveragePlanError(
160
+ f"Missing required field in file entry: {required}"
161
+ )
162
+ lines = [
163
+ LinePlan(
164
+ line=lp["line"],
165
+ id=lp["id"],
166
+ type=lp["type"],
167
+ branch_type=lp.get("branch_type"),
168
+ )
169
+ for lp in fdata.get("lines", [])
170
+ ]
171
+ files.append(
172
+ FilePlan(
173
+ file_id=fdata["file_id"],
174
+ path=fdata["path"],
175
+ source_hash=fdata["source_hash"],
176
+ lines=lines,
177
+ )
178
+ )
179
+
180
+ return CoveragePlan(
181
+ version=data["version"],
182
+ generated_by=data["generated_by"],
183
+ files=files,
184
+ )
185
+
186
+
187
+ # --- AST Parsing (FR-3) ---
188
+
189
+
190
+ def parse_gdscript(source: str) -> Tree:
191
+ """Parse GDScript source and return a Lark AST tree with metadata.
192
+
193
+ Args:
194
+ source: Raw GDScript source code.
195
+
196
+ Returns:
197
+ A Lark :class:`~lark.Tree` with line metadata enabled.
198
+ """
199
+ return gd_parser.parse(source, gather_metadata=True)
200
+
201
+
202
+ # --- Coverage Visitor (FR-2, FR-3) ---
203
+
204
+
205
+ class CoverageVisitor(Visitor):
206
+ """Lark AST visitor that identifies trackable statements and branches.
207
+
208
+ Walks the parsed GDScript AST and collects :class:`LinePlan` entries
209
+ for each trackable statement and branch point.
210
+
211
+ Attributes:
212
+ points: List of collected trackable points.
213
+ """
214
+
215
+ def __init__(self) -> None:
216
+ self.points: list[LinePlan] = []
217
+ self._next_id: int = 0
218
+
219
+ def _add_point(
220
+ self,
221
+ tree: Tree,
222
+ point_type: str,
223
+ branch_type: str | None = None,
224
+ ) -> None:
225
+ """Extract line number and append a new :class:`LinePlan`.
226
+
227
+ Args:
228
+ tree: The AST node being tracked.
229
+ point_type: Either "statement" or "branch".
230
+ branch_type: Branch type string if ``point_type`` is
231
+ "branch", otherwise ``None``.
232
+ """
233
+ self.points.append(
234
+ LinePlan(
235
+ line=tree.meta.line,
236
+ id=self._next_id,
237
+ type=point_type,
238
+ branch_type=branch_type,
239
+ )
240
+ )
241
+ self._next_id += 1
242
+
243
+ # --- Statement methods ---
244
+
245
+ def expr_stmt(self, tree: Tree) -> None:
246
+ """Track expression statements."""
247
+ self._add_point(tree, "statement")
248
+
249
+ def return_stmt(self, tree: Tree) -> None:
250
+ """Track return statements."""
251
+ self._add_point(tree, "statement")
252
+
253
+ def func_var_assigned(self, tree: Tree) -> None:
254
+ """Track inferred-type variable assignments in functions."""
255
+ self._add_point(tree, "statement")
256
+
257
+ def func_var_typed_assgnd(self, tree: Tree) -> None:
258
+ """Track typed variable assignments in functions."""
259
+ self._add_point(tree, "statement")
260
+
261
+ def func_var_inf(self, tree: Tree) -> None:
262
+ """Track ``:=`` inferred-type variable assignments."""
263
+ self._add_point(tree, "statement")
264
+
265
+ def break_stmt(self, tree: Tree) -> None:
266
+ """Track break statements."""
267
+ self._add_point(tree, "statement")
268
+
269
+ def continue_stmt(self, tree: Tree) -> None:
270
+ """Track continue statements."""
271
+ self._add_point(tree, "statement")
272
+
273
+ # --- Branch methods ---
274
+
275
+ def if_branch(self, tree: Tree) -> None:
276
+ """Track if branch as ``if_true``."""
277
+ self._add_point(tree, "branch", "if_true")
278
+
279
+ def elif_branch(self, tree: Tree) -> None:
280
+ """Track elif branch as ``elif_true``."""
281
+ self._add_point(tree, "branch", "elif_true")
282
+
283
+ def else_branch(self, tree: Tree) -> None:
284
+ """Track else branch as ``if_false``."""
285
+ self._add_point(tree, "branch", "if_false")
286
+
287
+ def while_stmt(self, tree: Tree) -> None:
288
+ """Track while loop body."""
289
+ self._add_point(tree, "branch", "loop_body")
290
+
291
+ def for_stmt(self, tree: Tree) -> None:
292
+ """Track for loop body."""
293
+ self._add_point(tree, "branch", "loop_body")
294
+
295
+ def for_stmt_typed(self, tree: Tree) -> None:
296
+ """Track typed for loop body."""
297
+ self._add_point(tree, "branch", "loop_body")
298
+
299
+ def match_branch(self, tree: Tree) -> None:
300
+ """Track each match case as ``match_case``."""
301
+ self._add_point(tree, "branch", "match_case")
302
+
303
+
304
+ # --- Plan Generation (FR-4, FR-6) ---
305
+
306
+
307
+ def generate_plan(
308
+ project_root: str,
309
+ source_dirs: list[str] | None = None,
310
+ exclude_dirs: list[str] | None = None,
311
+ test_dirs: list[str] | None = None,
312
+ ) -> CoveragePlan:
313
+ """Generate a coverage plan for a Godot project.
314
+
315
+ Discovers all ``.gd`` files in ``project_root``, parses each one,
316
+ runs :class:`CoverageVisitor` to identify trackable points, and
317
+ assembles a :class:`CoveragePlan` with sequential ``file_id`` values.
318
+
319
+ Args:
320
+ project_root: Root directory of the Godot project.
321
+ source_dirs: Optional list of source directories (unused,
322
+ all discovered files are included).
323
+ exclude_dirs: Directories to exclude from discovery.
324
+ Defaults to :data:`~gd_tools.config.DEFAULT_EXCLUDES`.
325
+ test_dirs: Directories whose files should be excluded from
326
+ coverage targets. Defaults to ``["test", "tests"]``.
327
+
328
+ Returns:
329
+ A :class:`CoveragePlan` with one :class:`FilePlan` per
330
+ discovered file.
331
+ """
332
+ if exclude_dirs is None:
333
+ from gd_tools.config import DEFAULT_EXCLUDES
334
+
335
+ exclude_dirs = DEFAULT_EXCLUDES.copy()
336
+
337
+ if test_dirs is None:
338
+ test_dirs = ["test", "tests"]
339
+
340
+ gd_files = discover_gd_files(project_root, excludes=exclude_dirs)
341
+
342
+ # Filter out files whose path contains a test directory component
343
+ gd_files = [
344
+ f
345
+ for f in gd_files
346
+ if not any(td in PurePath(f).parts for td in test_dirs)
347
+ ]
348
+
349
+ file_plans: list[FilePlan] = []
350
+ for file_id, gd_file in enumerate(gd_files):
351
+ source = Path(gd_file).read_text(encoding="utf-8")
352
+ source_hash = (
353
+ "sha256:" + hashlib.sha256(source.encode("utf-8")).hexdigest()
354
+ )
355
+
356
+ tree = parse_gdscript(source)
357
+ visitor = CoverageVisitor()
358
+ visitor.visit(tree)
359
+
360
+ # Build res:// path
361
+ rel_path = Path(gd_file).relative_to(project_root)
362
+ res_path = "res://" + str(rel_path).replace("\\", "/")
363
+
364
+ file_plans.append(
365
+ FilePlan(
366
+ file_id=file_id,
367
+ path=res_path,
368
+ source_hash=source_hash,
369
+ lines=visitor.points,
370
+ )
371
+ )
372
+
373
+ return CoveragePlan(
374
+ version=1,
375
+ generated_by="gd-tools",
376
+ files=file_plans,
377
+ )