Skip to content

Commit 2523f61

Browse files
committed
CI: check the single translation unit sources
Add .github/scripts/check_inl_sources.py and a SourceCheck workflow that runs it. The library is compiled through src/M5Unified.cpp, and the script checks that this stays consistent: - every *.inl under src is reachable from the hub, directly or through another *.inl, with quoted includes only, no missing or duplicated files, no cycles, and nothing outside the tree - every *.inl starts with the M5UNIFIED_IMPLEMENTATION guard - no stray *.c / *.cpp exists under src The check is lexical: comments, string literals and line splices are handled as the preprocessor does; #if conditions are not evaluated. The standalone compile check the script offers is not run here because the sources need the target framework headers.
1 parent 935b9e8 commit 2523f61

3 files changed

Lines changed: 334 additions & 0 deletions

File tree

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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()

.github/workflows/SourceCheck.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SourceCheck.yml
2+
name: SourceCheck
3+
4+
on:
5+
push:
6+
paths:
7+
- 'src/**'
8+
- '**check_inl_sources.py'
9+
- '**SourceCheck.yml'
10+
pull_request:
11+
12+
workflow_dispatch:
13+
14+
jobs:
15+
16+
inl-sources:
17+
18+
name: single translation unit consistency
19+
20+
runs-on: ubuntu-latest
21+
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v7
25+
26+
- name: Check the hub and the *.inl files
27+
run: |
28+
python3 .github/scripts/check_inl_sources.py \
29+
--hub src/M5Unified.cpp --root src --macro M5UNIFIED_IMPLEMENTATION

src/M5Unified.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
// file that defines them. Constants shared by several files go in a header
1818
// (utility/m5unified_i2c_addr.hpp for the on-board I2C addresses).
1919
// - M5Unified.inl (the former M5Unified.cpp) comes last; it uses the classes above.
20+
// - .github/scripts/check_inl_sources.py checks these rules (every *.inl is reachable from
21+
// this file, directly or through another *.inl, and starts with the guard below); CI runs
22+
// it (SourceCheck.yml).
2023

2124
#define __STDC_FORMAT_MACROS
2225

0 commit comments

Comments
 (0)