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

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 

6 

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

8 

9import json 

10import subprocess 

11from pathlib import Path 

12 

13 

14class ProjectScanner: 

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

16 

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

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

19 raise NotImplementedError 

20 

21 

22class CppScanner(ProjectScanner): 

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

24 

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 

28 

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}) 

37 

38 

39class JavaScanner(ProjectScanner): 

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

41 

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

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

44 self.root_dir = root_dir 

45 

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 

50 

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

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

53 

54 

55class PythonScanner(ProjectScanner): 

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

57 

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

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

60 

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

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

63 

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 

70 

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) 

80 

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) 

88 

89 

90class BearCppScanner(CppScanner): 

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

92 

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 

97 

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) 

105 

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()