blob: 20b4b8ab35d3b33a850bc9353f558a2790c4374e [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Script to check C and C++ file header guards.
This script accepts a list of file or directory arguments. If a given
path is a file, it runs the checker on it. If the path is a directory,
it runs the checker on all .h files in that directory.
Supports:
--fix: Updates header guards in-place using atomic file writes.
--emit: Prints the corrected file content to stdout without writing to disk
(used by SHAC formatters running in read-only sandboxes).
--root: Sets the repository root against which relative paths are resolved.
"""
import argparse
import collections
import os
import pathlib
import re
import shutil
import sys
import tempfile
from collections.abc import Collection
# Prefixes that are stripped or adjusted to form canonical header guards.
_SYSROOT_PREFIXES = frozenset({"ZIRCON_THIRD_PARTY_ULIB_MUSL_INCLUDE"})
_SYSROOT_PREFIX_PATTERN = re.compile(
"^(" + "|".join(sorted(_SYSROOT_PREFIXES)) + ")_"
)
_PUBLIC_PREFIXES = frozenset(
{
"ZIRCON_SYSTEM_PUBLIC",
"ZIRCON_SYSTEM_ULIB_.*_INCLUDE",
"SDK(_.*_INCLUDE)?",
}
)
_PUBLIC_PREFIX_PATTERN = re.compile(
"^(" + "|".join(sorted(_PUBLIC_PREFIXES)) + ")_"
)
_PRAGMA_ONCE_PATTERN = re.compile(r"^\s*#\s*pragma\s+once\b")
_DISALLOWED_HEADER_CHARACTERS = re.compile(r"[^A-Z0-9_]")
_IFNDEF_PATTERN = re.compile(r"^\s*#\s*ifndef\s+(\S+)")
_DEFINE_PATTERN = re.compile(r"^\s*#\s*define\s+(\S+)")
_ENDIF_PATTERN = re.compile(r"^\s*#\s*endif\b")
class HeaderGuardError(Exception):
"""Base exception for header guard errors."""
class HeaderGuardFixError(HeaderGuardError, ValueError):
"""Raised when file content cannot be safely fixed."""
def _adjust_for_location(header_guard: str) -> str:
"""Remove internal location prefixes from public and sysroot headers."""
return _SYSROOT_PREFIX_PATTERN.sub(
"SYSROOT_",
_PUBLIC_PREFIX_PATTERN.sub("", header_guard, 1),
1,
)
def _header_guard_from_path(path: pathlib.Path, root: pathlib.Path) -> str:
"""Compute the canonical header guard from the file path relative to root."""
try:
relative_path = path.resolve().relative_to(root.resolve())
except ValueError as e:
# Avoid crashing on path resolution mismatches; provide clear diagnostic.
raise HeaderGuardError(
f"Path '{path}' is not within root '{root}'"
) from e
upper_path = str(relative_path).upper()
return _adjust_for_location(
f"{_DISALLOWED_HEADER_CHARACTERS.sub('_', upper_path)}_"
)
def _generate_fixed_content(content: str, expected_guard: str) -> str:
"""Generates the corrected file content with standard header guards.
Unlike blanket line-by-line regex replacements, this targets only the outer
guard directives (first #ifndef/#define and final closing #endif). This avoids
corrupting internal preprocessor checks such as '#ifndef __cplusplus' or
feature macros.
Raises:
HeaderGuardFixError: If the header is malformed and cannot be safely fixed.
"""
lines = content.splitlines(keepends=True)
if not lines:
return f"#ifndef {expected_guard}\n#define {expected_guard}\n\n#endif // {expected_guard}\n"
pragma_indices = tuple(
index
for index, line in enumerate(lines)
if _PRAGMA_ONCE_PATTERN.match(line) is not None
)
if len(pragma_indices) > 1:
# Multiple #pragma once indicates a malformed header that should not be auto-fixed.
raise HeaderGuardFixError(
"Multiple #pragma once directives indicate a malformed header that should not be auto-fixed."
)
# Convert #pragma once into standard Google/Fuchsia include guards.
if pragma_indices:
pragma_index = pragma_indices[0]
lines[
pragma_index
] = f"#ifndef {expected_guard}\n#define {expected_guard}\n"
if not lines[-1].endswith("\n"):
lines[-1] += "\n"
lines.append(f"\n#endif // {expected_guard}\n")
return "".join(lines)
# Find the outer guard: first #ifndef and last #endif
first_ifndef_index = None
first_ifndef_macro = None
for index, line in enumerate(lines):
if (ifndef_match := _IFNDEF_PATTERN.match(line)) is not None:
first_ifndef_index = index
first_ifndef_macro = ifndef_match.group(1)
break
last_endif_index = None
for index in range(len(lines) - 1, -1, -1):
if _ENDIF_PATTERN.match(lines[index]) is not None:
last_endif_index = index
break
if first_ifndef_index is not None and last_endif_index is not None:
if first_ifndef_index >= last_endif_index:
raise HeaderGuardFixError(
f"Malformed header guard: #ifndef at line {first_ifndef_index + 1} "
f"appears after #endif at line {last_endif_index + 1}."
)
# Find matching or immediately following #define
define_index = None
for index in range(first_ifndef_index + 1, last_endif_index):
if (
define_match := _DEFINE_PATTERN.match(lines[index])
) is not None:
if define_match.group(1) == first_ifndef_macro:
define_index = index
break
if define_index is None:
define_index = index
lines[first_ifndef_index] = f"#ifndef {expected_guard}\n"
if define_index is not None:
lines[define_index] = f"#define {expected_guard}\n"
else:
lines.insert(first_ifndef_index + 1, f"#define {expected_guard}\n")
last_endif_index += 1
lines[last_endif_index] = f"#endif // {expected_guard}\n"
return "".join(lines)
if first_ifndef_index is not None:
raise HeaderGuardFixError(
f"Malformed header guard: unmatched #ifndef at line {first_ifndef_index + 1}."
)
if last_endif_index is not None:
raise HeaderGuardFixError(
f"Malformed header guard: unmatched #endif at line {last_endif_index + 1}."
)
# If no existing header guard directives were found, insert at the top
# after any leading license / block comments.
insert_index = 0
in_block_comment = False
for index, line in enumerate(lines):
stripped = line.strip()
if in_block_comment:
if "*/" in stripped:
in_block_comment = False
insert_index = index + 1
elif stripped.startswith("/*"):
if "*/" not in stripped:
in_block_comment = True
insert_index = index + 1
elif stripped.startswith("//") or not stripped:
insert_index = index + 1
else:
break
guard_start = f"#ifndef {expected_guard}\n#define {expected_guard}\n\n"
lines.insert(insert_index, guard_start)
if not lines[-1].endswith("\n"):
lines[-1] += "\n"
lines.append(f"\n#endif // {expected_guard}\n")
return "".join(lines)
def _write_atomically(path: pathlib.Path, content: str) -> None:
"""Writes content to a tempfile in the target directory and renames atomically.
This prevents file corruption if the process crashes or is interrupted mid-write.
"""
temp_file_path = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=path.parent,
delete=False,
) as temp_file:
temp_file_path = pathlib.Path(temp_file.name)
temp_file.write(content)
shutil.copymode(path, temp_file_path)
temp_file_path.replace(path)
finally:
if temp_file_path is not None and temp_file_path.exists():
temp_file_path.unlink()
def _check_file(
path: pathlib.Path,
root: pathlib.Path,
fix_guards: bool,
emit: bool,
collision_tracker: dict[str, list[pathlib.Path]],
) -> None:
"""Check or format the header guard for a given header file."""
if path.suffix != ".h":
return
expected_guard = _header_guard_from_path(path, root)
collision_tracker[expected_guard].append(path)
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
raise HeaderGuardError(f"Error reading {path}: {e}") from e
lines = content.splitlines()
# Directives matching the expected guard
ifndef_re = re.compile(rf"^#ifndef\s+{re.escape(expected_guard)}$")
define_re = re.compile(rf"^#define\s+{re.escape(expected_guard)}$")
endif_re = re.compile(
rf"^#endif\s+(?://\s*|/\*\s*){re.escape(expected_guard)}(?:\s*\*/)?\s*$"
)
found_pragma_once = False
found_ifndef = False
found_define = False
found_endif = False
for line in lines:
if _PRAGMA_ONCE_PATTERN.match(line) is not None:
if found_pragma_once:
raise HeaderGuardError(f"{path} contains multiple #pragma once")
found_pragma_once = True
if ifndef_re.match(line) is not None:
if found_ifndef:
raise HeaderGuardError(
f"{path} contains multiple ifndef header guards"
)
found_ifndef = True
if define_re.match(line) is not None:
if found_define:
raise HeaderGuardError(
f"{path} contains multiple define header guards"
)
found_define = True
if endif_re.match(line) is not None:
if found_endif:
raise HeaderGuardError(
f"{path} contains multiple endif header guards"
)
found_endif = True
if found_pragma_once:
if found_ifndef or found_define or found_endif:
raise HeaderGuardError(
f"{path} contains both #pragma once and header guards"
)
# If neither fixing nor emitting, #pragma once is accepted as valid.
if not fix_guards and not emit:
return
# Standard include guard match
is_valid = found_ifndef and found_define and found_endif
if is_valid:
if emit:
sys.stdout.write(content)
return
# Guard needs fix or emission of corrected content.
# In check mode, report the specific missing component.
if not fix_guards and not emit:
if not found_ifndef:
raise HeaderGuardError(
f"{path} missing #ifndef part of header guard (expected: {expected_guard})"
)
if not found_define:
raise HeaderGuardError(
f"{path} missing #define part of header guard (expected: {expected_guard})"
)
raise HeaderGuardError(
f"{path} missing matching #endif comment (expected: #endif // {expected_guard})"
)
# In fix or emit mode, generate the fixed content.
fixed_content = _generate_fixed_content(content, expected_guard)
if emit:
sys.stdout.write(fixed_content)
return
assert fix_guards
try:
_write_atomically(path, fixed_content)
print(f"Fixed header guard in {path}")
except OSError as e:
raise HeaderGuardError(f"Error writing {path}: {e}") from e
def _find_header_files(
dir_path: pathlib.Path,
) -> Collection[pathlib.Path]:
"""Finds all .h files in dir_path, pruning hidden and third_party directories."""
headers = []
for current_root, dirs, files in os.walk(dir_path):
# Prune dot directories (e.g. .git) and third_party directories
dirs[:] = [
d for d in dirs if not d.startswith(".") and d != "third_party"
]
for file_name in files:
if file_name.endswith(".h"):
headers.append(pathlib.Path(current_root) / file_name)
return tuple(headers)
def _check_collisions(
collision_tracker: dict[str, list[pathlib.Path]],
) -> None:
"""Checks whether multiple files share the same computed header guard."""
collision_messages = []
for header_guard, paths in collision_tracker.items():
if len(paths) > 1:
formatted_paths = "\n".join(f" {path}" for path in paths)
collision_messages.append(
f"Multiple files could use {header_guard} as a header guard:\n{formatted_paths}"
)
if collision_messages:
raise HeaderGuardError("\n".join(collision_messages))
def main() -> int:
parser = argparse.ArgumentParser(
description="Check and fix C/C++ header guards."
)
parser.add_argument(
"--fix",
help="Correct wrong header guards in-place atomically.",
action="store_true",
)
parser.add_argument(
"--emit",
help="Emit corrected file content to stdout without modifying disk.",
action="store_true",
)
parser.add_argument(
"--root",
help="Root of the repository or checkout to calculate relative guards against.",
)
parser.add_argument(
"paths",
nargs="*",
help="Files or directories to check/fix.",
)
args = parser.parse_args()
default_root = pathlib.Path(__file__).resolve().parents[2]
root = (
pathlib.Path(args.root).resolve()
if args.root
else pathlib.Path(os.environ.get("FUCHSIA_DIR", default_root)).resolve()
)
collision_tracker: dict[str, list[pathlib.Path]] = collections.defaultdict(
list
)
all_ok = True
paths_to_check: list[pathlib.Path] = []
for path_arg in args.paths:
resolved_path = pathlib.Path(path_arg).resolve()
if resolved_path.is_dir():
paths_to_check.extend(_find_header_files(resolved_path))
elif resolved_path.is_file():
paths_to_check.append(resolved_path)
else:
print(f"Path does not exist: {resolved_path}", file=sys.stderr)
all_ok = False
for path in paths_to_check:
try:
_check_file(
path,
root,
fix_guards=args.fix,
emit=args.emit,
collision_tracker=collision_tracker,
)
except HeaderGuardError as e:
print(e, file=sys.stderr)
all_ok = False
try:
_check_collisions(collision_tracker)
except HeaderGuardError as e:
print(e, file=sys.stderr)
all_ok = False
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())