Coverage for src/renaissance/project/project_scanner.py: 100%

55 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-02 13:12 +0000

1"""Language-specific source file scanners used to feed recipes with the files to process.""" 

2 

3import json 

4import subprocess 

5from pathlib import Path 

6 

7 

8class ProjectScanner: 

9 """Base class for language-specific source file scanners.""" 

10 

11 def find_sources(self) -> list[str]: 

12 """Return the paths of source files found by this scanner. Implemented by subclasses.""" 

13 raise NotImplementedError 

14 

15 

16class CppScanner(ProjectScanner): 

17 """Scan a C/C++ project's compile_commands.json for source files.""" 

18 

19 def __init__(self, compile_commands_path: str = "compile_commands.json") -> None: 

20 """Store the path to the compile_commands.json to read sources from.""" 

21 self.compile_commands_path = compile_commands_path 

22 

23 def find_sources(self) -> list[str]: 

24 """Return every file entry listed in compile_commands.json, sorted and deduplicated.""" 

25 if not Path(self.compile_commands_path).exists(): 

26 message = "compile_commands.json not found" 

27 raise FileNotFoundError(message) 

28 with Path(self.compile_commands_path).open() as f: 

29 commands = json.load(f) 

30 return sorted({entry["file"] for entry in commands if "file" in entry}) 

31 

32 

33class JavaScanner(ProjectScanner): 

34 """Scan a directory tree for Java source files.""" 

35 

36 def __init__(self, root_dir: str = ".") -> None: 

37 """Store the root directory to scan for .java files.""" 

38 self.root_dir = root_dir 

39 

40 def find_sources(self) -> list[str]: 

41 """Return every .java file under root_dir, sorted.""" 

42 # TODO: is this correct? does this filter out files correctly? 

43 # See https://github.com/TNO/Renaissance.Py/issues/200 

44 

45 java_files = Path(self.root_dir).rglob("*.java") 

46 return sorted(str(f) for f in java_files) 

47 

48 

49class PythonScanner(ProjectScanner): 

50 """Scan a directory tree for Python source files.""" 

51 

52 EXCLUDED_DIRS = frozenset({".git", "__pycache__", ".venv", "venv"}) 

53 # TODO: incomplete list, extend this list with more files/directories that should always be ignored 

54 

55 def __init__(self, root_dir: str = ".", package_dirs: list[str] | None = None) -> None: 

56 """Store the scan root and optional package_dirs narrowing. 

57 

58 package_dirs, when given, narrows the scan to those subdirectories of root_dir. 

59 Left as None (the default), the whole of root_dir is scanned instead of assuming a 

60 src/lib/test layout, since that assumption silently skipped real third-party layouts. 

61 """ 

62 self.root_dir = root_dir 

63 self.package_dirs = package_dirs 

64 

65 def find_sources(self) -> list[str]: 

66 """Return every .py file under root_dir (or package_dirs, if given), sorted, excluding EXCLUDED_DIRS.""" 

67 path = Path(self.root_dir) 

68 if not path.exists(): 

69 message = f"root_dir does not exist: {self.root_dir}" 

70 raise FileNotFoundError(message) 

71 if not path.is_dir(): 

72 message = f"root_dir is not a directory: {self.root_dir}" 

73 raise NotADirectoryError(message) 

74 

75 roots = [Path(self.root_dir) / d for d in self.package_dirs] if self.package_dirs else [Path(self.root_dir)] 

76 files: list[Path] = [] 

77 for root in roots: 

78 if not root.exists(): 

79 continue 

80 files.extend(path for path in root.rglob("*.py") if not any(part in self.EXCLUDED_DIRS for part in path.parts)) 

81 return sorted(str(path) for path in files) 

82 

83 

84class BearCppScanner(CppScanner): 

85 """CppScanner that generates compile_commands.json via Bear when it's missing.""" 

86 

87 def __init__(self, build_dir: str = ".", compile_commands_path: str = "compile_commands.json") -> None: 

88 """Store the build directory to run Bear in, alongside the compile_commands.json path.""" 

89 super().__init__(compile_commands_path) 

90 self.build_dir = build_dir 

91 

92 def run_bear(self) -> None: 

93 """Run Bear to generate compile_commands.json by wrapping the project's make invocation.""" 

94 # bear is resolved via PATH intentionally; build_dir isn't attacker-controlled here. 

95 result = subprocess.run(["bear", "--", "make", "-C", self.build_dir], check=False) # noqa: S603, S607 

96 if result.returncode != 0: 

97 message = "Bear failed to run or make failed." 

98 raise RuntimeError(message) 

99 

100 def find_sources(self) -> list[str]: 

101 """Generate compile_commands.json via Bear if missing, then return its listed sources.""" 

102 if not Path(self.compile_commands_path).exists(): 

103 self.run_bear() 

104 return super().find_sources()