ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_unused_includes.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Speculative include cleaner for ra8-firmware.
5
6Finds genuinely unused `#include` directives in first-party C translation units
7using speculative compilation. Rather than relying on heuristic AST linters
8(which falsely reject intentional umbrella headers or demand redundant direct
9includes), this checker tests each `#include` by commenting it out and
10attempting compilation. If the compiler succeeds with identical semantics and
11no errors, the header is verified to be dead code.
12
13Usage::
14
15 python3 scripts/checks/check_unused_includes.py --selftest
16 python3 scripts/checks/check_unused_includes.py --check [paths...]
17"""
18
19from __future__ import annotations
20
21import argparse
22import json
23import re
24import shlex
25import shutil
26import subprocess
27import sys
28import tempfile
29from pathlib import Path
30
31
32def _include_re() -> re.Pattern[str]:
33 return re.compile(r"^(#\s*include\s+[\"<][^\">]+[\">])", re.MULTILINE)
34
35
36def _repo_root() -> Path:
37 return Path(__file__).resolve().parents[2]
38
39
40def _find_compiler() -> str:
41 for candidate in ("cc", "gcc", "clang"):
42 found = shutil.which(candidate)
43 if found:
44 return found
45 return "cc"
46
47
48def _load_compile_db() -> dict[str, list[str]]:
49 commands: dict[str, list[str]] = {}
50 for db_path in (
51 _repo_root() / "compile_commands.json",
52 _repo_root() / "build" / "tidy" / "compile_commands.json",
53 _repo_root() / "build" / "compile_commands.json",
54 ):
55 if not db_path.is_file():
56 continue
57 try:
58 data = json.loads(db_path.read_text(encoding="utf-8"))
59 for entry in data:
60 file_rel = entry.get("file", "")
61 if file_rel:
62 p = Path(file_rel)
63 try:
64 rel = str(p.relative_to(_repo_root()))
65 except ValueError:
66 rel = file_rel
67 if "command" in entry:
68 commands[rel] = shlex.split(entry["command"])
69 elif "arguments" in entry:
70 commands[rel] = list(entry["arguments"])
71 if commands:
72 break
73 except (OSError, json.JSONDecodeError):
74 continue
75 return commands
76
77
78def _default_compile_args(path: Path) -> list[str]:
79 args = [
80 _find_compiler(),
81 "-std=gnu2x",
82 "-c",
83 "-D_GNU_SOURCE",
84 "-DRA8_OFF_TARGET",
85 f"-I{_repo_root()}",
86 f"-I{path.parent}",
87 "-Werror",
88 "-Wno-unknown-warning-option",
89 ]
90 for root in ("libs", "apps", "tools", "port"):
91 base = _repo_root() / root
92 if base.is_dir():
93 args.extend(f"-I{p}" for p in base.glob("**/inc") if p.is_dir())
94 return args
95
96
97def _compile_args_for_file(path: Path, db: dict[str, list[str]]) -> list[str]:
98 try:
99 rel = str(path.resolve().relative_to(_repo_root()))
100 except ValueError:
101 rel = str(path)
102 if rel in db:
103 raw_cmd = db[rel]
104 filtered: list[str] = []
105 skip_next = False
106 for arg in raw_cmd:
107 if skip_next:
108 skip_next = False
109 continue
110 if arg in ("-c", "-o"):
111 if arg == "-o":
112 skip_next = True
113 continue
114 if arg.endswith((".c", ".cpp", ".cc", ".s", ".S")):
115 continue
116 filtered.append(arg)
117 return filtered
118 return _default_compile_args(path)
119
120
121def _conditional_depths(source: str) -> list[int]:
122 """Return the preprocessor conditional nesting depth at each line start."""
123 depths: list[int] = []
124 depth = 0
125 directive_re = re.compile(r"^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b")
126 for line in source.split("\n"):
127 m = directive_re.match(line)
128 if m is not None:
129 kind = m.group(1)
130 if kind == "endif":
131 depth = max(0, depth - 1)
132 depths.append(depth)
133 elif kind in ("else", "elif"):
134 depths.append(max(0, depth - 1))
135 else:
136 depths.append(depth)
137 depth += 1
138 else:
139 depths.append(depth)
140 return depths
141
142
143def _keep_reason(source: str, match_end: int) -> str | None:
144 """Return the `ra8-keep-include` reason on an include line, if it has one."""
145 line_end = source.find("\n", match_end)
146 trailing = source[match_end : line_end if line_end != -1 else len(source)]
147 m = re.search(r"ra8-keep-include:\s*(\S.*)?$", trailing)
148 if m is None:
149 return None
150 reason = (m.group(1) or "").strip()
151 return reason or None
152
153
154def _keep_claimed_tokens(reason: str) -> list[str]:
155 """Return the backticked symbol tokens a keep marker vouches for."""
156 return re.findall(r"`([^`]+)`", reason)
157
158
159def _code_tokens(source: str) -> set[str]:
160 """Identifier tokens in non-include code with comments/strings stripped."""
161 no_comments = re.sub(r"/\*.*?\*/|//[^\n]*", " ", source, flags=re.DOTALL)
162 no_strings = re.sub(r"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'", " ", no_comments)
163 lines = [ln for ln in no_strings.split("\n") if not re.match(r"\s*#\s*include\b", ln)]
164 return set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", "\n".join(lines)))
165
166
167def _keep_honored(source: str, match_end: int) -> bool:
168 """Honor a keep marker only if it names used symbols in backticks."""
169 reason = _keep_reason(source, match_end)
170 if reason is None:
171 return False
172 claimed = _keep_claimed_tokens(reason)
173 if not claimed:
174 return False
175 code = _code_tokens(source)
176 return all(tok in code for tok in claimed)
177
178
179def _run_compile(base_cmd: list[str]) -> int:
180 """Run one speculative compile, returning its exit code."""
181 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool
182 base_cmd, capture_output=True, text=True, check=False
183 )
184 return proc.returncode
185
186
187def _comment_include(source: str, match_start: int, inc_text: str, match_end: int) -> str:
188 """Return source with one include line commented out."""
189 return source[:match_start] + "// " + inc_text + source[match_end:]
190
191
192def _body_trivial_without_includes(
193 source: str, matches: list[re.Match[str]], base_cmd: list[str], scratch: Path
194) -> bool:
195 """Return True when the file still compiles with every include disabled."""
196 # If the file compiles perfectly fine when ALL includes are commented
197 # out simultaneously, then the file's body is likely disabled by macros
198 # (e.g. RA8_OFF_TARGET). In this state, speculative compilation cannot
199 # distinguish between used and unused includes, because none of them
200 # affect compilation.
201 mutated_all = source
202 for m in reversed(matches):
203 mutated_all = _comment_include(mutated_all, m.start(), m.group(1).strip(), m.end())
204 scratch.write_text(mutated_all, encoding="utf-8")
205 return _run_compile(base_cmd) == 0
206
207
208def _test_top_level_includes(
209 source: str, base_cmd: list[str], scratch: Path, path: Path, verbose: bool
210) -> list[tuple[int, str]]:
211 """Test each top-level include by speculative compilation."""
212 # Configuration-dependent includes (inside #if/#ifdef blocks, e.g. an
213 # MVE-gated <arm_mve.h>) cannot be judged by compilation under a single
214 # configuration, so only top-level includes are tested per line.
215 matches = list(_include_re().finditer(source))
216 depths = _conditional_depths(source)
217 unused: list[tuple[int, str]] = []
218 for m in matches:
219 line_no = source[: m.start()].count("\n") + 1
220 if depths[line_no - 1] > 0:
221 if verbose:
222 msg = f"check_unused_includes: skipping guarded include {path}:{line_no}\n"
223 sys.stderr.write(msg)
224 continue
225 # A `// ra8-keep-include: ...` marker records a reviewed direct-use
226 # (IWYU) keep: the header declares symbols this file uses, even when
227 # they also arrive transitively. The marker must vouch for at least
228 # one used symbol in backticks; bare markers, generic prose, and
229 # mistargeted tokens are not honored.
230 if _keep_honored(source, m.end()):
231 continue
232 inc_text = m.group(1).strip()
233 scratch.write_text(_comment_include(source, m.start(), inc_text, m.end()), encoding="utf-8")
234 if _run_compile(base_cmd) == 0:
235 unused.append((line_no, inc_text))
236 return unused
237
238
239def check_file(
240 path: Path, db: dict[str, list[str]], *, verbose: bool = False
241) -> list[tuple[int, str]]:
242 """Test all `#include` lines in `path` by speculative compilation."""
243 if not path.is_file():
244 return []
245 source = path.read_text(encoding="utf-8", errors="replace")
246 matches = list(_include_re().finditer(source))
247 if not matches:
248 return []
249
250 compile_cmd = _compile_args_for_file(path, db)
251
252 with tempfile.TemporaryDirectory(prefix="ra8-include-check-") as tmp:
253 scratch = Path(tmp) / path.name
254 out_obj = Path(tmp) / f"{path.stem}.o"
255
256 scratch.write_text(source, encoding="utf-8")
257 base_cmd = [*compile_cmd, "-c", str(scratch), "-o", str(out_obj)]
258 if "-Wmissing-prototypes" not in base_cmd:
259 base_cmd.extend(["-Wmissing-prototypes", "-Werror=missing-prototypes"])
260 if _run_compile(base_cmd) != 0:
261 if verbose:
262 msg = f"check_unused_includes: skipping {path} (baseline fails)\n"
263 sys.stderr.write(msg)
264 return []
265 if _body_trivial_without_includes(source, matches, base_cmd, scratch):
266 if verbose:
267 msg = f"check_unused_includes: skipping {path} (trivial body)\n"
268 sys.stderr.write(msg)
269 return []
270 return _test_top_level_includes(source, base_cmd, scratch, path, verbose)
271
272
273def _git_changed_c_files() -> list[Path]:
274 c_files: list[Path] = []
275 for target in ("origin/dev", "dev", "HEAD~1"):
276 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool
277 ["git", "diff", "--name-only", target, "--", "*.c"], # noqa: S607 -- fixed argv, trusted tool
278 cwd=_repo_root(),
279 capture_output=True,
280 text=True,
281 check=False,
282 )
283 if proc.returncode == 0 and proc.stdout.strip():
284 for line in proc.stdout.splitlines():
285 p = _repo_root() / line.strip()
286 if p.is_file() and p.suffix == ".c":
287 c_files.append(p)
288 break
289 if not c_files:
290 proc = subprocess.run(
291 ["git", "diff", "--name-only", "--", "*.c"], # noqa: S607 -- fixed argv, trusted tool
292 cwd=_repo_root(),
293 capture_output=True,
294 text=True,
295 check=False,
296 )
297 if proc.returncode == 0 and proc.stdout.strip():
298 for line in proc.stdout.splitlines():
299 p = _repo_root() / line.strip()
300 if p.is_file() and p.suffix == ".c":
301 c_files.append(p)
302 return sorted(set(c_files))
303
304
305def _selftest_basic(test_dir: Path) -> list[str]:
306 """Prove that unused includes are flagged while required ones are kept."""
307 failures: list[str] = []
308 c_file = test_dir / "bad.c"
309 c_file.write_text(
310 """#include <stdint.h>
311#include <stdbool.h>
312
313static uint32_t compute(void) {
314 return 42U;
315}
316""",
317 encoding="utf-8",
318 )
319 unused = check_file(c_file, {}, verbose=False)
320 inc_names = [item[1] for item in unused]
321 if "#include <stdbool.h>" not in inc_names:
322 failures.append("selftest: <stdbool.h> was not flagged as unused")
323 if "#include <stdint.h>" in inc_names:
324 failures.append("selftest: <stdint.h> was falsely flagged as unused")
325 return failures
326
327
328def _selftest_guarded(test_dir: Path) -> list[str]:
329 """Prove guarded includes stay quiet while top-level dead ones fire."""
330 failures: list[str] = []
331 guarded = test_dir / "guarded.c"
332 guarded.write_text(
333 """#include <stdint.h>
334#include <stdbool.h>
335#ifdef __ARM_FEATURE_MVE
336#include <dead_guarded.h>
337#endif
338
339static uint32_t compute(void) {
340 return 42U;
341}
342""",
343 encoding="utf-8",
344 )
345 guarded_unused = [item[1] for item in check_file(guarded, {}, verbose=False)]
346 if "#include <dead_guarded.h>" in guarded_unused:
347 failures.append("selftest: guarded include judged without its configuration")
348 if "#include <stdbool.h>" not in guarded_unused:
349 failures.append("selftest: top-level dead include beside a guard missed")
350 return failures
351
352
353def _selftest_keep_markers(test_dir: Path) -> list[str]:
354 """Prove validated keep markers stay quiet and dishonest ones fire."""
355 failures: list[str] = []
356 valid = test_dir / "valid.c"
357 valid.write_text(
358 """#include <stdint.h>
359#include <stdbool.h> // ra8-keep-include: `bool` used directly
360
361static bool ready(void) {
362 return true;
363}
364""",
365 encoding="utf-8",
366 )
367 valid_unused = [item[1] for item in check_file(valid, {}, verbose=False)]
368 if "#include <stdbool.h>" in valid_unused:
369 failures.append("selftest: validated keep marker was not honored")
370 forged = test_dir / "forged.c"
371 forged.write_text(
372 """#include <stdint.h>
373#include <stdbool.h> // ra8-keep-include: `missing_symbol_xyz` used directly
374
375static uint32_t compute(void) {
376 return 42U;
377}
378""",
379 encoding="utf-8",
380 )
381 forged_unused = [item[1] for item in check_file(forged, {}, verbose=False)]
382 if "#include <stdbool.h>" not in forged_unused:
383 failures.append("selftest: fabricated keep token stayed quiet")
384 generic = test_dir / "generic.c"
385 generic.write_text(
386 """#include <stdint.h>
387#include <stdbool.h> // ra8-keep-include: reviewed direct-use keep
388
389static uint32_t compute(void) {
390 return 42U;
391}
392""",
393 encoding="utf-8",
394 )
395 generic_unused = [item[1] for item in check_file(generic, {}, verbose=False)]
396 if "#include <stdbool.h>" not in generic_unused:
397 failures.append("selftest: generic prose keep marker stayed quiet")
398 bare = test_dir / "bare.c"
399 bare.write_text(
400 """#include <stdint.h>
401#include <stdbool.h> // ra8-keep-include:
402
403static uint32_t compute(void) {
404 return 42U;
405}
406""",
407 encoding="utf-8",
408 )
409 bare_unused = [item[1] for item in check_file(bare, {}, verbose=False)]
410 if "#include <stdbool.h>" not in bare_unused:
411 failures.append("selftest: bare keep marker without reason stayed quiet")
412 return failures
413
414
415def selftest() -> int:
416 """Prove that unused includes are flagged while required includes are kept."""
417 with tempfile.TemporaryDirectory(prefix="ra8-selftest-inc-") as tmp:
418 test_dir = Path(tmp)
419 failures = (
420 _selftest_basic(test_dir)
421 + _selftest_guarded(test_dir)
422 + _selftest_keep_markers(test_dir)
423 )
424 if failures:
425 for failure in failures:
426 sys.stderr.write(f"selftest: FAILED -- {failure}\n")
427 return 1
428 print("check_unused_includes.py: selftest OK")
429 return 0
430
431
432def main(argv: list[str]) -> int:
433 """Run include checker over given paths or git-modified files."""
434 parser = argparse.ArgumentParser(description=__doc__)
435 parser.add_argument(
436 "paths", nargs="*", help="C source files to check (defaults to branch-modified)"
437 )
438 parser.add_argument("--check", action="store_true", help="enforce zero unused includes")
439 parser.add_argument("--selftest", action="store_true", help="run regression selftest")
440 parser.add_argument("--verbose", "-v", action="store_true", help="verbose diagnostics")
441 args = parser.parse_args(argv[1:])
442
443 if args.selftest:
444 return selftest()
445
446 files: list[Path] = []
447 if args.paths:
448 for p in args.paths:
449 path = Path(p)
450 if path.is_file() and path.suffix in (".c", ".h"):
451 files.append(path.resolve())
452 else:
453 files = _git_changed_c_files()
454
455 if not files:
456 if args.verbose:
457 print("check_unused_includes.py: no C files in scope.")
458 return 0
459
460 db = _load_compile_db()
461
462 total_unused = 0
463 for f in files:
464 unused = check_file(f, db, verbose=args.verbose)
465 if unused:
466 for line_no, inc_text in unused:
467 print(f"{f}:{line_no}: unused include: {inc_text}")
468 total_unused += len(unused)
469
470 if total_unused > 0:
471 print(f"\ncheck_unused_includes.py: {total_unused} unused include(s) found.")
472 return 1
473 return 0
474
475
476if __name__ == "__main__":
477 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298