Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
header_consumers.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Report what each includer of a header actually consumes through it.
3
4`gui/gtk.h` is included by 140 files, but almost none of them want 140 files' worth of
5header. Most want two or three symbols; a good number want *nothing it declares* and are
6only there for what it drags in transitively (that is how an earlier split attempt broke
7`control/control.h`, which was getting `dt_control_t` through this chain and lost it when
8the chain was shortened).
9
10Splitting a god-header safely needs that distinction made explicit per file, so this
11separates three cases for every includer:
12
13 OWN - uses a symbol the header itself declares/defines. Needs whichever new header
14 that symbol lands in.
15 VIA - uses no own symbol, but uses a symbol from a header this one includes. The
16 include is a transitive supply line; the file needs that header from somewhere
17 else. Only headers it cannot already reach through its *other* includes are
18 reported, so the list is what actually has to be added, not everything it happens
19 to touch.
20 UNUSED - uses nothing from the header or its transitive closure. The include can go.
21
22Symbols are collected per header (functions, macros, types, enums, struct tags) and matched
23against each includer by word-boundary search outside comments and strings. That over-counts
24slightly -- a name mentioned in a comment-like context, or a symbol also reachable from a
25different header -- so treat VIA as "candidate direct include", not gospel.
26
27Usage:
28 tools/header_consumers.py gui/gtk.h [--json] [--only own|via|unused]
29"""
30
31import collections
32import json
33import os
34import re
35import sys
36
38SRC = os.path.join(REPO, "src")
39
40COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.S)
41COMMENT_LINE = re.compile(r"//[^\n]*")
42STRING_LIT = re.compile(r'"(?:\\.|[^"\\])*"')
43
44INCLUDE = re.compile(r'^\s*#\s*include\s+"([^"]+)"')
45
46# What counts as a symbol this header supplies.
47DECLARE_PATTERNS = [
48 re.compile(r"^\s*#\s*define\s+([A-Za-z_]\w*)"),
49 re.compile(r"^\s*}[^;]*?\b([A-Za-z_]\w*)\s*;"), # `} name;` and `} ATTR(..) name;`
50 re.compile(r"^\s*typedef\s+.*?\b([A-Za-z_]\w*)\s*;"),
51 re.compile(r"^\s*typedef\s+.*\‍(\s*\*\s*([A-Za-z_]\w*)\s*\‍)\s*\‍("), # function-pointer typedef # typedef one-liner
52 re.compile(r"^\s*(?:struct|union|enum)\s+([A-Za-z_]\w*)\s*[;{]"),
53 # A declaration or definition at file scope: <type stuff> name(
54 re.compile(r"^\s*(?:[A-Za-z_][\w \t*]*?[ \t*])([A-Za-z_]\w*)\s*\‍("),
55]
56
57# Enum members are supplied too, and they are how DT_GUI_COLOR_* / DT_UI_CONTAINER_* travel.
58ENUM_MEMBER = re.compile(r"^\s*(DT_[A-Z0-9_]+)\s*(?:=|,|$)")
59
60# Keywords and primitive types get matched by the declaration patterns (a `void foo(` line
61# yields both) and by every consumer, so they would attribute every file to every header.
62NOISE = {
63 "if", "for", "while", "switch", "return", "sizeof", "defined", "else", "do",
64 "static", "inline", "const", "struct", "union", "enum", "typedef", "extern",
65 "void", "int", "char", "float", "double", "long", "short", "unsigned", "signed",
66 "gboolean", "gint", "guint", "gchar", "gpointer", "gdouble", "gfloat", "gsize",
67 "size_t", "ssize_t", "uint8_t", "uint16_t", "uint32_t", "uint64_t",
68 "int8_t", "int16_t", "int32_t", "int64_t", "va_list", "FILE",
69 "TRUE", "FALSE", "NULL",
70}
71
72
73def strip_noise(text):
74 """Blank comments and string literals, in ONE pass.
75
76 Three regexes applied in sequence cannot do this, in either order, because each construct
77 can contain the others' delimiters. Comments-first blanks from the `//` of a URL to the end
78 of the line, taking the string's closing quote with it; the surviving opening quote then
79 pairs with the next quote further down the file and swallows everything between. Measured on
80 src/gui/actions/help.c: 7550 of 9491 characters gone, and its three dt_control_log() calls
81 with them -- the file was reported as using NOTHING from control.h while calling it three
82 times. 129 files in this tree contain `//` inside a string literal.
83
84 Strings-first fails symmetrically: a lone `"` inside a comment (`// don't use "foo.h" here`
85 is fine, an unbalanced one is not) starts a literal that eats real code.
86
87 So: scan once, tracking which construct we are inside. Newlines are preserved so line
88 numbers stay meaningful; everything else becomes a space, and a string literal becomes the
89 empty pair the callers already expect.
90 """
91 out = []
92 i, n = 0, len(text)
93 while i < n:
94 c = text[i]
95 if c == '/' and i + 1 < n and text[i + 1] == '*':
96 j = text.find('*/', i + 2)
97 j = n if j < 0 else j + 2
98 out.append("".join(ch if ch == '\n' else ' ' for ch in text[i:j]))
99 i = j
100 elif c == '/' and i + 1 < n and text[i + 1] == '/':
101 j = text.find('\n', i)
102 j = n if j < 0 else j
103 out.append(' ' * (j - i))
104 i = j
105 elif c == '"' or c == "'":
106 quote, j = c, i + 1
107 while j < n:
108 if text[j] == '\\':
109 j += 2
110 continue
111 if text[j] == quote or text[j] == '\n': # newline: unterminated, stop there
112 break
113 j += 1
114 if j < n and text[j] == quote:
115 j += 1
116 out.append('""' if quote == '"' else "''")
117 out.append("".join(ch if ch == '\n' else '' for ch in text[i:j]))
118 i = j
119 else:
120 out.append(c)
121 i += 1
122 return "".join(out)
123
124
125def read(path):
126 try:
127 with open(path, errors="ignore") as fh:
128 return fh.read()
129 except OSError:
130 return ""
131
132
133def symbols_of(path):
134 """Every identifier `path` supplies to whoever includes it."""
135 text = strip_noise(read(path))
136 found = set()
137 for line in text.split("\n"):
138 for pat in DECLARE_PATTERNS:
139 m = pat.match(line)
140 if m:
142 m = ENUM_MEMBER.match(line)
143 if m:
145 return {s for s in found if s not in NOISE and len(s) > 2}
146
147
148def resolve(inc, from_dir):
149 """An include is written either relative to src/ or to the including file's directory."""
150 for cand in (os.path.join(SRC, inc), os.path.join(from_dir, inc)):
151 cand = os.path.normpath(cand)
152 if os.path.isfile(cand):
153 return cand
154 return None
155
156
157def closure(header, seen=None):
158 """Headers reachable from `header`, excluding itself."""
159 if seen is None:
160 seen = set()
161 for line in read(header).split("\n"):
162 m = INCLUDE.match(line)
163 if not m:
164 continue
165 target = resolve(m.group(1), os.path.dirname(header))
166 if target and target not in seen:
167 seen.add(target)
168 closure(target, seen)
169 return seen
170
171
173 for dirpath, dirnames, filenames in os.walk(SRC):
174 dirnames[:] = [d for d in dirnames if d not in ("external", "build")]
175 for fn in filenames:
176 if fn.endswith((".h", ".hpp", ".c", ".cc", ".cpp")):
177 yield os.path.join(dirpath, fn)
178
179
180def main():
181 args = [a for a in sys.argv[1:] if not a.startswith("--")]
182 if not args:
183 sys.exit(__doc__)
184 rel = args[0]
185 as_json = "--json" in sys.argv
186 only = None
187 if "--only" in sys.argv:
188 only = sys.argv[sys.argv.index("--only") + 1]
189
190 header = os.path.join(SRC, rel)
191 if not os.path.isfile(header):
192 sys.exit(f"error: no such header: {header}")
193
194 own = symbols_of(header)
195 # Which included header supplies which symbol, so a VIA finding names its replacement.
196 supplier = {}
197 for dep in closure(header):
198 for sym in symbols_of(dep) - own:
200
201 basename = os.path.basename(rel)
202 # Both spellings: a project header included as <gui/gtk.h> counts exactly the same, and
203 # two headers in this tree are written that way.
204 include_re = re.compile(
205 r'^\s*#\s*include\s+["<](?:.*/)?' + re.escape(basename) + r'[">]', re.M)
206
207 rows = []
208 for path in walk_sources():
209 if os.path.samefile(path, header):
210 continue
211 raw = read(path)
212 if not include_re.search(raw):
213 continue
214 body = strip_noise(include_re.sub(" ", raw))
215 words = set(re.findall(r"\b[A-Za-z_]\w*\b", body))
216
217 # What this file can already reach without the header under audit. A symbol available
218 # through one of its own other includes is not something it needs to add.
219 # Scanned on the raw text: strip_noise() blanks string literals, which is where an
220 # include path lives.
221 already = set()
222 for line in include_re.sub(" ", raw).split("\n"):
223 m = INCLUDE.match(line)
224 if not m:
225 continue
226 target = resolve(m.group(1), os.path.dirname(path))
227 if target:
228 already.add(target)
229 already |= closure(target)
230 already = {os.path.relpath(h, SRC) for h in already}
231
232 used_own = sorted(own & words)
233 used_via = collections.defaultdict(list)
234 for sym in words & supplier.keys():
235 if supplier[sym] in already:
236 continue
237 used_via[supplier[sym]].append(sym)
238
239 kind = "own" if used_own else ("via" if used_via else "unused")
241 "file": os.path.relpath(path, REPO),
242 "kind": kind,
243 "own_symbols": used_own,
244 "via": {k: sorted(v) for k, v in sorted(used_via.items())},
245 })
246
247 if only:
248 rows = [r for r in rows if r["kind"] == only]
249
250 if as_json:
251 print(json.dumps(rows, indent=2))
252 return 0
253
254 counts = collections.Counter(r["kind"] for r in rows)
255 print(f"{len(rows)} file(s) include {rel}: "
256 f"{counts['own']} use its own symbols, {counts['via']} only pull others through it, "
257 f"{counts['unused']} use nothing\n")
258
259 # Which of this header's own symbols are actually wanted, and by how many files. This is
260 # the split plan: symbols nobody uses can just go, and symbols used together tend to
261 # belong in the same new header.
262 demand = collections.Counter()
263 for r in rows:
264 demand.update(r["own_symbols"])
265 print(f"--- own symbols in demand ({len(demand)} of {len(own)} declared) ---")
266 for sym, n in demand.most_common():
267 print(f" {n:4d} {sym}")
268 unwanted = sorted(own - set(demand))
269 print(f"\n--- declared but used by no includer ({len(unwanted)}) ---")
270 print(" " + ", ".join(unwanted) if unwanted else " (none)")
271
272 print(f"\n--- files that only pull other headers through it ({counts['via']}) ---")
273 for r in sorted(rows, key=lambda x: x["file"]):
274 if r["kind"] != "via":
275 continue
276 print(f" {r['file']}")
277 for hdr, syms in r["via"].items():
278 print(f" {hdr}: {', '.join(syms[:6])}"
279 + (" ..." if len(syms) > 6 else ""))
280
281 print(f"\n--- files using nothing from it ({counts['unused']}) ---")
282 for r in sorted(rows, key=lambda x: x["file"]):
283 if r["kind"] == "unused":
284 print(f" {r['file']}")
285
286 return 0
287
288
289if __name__ == "__main__":
290 sys.exit(main())
const dt_collection_sort_t items[]
Definition filter.c:102
resolve(inc, from_dir)
closure(header, seen=None)