| #!/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 json | |
| 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 = None, | |
| return_fixed: bool = False, | |
| ) -> str | None: | |
| """Check or format the header guard for a given header file.""" | |
| if path.suffix != ".h": | |
| return None | |
| expected_guard = _header_guard_from_path(path, root) | |
| if collision_tracker is not None: | |
| 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 and not return_fixed: | |
| return None | |
| # Standard include guard match | |
| is_valid = found_ifndef and found_define and found_endif | |
| if is_valid: | |
| if emit: | |
| sys.stdout.write(content) | |
| return None | |
| # Guard needs fix or emission of corrected content. | |
| # In check mode, report the specific missing component. | |
| if not fix_guards and not emit and not return_fixed: | |
| 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 return_fixed: | |
| return fixed_content if fixed_content != content else None | |
| if emit: | |
| sys.stdout.write(fixed_content) | |
| return None | |
| 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 | |
| return None | |
| 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( | |
| "--json", | |
| help="Emit JSON array of findings/replacements to stdout without modifying disk.", | |
| action="store_true", | |
| ) | |
| parser.add_argument( | |
| "--output", | |
| type=pathlib.Path, | |
| help="With --json, write the JSON array to this file instead of stdout.", | |
| ) | |
| 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() | |
| ) | |
| if args.json: | |
| results = [] | |
| for path_arg in args.paths: | |
| path = pathlib.Path(path_arg) | |
| if not path.is_file(): | |
| results.append( | |
| { | |
| "path": path_arg, | |
| "error": f"Path does not exist: {path_arg}", | |
| } | |
| ) | |
| continue | |
| try: | |
| fixed = _check_file( | |
| path, | |
| root, | |
| fix_guards=False, | |
| emit=False, | |
| return_fixed=True, | |
| ) | |
| if fixed is not None: | |
| results.append({"path": path_arg, "replacements": [fixed]}) | |
| except HeaderGuardError as e: | |
| results.append({"path": path_arg, "error": str(e)}) | |
| # The results include the full fixed contents of each file with a wrong | |
| # header guard, which can exceed shac's limit on the size of a | |
| # subprocess's stdout. | |
| if args.output: | |
| args.output.write_text(json.dumps(results), encoding="utf-8") | |
| else: | |
| print(json.dumps(results)) | |
| return 0 | |
| 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()) |