blob: e6e495f692e88836c9d70591cd6737d55d2b00b6 [file]
#!/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())