Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
header_includes_audit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Check that a header includes only what its own declarations need.
3
4A header that includes more than its signatures require becomes a supply line its consumers
5never asked for and cannot see: they compile because something upstream happened to pull in
6what they use, and the day anyone tidies that include away the breakage surfaces somewhere
7else entirely, in a file that was never touched.
8
9Two findings, opposite directions:
10
11 UNUSED - the header includes something none of its own declarations reference. Its
12 consumers may nonetheless be relying on it; that is the problem, not a reason to
13 keep it. Move the include to the .c, and give each consumer what it actually uses.
14 MISSING - the header names a type it does not include a definition for, and is getting it
15 transitively. It works today and breaks when the chain shortens.
16
17Reported symbol by symbol so each finding can be checked rather than trusted. Matching is
18textual -- it cannot see through macros, and it reads one preprocessor branch like every other
19tool here -- so treat MISSING as "verify this", and check UNUSED against a build before acting
20on it. A platform-only use (`#ifdef _WIN32`) will read as UNUSED on Linux; removing such an
21include is how this tree broke its Windows build once already.
22
23Usage:
24 tools/header_includes_audit.py src/gui/application.h [more headers...]
25 tools/header_includes_audit.py --all # every header under src/
26"""
27
28import os
29import re
30import sys
31
32REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
33SRC = os.path.join(REPO, "src")
34
35COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.S)
36COMMENT_LINE = re.compile(r"//[^\n]*")
37INCLUDE_LINE = re.compile(r'^\s*#\s*include\s+"([^"]+)"', re.M)
38
39DECLARE = [
40 re.compile(r"^\s*#\s*define\s+([A-Za-z_]\w*)"),
41 re.compile(r"^\s*}[^;]*?\b([A-Za-z_]\w*)\s*;"), # `} name;` and `} ATTR(..) name;`
42 re.compile(r"^\s*typedef\s+.*?\b([A-Za-z_]\w*)\s*;"),
43 re.compile(r"^\s*typedef\s+.*\‍(\s*\*\s*([A-Za-z_]\w*)\s*\‍)\s*\‍("), # function-pointer typedef
44 re.compile(r"^\s*(?:struct|union|enum)\s+([A-Za-z_]\w*)\s*[;{]"),
45 re.compile(r"^\s*(?:[A-Za-z_][\w \t*]*?[ \t*])([A-Za-z_]\w*)\s*\‍("),
46]
47ENUM_MEMBER = re.compile(r"^\s*([A-Z][A-Z0-9_]{2,})\s*(?:=|,|$)")
48
49# Keywords and primitives are matched by both sides and would pair every header with every
50# other one.
51NOISE = {
52 "if", "for", "while", "switch", "return", "sizeof", "defined", "else", "do",
53 "static", "inline", "const", "struct", "union", "enum", "typedef", "extern",
54 "void", "int", "char", "float", "double", "long", "short", "unsigned", "signed",
55 "gboolean", "gint", "guint", "gchar", "gpointer", "gdouble", "gfloat", "gsize",
56 "TRUE", "FALSE", "NULL",
57}
58
59
61 return COMMENT_LINE.sub(" ", COMMENT_BLOCK.sub(" ", text))
62
63
64def read(path):
65 try:
66 with open(path, errors="ignore") as fh:
67 return fh.read()
68 except OSError:
69 return ""
70
71
72def supplied_by(path):
73 """Identifiers `path` defines."""
74 found = set()
75 for line in strip_comments(read(path)).split("\n"):
76 for pat in DECLARE:
77 m = pat.match(line)
78 if m:
79 found.add(m.group(1))
80 m = ENUM_MEMBER.match(line)
81 if m:
82 found.add(m.group(1))
83 return {s for s in found if s not in NOISE and len(s) > 2}
84
85
86def resolve(inc, from_dir):
87 for cand in (os.path.join(SRC, inc), os.path.join(from_dir, inc)):
88 cand = os.path.normpath(cand)
89 if os.path.isfile(cand):
90 return cand
91 return None
92
93
94def closure(header, seen=None):
95 if seen is None:
96 seen = set()
97 for inc in INCLUDE_LINE.findall(read(header)):
98 target = resolve(inc, os.path.dirname(header))
99 if target and target not in seen:
100 seen.add(target)
101 closure(target, seen)
102 return seen
103
104
105def audit(header):
106 text = read(header)
107 body = strip_comments(text)
108 body = re.sub(r"^\s*#\s*include.*$", " ", body, flags=re.M)
109 words = set(re.findall(r"\b[A-Za-z_]\w*\b", body))
110
111 rel = os.path.relpath(header, REPO)
112 unused, used = [], []
113 for inc in INCLUDE_LINE.findall(text):
114 target = resolve(inc, os.path.dirname(header))
115 if not target:
116 continue
117 hit = sorted(supplied_by(target) & words)
118 (used if hit else unused).append((inc, hit))
119
120 # A type the header names but no direct include defines: arriving transitively.
121 direct = set()
122 for inc in INCLUDE_LINE.findall(text):
123 target = resolve(inc, os.path.dirname(header))
124 if target:
125 direct |= supplied_by(target)
126 missing = []
127 for dep in closure(header):
128 for sym in (supplied_by(dep) & words) - direct:
129 missing.append((sym, os.path.relpath(dep, SRC)))
130
131 if not unused and not missing:
132 return 0
133 print(f"--- {rel} ---")
134 for inc, _ in unused:
135 print(f" UNUSED {inc} (no declaration here references it)")
136 for sym, owner in sorted(set(missing)):
137 print(f" MISSING {sym} -- reached transitively; {owner} defines it")
138 for inc, hit in used:
139 print(f" ok {inc} <- {', '.join(hit[:6])}")
140 print()
141 return len(unused) + len(set(missing))
142
143
144def main():
145 if "--all" in sys.argv:
146 headers = []
147 for dirpath, dirnames, filenames in os.walk(SRC):
148 dirnames[:] = [d for d in dirnames if d not in ("external", "build")]
149 headers += [os.path.join(dirpath, f) for f in filenames if f.endswith(".h")]
150 else:
151 headers = [a for a in sys.argv[1:] if not a.startswith("--")]
152 if not headers:
153 sys.exit(__doc__)
154
155 total = sum(audit(h) for h in sorted(headers))
156 print(f"{total} finding(s) across {len(headers)} header(s).")
157 return 0
158
159
160if __name__ == "__main__":
161 sys.exit(main())