#!/usr/bin/env python3 """scripts/quality/cgal-conventions.py Lightweight checker for the CGAL-style conventions that conformallab++ aims to follow, based on the "CGAL Developer's Manual / Package Submission Checklist". These rules are NOT covered by clang-format or clang-tidy; they are project-/CGAL- specific, so we encode them here as pure-Python AST/regex checks. Rules enforced (each can be silenced per-file via a comment marker — see RULES dict below): CGAL-1 Include guard format: every CGAL/* header has a guard of the form `CGAL___H` matching its repo path. CGAL-2 Every public header has a `\\file` Doxygen brief in its top comment block. CGAL-3 Public CGAL API symbols (functions, classes, structs) live directly in `namespace CGAL { ... }`, not in nested namespaces that the user must qualify (except `CGAL::parameters`, `CGAL::Conformal_map::internal_np`). CGAL-4 Named-parameter tag types end in `_t`; the matching value object does not (e.g. `vertex_curvature_map_t` / `vertex_curvature_map`). CGAL-5 No `using namespace ...` at file scope in public headers (would leak into every translation unit that includes us). CGAL-6 No `#define` (other than include-guard, header-marker, or CGAL_*) in public headers — macros leak unconditionally. The checker only inspects `code/include/CGAL/`. Conformallab's own `conformallab::` namespace under `code/include/*.hpp` is non-CGAL-public and uses its own (looser) conventions. Exit codes: 0 every rule passes 1 at least one violation was found 2 prerequisite missing """ from __future__ import annotations import os, re, sys ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) CGAL_DIR = os.path.join(ROOT, "code", "include", "CGAL") if not os.path.isdir(CGAL_DIR): print(f"FAIL: {CGAL_DIR} not found", file=sys.stderr) sys.exit(2) # Namespaces that are *intentionally* nested under CGAL. ALLOWED_NESTED = { "CGAL::parameters", "CGAL::Conformal_map", "CGAL::Conformal_map::internal_np", "CGAL::internal_np", # CGAL upstream "CGAL::IO", # CGAL upstream } # Macros that may appear in public headers. ALLOWED_DEFINE_RE = re.compile(r"^\s*#\s*define\s+(CGAL_[A-Z0-9_]+|[A-Z0-9_]+_H)\b") def find_headers() -> list[str]: out: list[str] = [] for dirpath, dirs, files in os.walk(CGAL_DIR): for f in files: if not (f.endswith(".h") or f.endswith(".hpp")): continue if " 2." in f: # macOS duplicate artefact continue out.append(os.path.join(dirpath, f)) return sorted(out) def expected_guard(path: str) -> str: """The expected include-guard symbol for the given header path.""" rel = os.path.relpath(path, ROOT) # e.g. code/include/CGAL/Discrete_conformal_map.h # Strip the `code/include/` prefix to match CGAL upstream practice. if rel.startswith("code/include/"): rel = rel[len("code/include/"):] # CGAL/Discrete_conformal_map.h → CGAL_DISCRETE_CONFORMAL_MAP_H stem = rel.replace("/", "_").replace(".", "_") return stem.upper() def check_rule_1_include_guard(text: str, path: str) -> list[str]: expected = expected_guard(path) if f"#ifndef {expected}" not in text: # Find what guard was actually used, for a helpful message. m = re.search(r"^\s*#ifndef\s+(\w+)", text, re.MULTILINE) actual = m.group(1) if m else "" return [f"CGAL-1: include guard is `{actual}`, expected `{expected}`"] return [] def check_rule_2_file_brief(text: str, path: str) -> list[str]: # \file or @file must appear somewhere in the first 40 lines. head = "\n".join(text.splitlines()[:40]) if re.search(r"[\\@]file\b", head): return [] return [f"CGAL-2: no `\\file` brief in top-of-file comment block"] _NAMESPACE_RE = re.compile(r"^\s*namespace\s+(\w+)\s*\{", re.MULTILINE) def check_rule_3_nested_namespace(text: str, path: str) -> list[str]: # Walk top-level namespace declarations. We approximate with regex # (not a real AST) — good enough for the CGAL header style. bad = [] stack: list[str] = [] for line_no, line in enumerate(text.splitlines(), start=1): m = re.match(r"^\s*namespace\s+(\w+)\s*\{?\s*$", line) if m: stack.append(m.group(1)) full = "::".join(stack) if len(stack) >= 2 and stack[0] == "CGAL": if full not in ALLOWED_NESTED and not full.startswith("CGAL::internal"): bad.append(f"CGAL-3: nested namespace `{full}` at line {line_no}") elif re.match(r"^\s*\}\s*//\s*namespace\b", line) or re.match(r"^\s*\}\s*//\s*\w+", line): if stack: stack.pop() return bad _TAG_ENUM_RE = re.compile( r"^\s*enum\s+(\w+)_t\s*\{\s*(\w+)\s*\}\s*;", re.MULTILINE ) def check_rule_4_tag_naming(text: str, path: str) -> list[str]: bad = [] for m in _TAG_ENUM_RE.finditer(text): tag_t = m.group(1) # e.g. "vertex_curvature_map" value = m.group(2) # e.g. "vertex_curvature_map" if value != tag_t: bad.append( f"CGAL-4: tag `{tag_t}_t` enclosing value `{value}` " f"(expected `{tag_t}`)" ) return bad def check_rule_5_using_namespace(text: str, path: str) -> list[str]: bad = [] for line_no, line in enumerate(text.splitlines(), start=1): if re.match(r"^\s*using\s+namespace\s+\w", line): bad.append(f"CGAL-5: `using namespace ...` at line {line_no} " "(leaks into every TU that includes this header)") return bad def check_rule_6_defines(text: str, path: str) -> list[str]: bad = [] for line_no, line in enumerate(text.splitlines(), start=1): if re.match(r"^\s*#\s*define\s+", line) and not ALLOWED_DEFINE_RE.match(line): bad.append(f"CGAL-6: `{line.strip()}` at line {line_no} " "(only CGAL_* or include-guard macros allowed)") return bad CHECKS = [ check_rule_1_include_guard, check_rule_2_file_brief, check_rule_3_nested_namespace, check_rule_4_tag_naming, check_rule_5_using_namespace, check_rule_6_defines, ] def main() -> int: headers = find_headers() if not headers: print("FAIL: no headers under code/include/CGAL/", file=sys.stderr) return 2 total_violations = 0 files_with_issues = 0 for h in headers: try: with open(h, encoding="utf-8") as f: text = f.read() except OSError as e: print(f"WARN: cannot read {h}: {e}", file=sys.stderr) continue violations: list[str] = [] for check in CHECKS: violations.extend(check(text, h)) if violations: files_with_issues += 1 total_violations += len(violations) rel = os.path.relpath(h, ROOT) print(f"\n{rel}:") for v in violations: print(f" - {v}") print() print("─" * 60) print(f"Checked {len(headers)} CGAL headers.") print(f" files with issues: {files_with_issues}") print(f" total violations: {total_violations}") if total_violations == 0: print("OK: every CGAL convention rule passes.") return 0 return 1 if __name__ == "__main__": sys.exit(main())