Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
check_header_selfcontained.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Check that every header compiles on its own.
3
4A header should compile standalone. One that does not is relying on whoever includes
5it having pulled something in first, which is the same defect as an unnecessary
6include seen from the other side: the dependency is real, and written nowhere. It
7breaks the day someone tidies an include in a file that never mentioned this header.
8
9Each header is compiled as a translation unit containing nothing but an include of
10itself, reusing the flags a real translation unit is built with, taken from
11compile_commands.json. Only the syntax pass runs (-fsyntax-only), so no object code
12is produced and the whole sweep is fast.
13
14Headers that are NOT expected to stand alone are skipped, and the reason is recorded
15rather than hidden:
16
17 - X-macro headers, re-included several times in one translation unit with different
18 macros defined, and expanded inside struct bodies. Compiling one alone is
19 meaningless: it has no guard, by design.
20 - Headers under a vendored or generated directory, which are not this project's to
21 fix.
22
23Usage:
24 python3 tools/check_header_selfcontained.py -p build -o selfcontained.json [--jobs N]
25"""
26
27import argparse
28import json
29import os
30import shlex
31import subprocess
32import sys
33import re
34import tempfile
35from concurrent.futures import ThreadPoolExecutor
36
37# Re-included on purpose, with different macros defined each time, and expanded inside
38# struct bodies. They have no include guard by design, so "does it compile alone?" is
39# not a question that applies to them.
40X_MACRO_HEADERS = (
41 "common/module_api.h",
42 "views/view_api.h",
43 "libs/lib_api.h",
44 "imageio/format/imageio_format_api.h",
45 "imageio/storage/imageio_storage_api.h",
46 "iop/iop_api.h",
47)
48
49ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
50
51SKIP_DIR_PARTS = ("/external/", "/tests/integration/", "/doxygen-awesome-css/",
52 "/build/", "/CMakeFiles/")
53
54
55def flags_from_database(build_dir):
56 """Take the compile flags of a representative C translation unit."""
57 path = os.path.join(build_dir, "compile_commands.json")
58 with open(path, encoding="utf-8") as fh:
59 entries = json.load(fh)
60 best = None
61 for e in entries:
62 f = e.get("file", "")
63 if any(p in f for p in SKIP_DIR_PARTS) or not f.endswith(".c"):
64 continue
65 # prefer a plain, central translation unit over a generated or odd one
66 if best is None or ("/common/" in f and "/common/" not in best.get("file", "")):
67 best = e
68 if best is None:
69 raise SystemExit("no usable C entry in compile_commands.json")
70
71 argv = best.get("arguments") or shlex.split(best.get("command", ""))
72
73 # Flags whose value is a SEPARATE argv entry. Keeping the flag and dropping the
74 # path that follows it silently removes an include directory: -isystem
75 # /usr/include/glib-2.0 becomes a bare -isystem, glib.h stops being findable, and
76 # every header that reaches glib fails for a reason that has nothing to do with
77 # the header. That produced a "10% self-contained" reading before it was caught.
78 TAKES_VALUE = ("-isystem", "-I", "-D", "-U", "-include", "-imacros",
79 "-idirafter", "-iquote", "-iprefix", "-isysroot", "--sysroot")
80 DROP_WITH_VALUE = ("-o", "-c", "-MF", "-MT", "-MQ")
81
82 keep, i = [], 1
83 while i < len(argv):
84 a = argv[i]
85 if a in DROP_WITH_VALUE:
86 i += 2
87 continue
88 if a.endswith((".c", ".cc", ".cpp", ".o")) or a in ("-MD", "-MMD"):
89 i += 1
90 continue
91 if a in TAKES_VALUE and i + 1 < len(argv):
92 keep.extend([a, argv[i + 1]])
93 i += 2
94 continue
95 if a.startswith(("-I", "-D", "-i", "-std", "-f", "-m", "-U", "-W", "-pthread",
96 "-O", "-g")):
97 keep.append(a)
98 i += 1
99 return argv[0], keep, best.get("directory", build_dir)
100
101
102def main():
103 ap = argparse.ArgumentParser(description=__doc__,
104 formatter_class=argparse.RawDescriptionHelpFormatter)
105 ap.add_argument("-p", "--build", required=True, help="dir holding compile_commands.json")
106 ap.add_argument("-s", "--source-dir", default="src")
107 ap.add_argument("-o", "--out", default="selfcontained.json")
108 ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 2)))
109 args = ap.parse_args()
110
111 compiler, flags, workdir = flags_from_database(args.build)
112 sys.stderr.write("selfcontained: %s with %d flags\n" % (compiler, len(flags)))
113
114 headers, skipped = [], []
115 for dirpath, dirnames, filenames in os.walk(args.source_dir):
116 rel_dir = "/" + dirpath.replace(os.sep, "/").strip("/") + "/"
117 if any(p in rel_dir for p in SKIP_DIR_PARTS):
118 dirnames[:] = []
119 continue
120 for name in filenames:
121 if not name.endswith((".h", ".hpp")):
122 continue
123 rel = os.path.join(dirpath, name).replace(os.sep, "/")
124 if any(rel.endswith(x) for x in X_MACRO_HEADERS):
125 skipped.append({"header": rel, "reason": "X-macro header, no guard by design"})
126 continue
127 headers.append(rel)
128 headers.sort()
129 sys.stderr.write("selfcontained: %d headers, %d skipped\n" % (len(headers), len(skipped)))
130
131 def check(rel):
132 src = '#include "%s"\n' % os.path.abspath(rel)
133 with tempfile.NamedTemporaryFile("w", suffix=".c", delete=False) as tf:
134 tf.write(src)
135 tmp = tf.name
136 try:
137 # LC_ALL=C: the compiler's diagnostics are parsed below, and a localised
138 # build reports "erreur:" rather than "error:", which silently turns every
139 # extracted message into the wrong line.
140 env = dict(os.environ, LC_ALL="C", LANG="C")
141 r = subprocess.run([compiler] + flags
142 + ["-fdiagnostics-color=never", "-fsyntax-only", tmp],
143 capture_output=True, text=True, errors="replace",
144 cwd=workdir, check=False, env=env)
145 ok = r.returncode == 0
146 first = ""
147 if not ok:
148 # Belt and braces: a build configured with colour forced on ignores
149 # -fdiagnostics-color=never, and the escape codes break the match.
150 clean = ANSI.sub("", r.stderr)
151 for line in clean.splitlines():
152 if ": error:" in line or ": fatal error:" in line:
153 first = line.strip()
154 break
155 else:
156 first = (clean.strip().splitlines() or [""])[0]
157 return {"header": rel, "ok": ok, "first_error": first}
158 finally:
159 os.unlink(tmp)
160
161 with ThreadPoolExecutor(max_workers=args.jobs) as pool:
162 results = list(pool.map(check, headers))
163
164 bad = [r for r in results if not r["ok"]]
165 payload = {"results": results, "skipped": skipped,
166 "headers": len(results), "failing": len(bad)}
167 with open(args.out, "w", encoding="utf-8") as fh:
168 json.dump(payload, fh, indent=1)
169 sys.stderr.write("selfcontained: %d/%d self-contained (%.1f%%), wrote %s\n"
170 % (len(results) - len(bad), len(results),
171 100.0 * (len(results) - len(bad)) / max(1, len(results)), args.out))
172 return 0
173
174
175if __name__ == "__main__":
176 sys.exit(main())
const float max