|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Consistency checks for a library built as a single translation unit. |
| 3 | +
|
| 4 | +The implementation lives in *.inl files that a single hub *.cpp includes, |
| 5 | +directly or through other *.inl files. This script checks that the hub and |
| 6 | +the *.inl files agree: |
| 7 | +
|
| 8 | + 1. every *.inl include reachable from the hub names an existing file under |
| 9 | + the source root, no file includes the same *.inl twice, and *.inl files |
| 10 | + are included with the quoted form only |
| 11 | + 2. every *.inl under the source root is reachable from the hub |
| 12 | + 3. every *.inl starts with #ifndef <MACRO> / #error / #endif so it cannot |
| 13 | + be included by user code on its own |
| 14 | + 4. no *.c / *.cpp other than the hub exists under the source root (a stray |
| 15 | + one would compile as a separate translation unit and defeat the purpose) |
| 16 | + 5. (--compile) every *.inl the hub includes directly compiles on its own with |
| 17 | + only the guard macro defined, so no file silently depends on what an |
| 18 | + earlier include brought in (files included by other *.inl files are |
| 19 | + fragments of those and are not compiled separately) |
| 20 | +
|
| 21 | +The checks are lexical: comments, string literals and line splices are |
| 22 | +handled like the preprocessor does, but #if conditions are not evaluated, so |
| 23 | +an include inside a disabled #if block still counts as reachable, a file |
| 24 | +included from several places (the platform directories each include the |
| 25 | +same bit-bang helpers, under mutually exclusive conditions) is not reported |
| 26 | +as a duplicate, and a platform file that compiles to nothing on the host |
| 27 | +still passes the compile check. The script guards against mistakes, not |
| 28 | +against code written to evade it. |
| 29 | +
|
| 30 | +Usage: |
| 31 | + check_inl_sources.py --hub src/lgfx/v1/lgfx_v1.cpp --root src/lgfx/v1 \ |
| 32 | + --macro LGFX_V1_IMPLEMENTATION [--compile CXX -std=c++17 -DLGFX_SDL -Isrc ...] |
| 33 | +
|
| 34 | +Everything after --compile is the compiler command line; the script appends |
| 35 | +-fsyntax-only -x c++ -D<MACRO> and the file. |
| 36 | +""" |
| 37 | + |
| 38 | +import argparse |
| 39 | +import concurrent.futures |
| 40 | +import os |
| 41 | +import re |
| 42 | +import subprocess |
| 43 | +import sys |
| 44 | + |
| 45 | +WS = r'[ \t\f\v]' |
| 46 | +INCLUDE_RE = re.compile(r'^' + WS + r'*#' + WS + r'*include' + WS + r'*(?:"([^"\n]+)"|<([^>\n]+)>)' + WS + r'*$', re.M) |
| 47 | +DIRECTIVE_RE = re.compile(r'^' + WS + r'*#' + WS + r'*(\w+)(.*)$', re.M) |
| 48 | +RAW_STRING_RE = re.compile(r'(?:u8|u|U|L)?R"([^()\\ \t\f\v\n]{0,16})\(') |
| 49 | + |
| 50 | + |
| 51 | +def digit_separator_at(text, i): |
| 52 | + """True when the quote at text[i] separates digits of a numeric literal (1'000, 0xFF'FF).""" |
| 53 | + k = i - 1 |
| 54 | + while k >= 0 and (text[k].isalnum() or text[k] in '_.'): |
| 55 | + k -= 1 |
| 56 | + token = text[k + 1:i].lstrip('.') |
| 57 | + return bool(token) and token[0].isdigit() and i + 1 < len(text) and (text[i + 1].isalnum()) |
| 58 | + |
| 59 | + |
| 60 | +def raw_string_at(text, i): |
| 61 | + """Match object when the quote at text[i] opens a raw string literal, else None.""" |
| 62 | + for start in (i - 1, i - 2, i - 3): |
| 63 | + if start < 0: |
| 64 | + break |
| 65 | + if start > 0 and (text[start - 1].isalnum() or text[start - 1] == '_'): |
| 66 | + continue |
| 67 | + m = RAW_STRING_RE.match(text, start) |
| 68 | + if m and text[m.start():].startswith(m.group(0)) and m.group(0).index('"') == i - start: |
| 69 | + return m |
| 70 | + return None |
| 71 | + |
| 72 | + |
| 73 | +def read_code(path): |
| 74 | + """File contents prepared for line-based directive matching. |
| 75 | +
|
| 76 | + Line splices are joined (except inside raw string literals), comments |
| 77 | + are replaced by a space, raw string literals are blanked, and ordinary |
| 78 | + string and character literals are copied through (a comment opener |
| 79 | + inside one is not a comment). Newlines are kept so each directive still |
| 80 | + sits on its own line. |
| 81 | + """ |
| 82 | + with open(path, encoding='utf-8', errors='replace') as f: |
| 83 | + text = f.read().replace('\r\n', '\n') |
| 84 | + out = [] |
| 85 | + i = 0 |
| 86 | + n = len(text) |
| 87 | + while i < n: |
| 88 | + c = text[i] |
| 89 | + if text.startswith('\\\n', i): |
| 90 | + i += 2 |
| 91 | + elif text.startswith('//', i): |
| 92 | + j = text.find('\n', i) |
| 93 | + while 0 < j and text[j - 1] == '\\': |
| 94 | + j = text.find('\n', j + 1) |
| 95 | + i = n if j < 0 else j |
| 96 | + elif c == "'" and digit_separator_at(text, i): |
| 97 | + i += 1 |
| 98 | + elif text.startswith('/*', i): |
| 99 | + j = text.find('*/', i + 2) |
| 100 | + j = n if j < 0 else j + 2 |
| 101 | + out.append(' ' + '\n' * text.count('\n', i, j)) |
| 102 | + i = j |
| 103 | + elif c == '"' and (m := raw_string_at(text, i)): |
| 104 | + # raw string literal R"delim( ... )delim": blank it, keep its newlines |
| 105 | + close = ')' + m.group(1) + '"' |
| 106 | + j = text.find(close, m.end()) |
| 107 | + j = n if j < 0 else j + len(close) |
| 108 | + out.append('""' + '\n' * text.count('\n', i, j)) |
| 109 | + i = j |
| 110 | + elif c in '"\'': |
| 111 | + # ordinary string or character literal: copy it. Line splices are removed |
| 112 | + # before escape sequences are read, as in translation phase 2. |
| 113 | + lit = [c] |
| 114 | + j = i + 1 |
| 115 | + while j < n: |
| 116 | + if text.startswith('\\\n', j): |
| 117 | + j += 2 |
| 118 | + continue |
| 119 | + ch = text[j] |
| 120 | + if ch == '\n': |
| 121 | + break |
| 122 | + j += 1 |
| 123 | + if ch == c: |
| 124 | + lit.append(c) |
| 125 | + break |
| 126 | + if ch == '\\': |
| 127 | + while text.startswith('\\\n', j): |
| 128 | + j += 2 |
| 129 | + if j < n and text[j] != '\n': |
| 130 | + lit.append('\\' + text[j]) |
| 131 | + j += 1 |
| 132 | + continue |
| 133 | + lit.append(ch) |
| 134 | + out.append(''.join(lit)) |
| 135 | + i = j |
| 136 | + else: |
| 137 | + out.append(c) |
| 138 | + i += 1 |
| 139 | + return ''.join(out) |
| 140 | + |
| 141 | + |
| 142 | +def rel(path, base): |
| 143 | + return os.path.relpath(path, base).replace(os.sep, '/') |
| 144 | + |
| 145 | + |
| 146 | +def walk(root, exts): |
| 147 | + for dirpath, _dirs, files in os.walk(root): |
| 148 | + for name in sorted(files): |
| 149 | + if os.path.splitext(name)[1] in exts: |
| 150 | + yield os.path.normpath(os.path.join(dirpath, name)) |
| 151 | + |
| 152 | + |
| 153 | +def inl_includes(path): |
| 154 | + """(form, include text) for every *.inl include directive in the file. |
| 155 | +
|
| 156 | + form is '"' or '<' for a well-formed directive, and '?' with the raw |
| 157 | + directive text when an #include mentions .inl but does not parse. |
| 158 | + """ |
| 159 | + found = [] |
| 160 | + code = read_code(path) |
| 161 | + for m in DIRECTIVE_RE.finditer(code): |
| 162 | + if m.group(1) != 'include': |
| 163 | + continue |
| 164 | + inc = INCLUDE_RE.match(code, m.start()) |
| 165 | + if inc: |
| 166 | + form, name = ('"', inc.group(1)) if inc.group(1) is not None else ('<', inc.group(2)) |
| 167 | + if name.endswith('.inl'): |
| 168 | + found.append((form, name)) |
| 169 | + elif '.inl' in m.group(2): |
| 170 | + found.append(('?', m.group(0).strip())) |
| 171 | + return found |
| 172 | + |
| 173 | + |
| 174 | +def is_under(path, root): |
| 175 | + """True when the file (symlinks resolved) lies under root.""" |
| 176 | + real_root = os.path.realpath(root) |
| 177 | + return os.path.commonpath([os.path.realpath(path), real_root]) == real_root |
| 178 | + |
| 179 | + |
| 180 | +def check_reachability(hub, root, problems): |
| 181 | + """Walks the *.inl include graph from the hub. |
| 182 | +
|
| 183 | + Returns (direct, reachable): the files the hub includes directly, in hub |
| 184 | + order, and every *.inl reachable from the hub. |
| 185 | + """ |
| 186 | + direct = [] |
| 187 | + reachable = set() |
| 188 | + visited = set() |
| 189 | + |
| 190 | + def visit(src, chain): |
| 191 | + key = os.path.realpath(src) |
| 192 | + if key in visited: |
| 193 | + return |
| 194 | + visited.add(key) |
| 195 | + seen_here = set() |
| 196 | + for form, inc in inl_includes(src): |
| 197 | + if form == '?': |
| 198 | + problems.append(f'{rel(src, root)}: malformed include directive: {inc}') |
| 199 | + continue |
| 200 | + if form == '<': |
| 201 | + problems.append(f'{rel(src, root)}: includes <{inc}>; use the quoted form for *.inl files') |
| 202 | + continue |
| 203 | + path = os.path.normpath(os.path.join(os.path.dirname(src), inc)) |
| 204 | + if not os.path.isfile(path): |
| 205 | + problems.append(f'{rel(src, root)}: includes missing file "{inc}"') |
| 206 | + continue |
| 207 | + if not is_under(path, root): |
| 208 | + problems.append(f'{rel(src, root)}: includes "{inc}", which is outside {rel(root, os.getcwd())}') |
| 209 | + continue |
| 210 | + real = os.path.realpath(path) |
| 211 | + if real in seen_here: |
| 212 | + problems.append(f'{rel(src, root)}: includes "{inc}" twice') |
| 213 | + continue |
| 214 | + seen_here.add(real) |
| 215 | + if real in chain: |
| 216 | + problems.append(f'{rel(src, root)}: includes "{inc}", which already includes this file (cycle)') |
| 217 | + continue |
| 218 | + if src == hub: |
| 219 | + direct.append(path) |
| 220 | + reachable.add(path) |
| 221 | + visit(path, chain + (real,)) |
| 222 | + |
| 223 | + visit(hub, (os.path.realpath(hub),)) |
| 224 | + for inl in walk(root, {'.inl'}): |
| 225 | + if inl not in reachable: |
| 226 | + problems.append(f'{rel(inl, root)}: not reachable from the hub') |
| 227 | + return direct, reachable |
| 228 | + |
| 229 | + |
| 230 | +def check_guards(root, macro, problems): |
| 231 | + """The first three directives of every *.inl must be #ifndef MACRO / #error / #endif.""" |
| 232 | + for inl in walk(root, {'.inl'}): |
| 233 | + directives = [(m.group(1), m.group(2).strip()) for m in DIRECTIVE_RE.finditer(read_code(inl))] |
| 234 | + ok = (len(directives) >= 3 |
| 235 | + and directives[0] == ('ifndef', macro) |
| 236 | + and directives[1][0] == 'error' |
| 237 | + and directives[2][0] == 'endif') |
| 238 | + if not ok: |
| 239 | + problems.append(f'{rel(inl, root)}: does not start with "#ifndef {macro}" / "#error" / "#endif"') |
| 240 | + |
| 241 | + |
| 242 | +def check_stray_sources(root, hub, problems): |
| 243 | + for src in walk(root, {'.c', '.cpp'}): |
| 244 | + if os.path.abspath(src) != os.path.abspath(hub): |
| 245 | + problems.append(f'{rel(src, root)}: source file outside the hub (add it as *.inl to the hub instead)') |
| 246 | + |
| 247 | + |
| 248 | +def compile_one(cmd, macro, path): |
| 249 | + full = cmd + ['-fsyntax-only', '-x', 'c++', '-D' + macro, path] |
| 250 | + proc = subprocess.run(full, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) |
| 251 | + return path, proc.returncode, proc.stdout |
| 252 | + |
| 253 | + |
| 254 | +def check_compile(direct, root, macro, cmd, problems): |
| 255 | + workers = os.cpu_count() or 2 |
| 256 | + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: |
| 257 | + futures = [pool.submit(compile_one, cmd, macro, p) for p in direct] |
| 258 | + for fut in futures: |
| 259 | + path, rc, out = fut.result() |
| 260 | + if rc != 0: |
| 261 | + head = '\n'.join(out.strip().splitlines()[:12]) |
| 262 | + problems.append(f'{rel(path, root)}: does not compile on its own\n{head}') |
| 263 | + |
| 264 | + |
| 265 | +def main(): |
| 266 | + argv = sys.argv[1:] |
| 267 | + compile_cmd = None |
| 268 | + if '--compile' in argv: |
| 269 | + i = argv.index('--compile') |
| 270 | + compile_cmd = argv[i + 1:] |
| 271 | + argv = argv[:i] |
| 272 | + if not compile_cmd: |
| 273 | + sys.exit('--compile needs a compiler command line') |
| 274 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 275 | + ap.add_argument('--hub', required=True, help='the *.cpp that includes the *.inl files') |
| 276 | + ap.add_argument('--root', required=True, help='directory whose *.inl files must all be reachable from the hub') |
| 277 | + ap.add_argument('--macro', required=True, help='guard macro the hub defines around its includes') |
| 278 | + args = ap.parse_args(argv) |
| 279 | + |
| 280 | + hub = os.path.normpath(os.path.abspath(args.hub)) |
| 281 | + root = os.path.normpath(os.path.abspath(args.root)) |
| 282 | + problems = [] |
| 283 | + |
| 284 | + direct, reachable = check_reachability(hub, root, problems) |
| 285 | + check_guards(root, args.macro, problems) |
| 286 | + check_stray_sources(root, hub, problems) |
| 287 | + if compile_cmd: |
| 288 | + check_compile(direct, root, args.macro, compile_cmd, problems) |
| 289 | + |
| 290 | + print(f'{rel(hub, root)}: {len(direct)} files included directly, {len(reachable)} reachable, ' |
| 291 | + f'{sum(1 for _ in walk(root, {".inl"}))} *.inl under {rel(root, os.getcwd())}' |
| 292 | + + (f', compiled with: {" ".join(compile_cmd)}' if compile_cmd else '')) |
| 293 | + if problems: |
| 294 | + print(f'{len(problems)} problem(s):') |
| 295 | + for p in problems: |
| 296 | + print(' - ' + p.replace('\n', '\n ')) |
| 297 | + sys.exit(1) |
| 298 | + print('OK') |
| 299 | + |
| 300 | + |
| 301 | +if __name__ == '__main__': |
| 302 | + main() |
0 commit comments