| #!/usr/bin/env python3 |
| """ |
| check_extern_c_guards.py |
| |
| Git commit checker for pico-sdk header files. |
| Ensures every .h file contains the standard C++ linkage guard: |
| |
| #ifdef __cplusplus |
| extern "C" { |
| #endif |
| |
| Usage: |
| # Check all headers under a directory (defaults to current dir) |
| python check_extern_c_guards.py [path/to/pico-sdk] |
| |
| # Check only files staged for commit (pre-commit hook mode) |
| python check_extern_c_guards.py --staged |
| |
| # Check files changed in a specific commit |
| python check_extern_c_guards.py --commit HEAD |
| |
| # Check files changed in a range (useful for CI on PRs) |
| python check_extern_c_guards.py --range origin/main..HEAD |
| |
| # Only check headers whose path matches a pattern |
| python check_extern_c_guards.py --match "src/rp2_common/**/include/**" |
| python check_extern_c_guards.py --match "**/include/pico/**" |
| |
| # Combine match and exclude for fine-grained control |
| python check_extern_c_guards.py --match "**/include/**" --exclude "**/include/pico/config_autogen.h" |
| |
| # Skip files listed in an ignore list (reviewed, don't need guards) |
| python check_extern_c_guards.py --ignore-list .extern_c_ignore |
| |
| # Check only specific files from a list (e.g. to verify your ignore list) |
| python check_extern_c_guards.py /path/to/pico-sdk --include-list .extern_c_ignore |
| |
| # Exclude specific files or directories |
| python check_extern_c_guards.py --exclude "test/**" --exclude "host/**" |
| |
| # Install as a git pre-commit hook |
| python check_extern_c_guards.py --install-hook |
| |
| Exit codes: |
| 0 - All checked headers contain the guard |
| 1 - One or more headers are missing the guard |
| """ |
| |
| import argparse |
| import fnmatch |
| import os |
| import re |
| import subprocess |
| import sys |
| from pathlib import Path |
| |
| |
| # --------------------------------------------------------------------------- |
| # Pattern |
| # --------------------------------------------------------------------------- |
| |
| # Matches the opening guard, tolerating varied whitespace and brace style. |
| # We look for: |
| # #ifdef __cplusplus (with optional leading/trailing whitespace) |
| # extern "C" { (with optional whitespace around tokens) |
| # #endif (with optional leading/trailing whitespace) |
| # |
| # Lines between them may contain blank lines or comments. |
| |
| GUARD_PATTERN = re.compile( |
| r'#\s*ifdef\s+__cplusplus\s*\n' # #ifdef __cplusplus |
| r'(?:\s*(?://[^\n]*)?\n)*' # optional blank / comment lines |
| r'\s*extern\s+"C"\s*\{\s*\n' # extern "C" { |
| r'(?:\s*(?://[^\n]*)?\n)*' # optional blank / comment lines |
| r'\s*#\s*endif', # #endif |
| re.MULTILINE, |
| ) |
| |
| # Files that are commonly exempt (pure macro / config headers with no symbols). |
| DEFAULT_EXCLUDES = [ |
| "**/pico/config_autogen.h", |
| "**/generated/**", |
| ] |
| |
| |
| # --------------------------------------------------------------------------- |
| # Helpers |
| # --------------------------------------------------------------------------- |
| |
| def git(*args: str) -> str: |
| """Run a git command and return stripped stdout.""" |
| result = subprocess.run( |
| ["git", *args], |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| return result.stdout.strip() |
| |
| |
| def is_excluded(filepath: str, excludes: list[str]) -> bool: |
| """Check whether a file matches any exclusion glob.""" |
| for pattern in excludes: |
| if fnmatch.fnmatch(filepath, pattern): |
| return True |
| return False |
| |
| |
| def is_matched(filepath: str, matches: list[str]) -> bool: |
| """Check whether a file matches at least one inclusion glob. |
| |
| Returns True if no match patterns are specified (i.e. everything matches |
| by default), or if the filepath matches at least one of the patterns. |
| """ |
| if not matches: |
| return True |
| return any(fnmatch.fnmatch(filepath, pat) for pat in matches) |
| |
| |
| def load_path_list(filepath: str) -> list[str]: |
| """Load a file containing one path per line. |
| |
| Blank lines and lines starting with ``#`` are treated as comments. |
| Leading/trailing whitespace is stripped and paths are normalised to |
| forward slashes so the file works cross-platform. |
| """ |
| entries: list[str] = [] |
| try: |
| with open(filepath, encoding="utf-8") as fh: |
| for raw_line in fh: |
| line = raw_line.strip() |
| if not line or line.startswith("#"): |
| continue |
| normalised = line.replace("\\", "/").rstrip("/") |
| entries.append(normalised) |
| except FileNotFoundError: |
| print(f"Warning: path list not found: {filepath}", file=sys.stderr) |
| except Exception as exc: |
| print(f"Warning: could not read path list {filepath}: {exc}", file=sys.stderr) |
| return entries |
| |
| |
| def is_ignored(filepath: str, ignore_set: set[str]) -> bool: |
| """Check whether a file's relative path appears in the ignore set. |
| |
| Compares using normalised forward-slash paths. Also matches if any |
| parent directory of the file is listed (so you can ignore a whole |
| subtree by listing the directory). |
| """ |
| if not ignore_set: |
| return False |
| normalised = filepath.replace("\\", "/").rstrip("/") |
| if normalised in ignore_set: |
| return True |
| # Check parent directories so "src/foo" ignores everything under it |
| parts = normalised.split("/") |
| for i in range(1, len(parts)): |
| if "/".join(parts[:i]) in ignore_set: |
| return True |
| return False |
| |
| |
| def has_extern_c_guard(content: str) -> bool: |
| """Return True if the file content contains the extern C guard.""" |
| return GUARD_PATTERN.search(content) is not None |
| |
| |
| def is_header_file(path: str) -> bool: |
| """Return True for .h files.""" |
| return path.endswith(".h") |
| |
| |
| def check_file(filepath: str) -> dict: |
| """ |
| Check a single file. Returns a dict with: |
| path - the file path |
| status - 'ok', 'missing', 'skipped', or 'error' |
| detail - human-readable explanation (only for non-ok) |
| """ |
| try: |
| content = Path(filepath).read_text(encoding="utf-8", errors="replace") |
| except FileNotFoundError: |
| return {"path": filepath, "status": "error", "detail": "File not found"} |
| except Exception as exc: |
| return {"path": filepath, "status": "error", "detail": str(exc)} |
| |
| # Skip very short / empty stubs |
| stripped = content.strip() |
| if not stripped or stripped.count("\n") < 3: |
| return {"path": filepath, "status": "skipped", "detail": "Too short / stub header"} |
| |
| if has_extern_c_guard(content): |
| return {"path": filepath, "status": "ok"} |
| |
| return { |
| "path": filepath, |
| "status": "missing", |
| "detail": 'Missing #ifdef __cplusplus / extern "C" guard', |
| } |
| |
| |
| # --------------------------------------------------------------------------- |
| # File collection strategies |
| # --------------------------------------------------------------------------- |
| |
| def collect_all_headers(root: str, excludes: list[str], matches: list[str] | None = None) -> list[str]: |
| """Walk the tree and return all .h files.""" |
| headers = [] |
| for dirpath, _dirs, filenames in os.walk(root): |
| for fname in filenames: |
| if not is_header_file(fname): |
| continue |
| full = os.path.join(dirpath, fname) |
| rel = os.path.relpath(full, root) |
| if not is_excluded(rel, excludes) and is_matched(rel, matches or []): |
| headers.append(full) |
| headers.sort() |
| return headers |
| |
| |
| def collect_staged(excludes: list[str], matches: list[str] | None = None) -> list[str]: |
| """Return staged .h files (for pre-commit hook).""" |
| raw = git("diff", "--cached", "--name-only", "--diff-filter=ACM") |
| if not raw: |
| return [] |
| return [ |
| f for f in raw.splitlines() |
| if is_header_file(f) and not is_excluded(f, excludes) and is_matched(f, matches or []) |
| ] |
| |
| |
| def collect_commit(ref: str, excludes: list[str], matches: list[str] | None = None) -> list[str]: |
| """Return .h files changed in a specific commit.""" |
| raw = git("diff-tree", "--no-commit-id", "--name-only", "-r", "--diff-filter=ACM", ref) |
| if not raw: |
| return [] |
| return [ |
| f for f in raw.splitlines() |
| if is_header_file(f) and not is_excluded(f, excludes) and is_matched(f, matches or []) |
| ] |
| |
| |
| def collect_range(range_spec: str, excludes: list[str], matches: list[str] | None = None) -> list[str]: |
| """Return .h files changed across a commit range.""" |
| raw = git("diff", "--name-only", "--diff-filter=ACM", range_spec) |
| if not raw: |
| return [] |
| return [ |
| f for f in raw.splitlines() |
| if is_header_file(f) and not is_excluded(f, excludes) and is_matched(f, matches or []) |
| ] |
| |
| |
| # --------------------------------------------------------------------------- |
| # Hook installer |
| # --------------------------------------------------------------------------- |
| |
| HOOK_SCRIPT = """\ |
| #!/bin/sh |
| # Auto-installed by check_extern_c_guards.py |
| exec python3 "{script}" --staged |
| """ |
| |
| |
| def install_hook(): |
| """Install this script as a git pre-commit hook.""" |
| try: |
| hooks_dir = git("rev-parse", "--git-path", "hooks") |
| except subprocess.CalledProcessError: |
| print("Error: not inside a git repository.", file=sys.stderr) |
| return 1 |
| |
| hook_path = Path(hooks_dir) / "pre-commit" |
| script_path = Path(__file__).resolve() |
| |
| if hook_path.exists(): |
| print(f"Warning: {hook_path} already exists.", file=sys.stderr) |
| answer = input("Overwrite? [y/N] ").strip().lower() |
| if answer != "y": |
| print("Aborted.") |
| return 1 |
| |
| hook_path.write_text(HOOK_SCRIPT.format(script=script_path)) |
| hook_path.chmod(0o755) |
| print(f"Installed pre-commit hook at {hook_path}") |
| return 0 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Reporting |
| # --------------------------------------------------------------------------- |
| |
| COLOUR_RED = "\033[91m" |
| COLOUR_GREEN = "\033[92m" |
| COLOUR_YELLOW = "\033[93m" |
| COLOUR_RESET = "\033[0m" |
| COLOUR_BOLD = "\033[1m" |
| |
| |
| def supports_colour() -> bool: |
| return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() |
| |
| |
| def report(results: list[dict], verbose: bool = False) -> int: |
| """Print results and return the exit code (0 = pass, 1 = fail).""" |
| colour = supports_colour() |
| |
| missing = [r for r in results if r["status"] == "missing"] |
| errors = [r for r in results if r["status"] == "error"] |
| skipped = [r for r in results if r["status"] == "skipped"] |
| ok = [r for r in results if r["status"] == "ok"] |
| |
| if verbose: |
| for r in ok: |
| tag = f"{COLOUR_GREEN}PASS{COLOUR_RESET}" if colour else "PASS" |
| print(f" {tag} {r['path']}") |
| |
| for r in skipped: |
| tag = f"{COLOUR_YELLOW}SKIP{COLOUR_RESET}" if colour else "SKIP" |
| print(f" {tag} {r['path']} ({r['detail']})") |
| |
| for r in errors: |
| tag = f"{COLOUR_RED}ERR {COLOUR_RESET}" if colour else "ERR " |
| print(f" {tag} {r['path']} ({r['detail']})") |
| |
| for r in missing: |
| tag = f"{COLOUR_RED}FAIL{COLOUR_RESET}" if colour else "FAIL" |
| print(f" {tag} {r['path']}") |
| |
| # Summary |
| total = len(results) |
| print() |
| if colour: |
| print(f"{COLOUR_BOLD}Summary:{COLOUR_RESET} ", end="") |
| else: |
| print("Summary: ", end="") |
| |
| print( |
| f"{len(ok)} passed, {len(missing)} failed, " |
| f"{len(skipped)} skipped, {len(errors)} errors " |
| f"({total} headers checked)" |
| ) |
| |
| if missing: |
| print() |
| print("The following headers need an extern \"C\" guard added:") |
| print() |
| print(" #ifdef __cplusplus") |
| print(" extern \"C\" {") |
| print(" #endif") |
| print() |
| print(" // ... declarations ...") |
| print() |
| print(" #ifdef __cplusplus") |
| print(" }") |
| print(" #endif") |
| print() |
| return 1 |
| |
| return 0 |
| |
| |
| # --------------------------------------------------------------------------- |
| # CLI |
| # --------------------------------------------------------------------------- |
| |
| def build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser( |
| description="Check that pico-sdk .h files contain extern \"C\" guards.", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=__doc__, |
| ) |
| mode = parser.add_mutually_exclusive_group() |
| mode.add_argument( |
| "path", |
| nargs="?", |
| default=".", |
| help="Root directory to scan (default: current directory)", |
| ) |
| mode.add_argument( |
| "--staged", |
| action="store_true", |
| help="Check only git-staged .h files (pre-commit hook mode)", |
| ) |
| mode.add_argument( |
| "--commit", |
| metavar="REF", |
| help="Check .h files changed in a specific commit", |
| ) |
| mode.add_argument( |
| "--range", |
| metavar="RANGE", |
| help="Check .h files changed across a commit range (e.g. origin/main..HEAD)", |
| ) |
| mode.add_argument( |
| "--install-hook", |
| action="store_true", |
| help="Install this script as a git pre-commit hook", |
| ) |
| parser.add_argument( |
| "--match", |
| action="append", |
| default=[], |
| help="Glob pattern to include — only matching paths are checked (may be repeated)", |
| ) |
| parser.add_argument( |
| "--exclude", |
| action="append", |
| default=[], |
| help="Glob pattern to exclude (may be repeated)", |
| ) |
| parser.add_argument( |
| "--ignore-list", |
| metavar="FILE", |
| help=( |
| "Path to a file listing headers to skip (one per line). " |
| "These are files that have been reviewed and confirmed not to " |
| "need extern C guards. Lines starting with # are comments." |
| ), |
| ) |
| parser.add_argument( |
| "--include-list", |
| metavar="FILE", |
| help=( |
| "Path to a file listing headers to check (one per line). " |
| "When given, ONLY these files are checked — the normal directory " |
| "walk or git diff is skipped. Paths are resolved relative to the " |
| "scan root. Useful for re-verifying your ignore list. " |
| "Uses the same file format as --ignore-list (# comments, blanks ok)." |
| ), |
| ) |
| parser.add_argument( |
| "-v", "--verbose", |
| action="store_true", |
| help="Show passing files as well", |
| ) |
| return parser |
| |
| |
| def main() -> int: |
| parser = build_parser() |
| args = parser.parse_args() |
| |
| if args.install_hook: |
| return install_hook() |
| |
| excludes = DEFAULT_EXCLUDES + args.exclude |
| matches = args.match # empty list means "match everything" |
| ignore_set = set(load_path_list(args.ignore_list)) if args.ignore_list else set() |
| |
| # Collect files based on mode |
| if args.include_list: |
| # --include-list: check exactly these files, resolved against root |
| root = os.path.abspath(args.path) if not (args.staged or args.commit or args.range) else os.path.abspath(".") |
| raw_paths = load_path_list(args.include_list) |
| files = [] |
| for p in raw_paths: |
| full = os.path.join(root, p) |
| rel = os.path.relpath(full, root) |
| if is_header_file(rel) and not is_excluded(rel, excludes) and is_matched(rel, matches or []): |
| files.append(full) |
| files.sort() |
| label = f"headers from {args.include_list}" |
| rel_root = root |
| elif args.staged: |
| files = collect_staged(excludes, matches) |
| label = "staged headers" |
| rel_root = None |
| elif args.commit: |
| files = collect_commit(args.commit, excludes, matches) |
| label = f"headers in commit {args.commit}" |
| rel_root = None |
| elif args.range: |
| files = collect_range(args.range, excludes, matches) |
| label = f"headers in range {args.range}" |
| rel_root = None |
| else: |
| root = os.path.abspath(args.path) |
| files = collect_all_headers(root, excludes, matches) |
| label = f"headers under {root}" |
| rel_root = root |
| |
| if matches: |
| label += f" matching {matches}" |
| |
| # Partition into files to check vs files on the ignore list |
| to_check = [] |
| ignored_results = [] |
| for f in files: |
| rel = os.path.relpath(f, rel_root) if rel_root else f |
| if is_ignored(rel, ignore_set): |
| ignored_results.append({ |
| "path": f, |
| "status": "skipped", |
| "detail": "In ignore list", |
| }) |
| else: |
| to_check.append(f) |
| |
| if not files: |
| print(f"No .h files found ({label}).") |
| return 0 |
| |
| print(f"Checking {len(to_check)} {label}" |
| f"{f' ({len(ignored_results)} ignored)' if ignored_results else ''}...\n") |
| |
| results = [check_file(f) for f in to_check] + ignored_results |
| return report(results, verbose=args.verbose) |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |
| |