Coverage for src/renaissance/project/project_scanner.py: 100%
55 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
1# Copyright (C) 2024-2026 TNO
2#
3# Licensed under the terms specified in the LICENSE file in the root directory
4# of this repository, also available at:
5# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE
7"""Language-specific source file scanners used to feed recipes with the files to process."""
9import json
10import subprocess
11from pathlib import Path
14class ProjectScanner:
15 """Base class for language-specific source file scanners."""
17 def find_sources(self) -> list[str]:
18 """Return the paths of source files found by this scanner. Implemented by subclasses."""
19 raise NotImplementedError
22class CppScanner(ProjectScanner):
23 """Scan a C/C++ project's compile_commands.json for source files."""
25 def __init__(self, compile_commands_path: str = "compile_commands.json") -> None:
26 """Store the path to the compile_commands.json to read sources from."""
27 self.compile_commands_path = compile_commands_path
29 def find_sources(self) -> list[str]:
30 """Return every file entry listed in compile_commands.json, sorted and deduplicated."""
31 if not Path(self.compile_commands_path).exists():
32 message = "compile_commands.json not found"
33 raise FileNotFoundError(message)
34 with Path(self.compile_commands_path).open() as f:
35 commands = json.load(f)
36 return sorted({entry["file"] for entry in commands if "file" in entry})
39class JavaScanner(ProjectScanner):
40 """Scan a directory tree for Java source files."""
42 def __init__(self, root_dir: str = ".") -> None:
43 """Store the root directory to scan for .java files."""
44 self.root_dir = root_dir
46 def find_sources(self) -> list[str]:
47 """Return every .java file under root_dir, sorted."""
48 # TODO: is this correct? does this filter out files correctly?
49 # See https://github.com/TNO/Renaissance.Py/issues/200
51 java_files = Path(self.root_dir).rglob("*.java")
52 return sorted(str(f) for f in java_files)
55class PythonScanner(ProjectScanner):
56 """Scan a directory tree for Python source files."""
58 EXCLUDED_DIRS = frozenset({".git", "__pycache__", ".venv", "venv"})
59 # TODO: incomplete list, extend this list with more files/directories that should always be ignored
61 def __init__(self, root_dir: str = ".", package_dirs: list[str] | None = None) -> None:
62 """Store the scan root and optional package_dirs narrowing.
64 package_dirs, when given, narrows the scan to those subdirectories of root_dir.
65 Left as None (the default), the whole of root_dir is scanned instead of assuming a
66 src/lib/test layout, since that assumption silently skipped real third-party layouts.
67 """
68 self.root_dir = root_dir
69 self.package_dirs = package_dirs
71 def find_sources(self) -> list[str]:
72 """Return every .py file under root_dir (or package_dirs, if given), sorted, excluding EXCLUDED_DIRS."""
73 path = Path(self.root_dir)
74 if not path.exists():
75 message = f"root_dir does not exist: {self.root_dir}"
76 raise FileNotFoundError(message)
77 if not path.is_dir():
78 message = f"root_dir is not a directory: {self.root_dir}"
79 raise NotADirectoryError(message)
81 roots = [Path(self.root_dir) / d for d in self.package_dirs] if self.package_dirs else [Path(self.root_dir)]
82 files: list[Path] = []
83 for root in roots:
84 if not root.exists():
85 continue
86 files.extend(path for path in root.rglob("*.py") if not any(part in self.EXCLUDED_DIRS for part in path.parts))
87 return sorted(str(path) for path in files)
90class BearCppScanner(CppScanner):
91 """CppScanner that generates compile_commands.json via Bear when it's missing."""
93 def __init__(self, build_dir: str = ".", compile_commands_path: str = "compile_commands.json") -> None:
94 """Store the build directory to run Bear in, alongside the compile_commands.json path."""
95 super().__init__(compile_commands_path)
96 self.build_dir = build_dir
98 def run_bear(self) -> None:
99 """Run Bear to generate compile_commands.json by wrapping the project's make invocation."""
100 # bear is resolved via PATH intentionally; build_dir isn't attacker-controlled here.
101 result = subprocess.run(["bear", "--", "make", "-C", self.build_dir], check=False) # noqa: S603, S607
102 if result.returncode != 0:
103 message = "Bear failed to run or make failed."
104 raise RuntimeError(message)
106 def find_sources(self) -> list[str]:
107 """Generate compile_commands.json via Bear if missing, then return its listed sources."""
108 if not Path(self.compile_commands_path).exists():
109 self.run_bear()
110 return super().find_sources()