Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
mingw_syntax_check.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Syntax-check translation units with the MinGW cross compiler.
3
4Why: a clean Linux build cannot vouch for code inside `#ifdef _WIN32`, and it cannot
5see the legacy macros `windows.h` defines (`near`, `grp2`, `interface`, ...). Every
6cross-platform breakage in the darktable.h series was of that shape -- green locally,
7broken on MinGW, and usually reported in a file that was not at fault. This runs the
8real Windows toolchain over the tree so those are caught before CI, or when CI is
9unavailable.
10
11This is `-fsyntax-only`: it compiles nothing and links nothing. That is deliberate --
12the goal is the preprocessor and the parser, which is where this class of bug lives.
13
14Some libraries have no Fedora mingw64 package (json-glib, lensfun, libcurl, libraw,
15osmgpsmap, openjpeg, sentry, OpenCL ...), so files needing them cannot be checked here.
16They are reported as SKIPPED and counted separately, never as passing: a check you did
17not run is not a check that succeeded. Keep UNAVAILABLE honest -- an entry for a library
18that IS installed would downgrade a real failure to "skipped".
19
20Setup (Fedora):
21 sudo dnf install mingw64-gcc mingw64-gcc-c++ mingw64-glib2 mingw64-gtk3 \
22 mingw64-lcms2 mingw64-sqlite mingw64-exiv2 mingw64-libpng \
23 mingw64-libtiff mingw64-libjpeg-turbo
24
25Usage:
26 python3 tools/mingw_syntax_check.py # every .c/.cc under src/
27 python3 tools/mingw_syntax_check.py --changed master # only what a branch touched
28 python3 tools/mingw_syntax_check.py src/iop/lens.c # specific files
29 python3 tools/mingw_syntax_check.py --jobs 8
30"""
31import concurrent.futures
32import json
33import os
34import re
35import subprocess
36import sys
37
38REPO = os.getcwd()
39CC = 'x86_64-w64-mingw32-gcc'
40CXX = 'x86_64-w64-mingw32-g++'
41PKGS = ['gtk+-3.0', 'lcms2', 'sqlite3', 'libpng', 'libtiff-4', 'libjpeg', 'exiv2',
42 'librsvg-2.0', 'libxml-2.0', 'libwebp', 'OpenEXR', 'libsoup-2.4']
43
44# Third-party headers genuinely absent from the mingw64 sysroot. A file that fails
45# ONLY because one of these is missing is not a finding -- but this list must be kept
46# honest: leaving an installed library in here would silently downgrade a REAL failure
47# to "skipped", which defeats the purpose of the harness. Verified against
48# /usr/x86_64-w64-mingw32/sys-root/mingw/include; re-check after installing packages.
49UNAVAILABLE = re.compile(
50 r'(json-glib[/.]|lensfun[/.]|curl/curl\.h|libraw|osmgpsmap|osm-gps-map|openjpeg|'
51 r'cmark|colord|gphoto2|portmidi|pugixml|rawspeed|libdeflate|jasper|portaudio)',
52 re.I)
53
54# Mirror the defines the real Win64 CI job passes. Without them the harness invents
55# failures that do not exist in the actual build -- e.g. localtime_r is only declared
56# on MinGW when _POSIX_THREAD_SAFE_FUNCTIONS is set, so omitting it makes every user
57# of localtime_r look broken. Taken from the Win64.UCRT64 compile command line.
58WIN_DEFINES = ['-DHAVE_CONFIG_H', '-D_POSIX_THREAD_SAFE_FUNCTIONS', '-D_USE_MATH_DEFINES',
59 '-D__USE_MINGW_ANSI_STDIO=1', '-DUNICODE', '-D_UNICODE',
60 '-D__GDK_KEYSYMS_COMPAT_H__']
61
62MISSING_HEADER = re.compile(r"fatal error: ([^:]+): No such file or directory")
63
64# Some sources guard an absent library with a hand-written directive instead:
65# #error "openjpeg.h not found"
66# That is the same condition as a missing header -- the library is not in the sysroot --
67# but it does not match MISSING_HEADER, so it was being reported as a real failure.
68EXPLICIT_ERROR = re.compile(r'error: #error\s+"?([^"\n]+)')
69
70
71# Feature defines whose headers are not in the mingw64 sysroot. Keeping them does not
72# make the harness stricter -- it just converts checkable files into skipped ones,
73# because the guarded include fails outright. Dropping them checks everything EXCEPT
74# those branches, which were unverifiable regardless. HAVE_MAP alone accounted for 110
75# skipped files.
76UNBUILDABLE_FEATURES = ('HAVE_MAP', 'HAVE_LIBAVIF', 'HAVE_LIBHEIF',
77 'HAVE_HTTP_SERVER',
78 'HAVE_SENTRY', 'HAVE_LIBRAW', 'HAVE_OPENJPEG', 'HAVE_WEBP',
79 'HAVE_OPENEXR', 'HAVE_ISO_CODES', 'HAVE_CMARK',
80 'HAVE_OSMGPSMAP_110_OR_NEWER', 'HAVE_OSMGPSMAP_NEWER_THAN_110')
81
82
84 keep = []
85 for f in flags:
86 if f.startswith('-D') and any(f[2:].split('=')[0] == n for n in UNBUILDABLE_FEATURES):
87 continue
88 keep.append(f)
89 return keep
90
91
92def _in_repo(path):
93 """Keep project include paths, drop the host toolchain's own -I/-isystem: those are
94 Linux headers and must not leak into a MinGW cross check."""
95 return not os.path.isabs(path) or REPO in path
96
97
99 """The -D / -include / project -I flags from one compile command."""
100 flags, i = [], 0
101 while i < len(argv):
102 a = argv[i]
103 if a.startswith('-std='):
104 # Take the language standard from the build too: RawSpeed's headers need
105 # C++20, and forcing gnu++17 made common/imageio_rawspeed.cc fail inside a
106 # submodule header -- a harness artefact, not a defect in our code.
107 flags.append(a)
108 elif a.startswith('-D'):
109 flags.append(a)
110 elif a == '-include' and i + 1 < len(argv):
111 flags += ['-include', argv[i + 1]]
112 i += 1
113 elif a in ('-I', '-isystem') and i + 1 < len(argv):
114 if _in_repo(argv[i + 1]):
115 flags += ['-I', argv[i + 1]]
116 i += 1
117 elif a.startswith('-I') and len(a) > 2 and _in_repo(a[2:]):
118 flags.append(a)
119 i += 1
120 return flags
121
122
123def _source_for(entry_file):
124 """Map a compdb entry back to the source we care about. Generated
125 introspection_X.c carries the flags for the src/iop/X.c it textually includes."""
126 base = os.path.basename(entry_file)
127 if not base.startswith('introspection_'):
128 rel = os.path.relpath(entry_file, REPO) if os.path.isabs(entry_file) else entry_file
129 return [os.path.normpath(rel)]
130 stem = base[len('introspection_'):]
131 return [os.path.normpath(d + stem)
132 for d in ('src/iop/', 'src/libs/', 'src/imageio/format/', 'src/imageio/storage/')
133 if os.path.exists(d + stem)]
134
135
136def build_flag_map(compdb='build/compile_commands.json'):
137 """Per-file -D/-include/project -I flags, taken from the BUILD SYSTEM.
138
139 Guessing these is how a cross-check invents failures: the real build force-includes
140 common/module_api.h and iop/iop_api.h into every module, and gates whole APIs behind
141 -DHAVE_MAP / -DBUILD_PRINT / -DHAVE_OPENCL. Without them the harness reports missing
142 declarations that exist perfectly well in the real build.
143
144 Generated introspection_X.c entries carry the flags for src/iop/X.c, which is
145 textually included by them, so they are mapped back onto the original.
146 """
147 import shlex
148 if not os.path.exists(compdb):
149 r = subprocess.run(['ninja', '-C', 'build', '-t', 'compdb'],
150 capture_output=True, text=True)
151 if r.returncode != 0:
152 return {}
153 entries = json.loads(r.stdout)
154 else:
155 entries = json.load(open(compdb))
156
157 out = {}
158 for e in entries:
159 f = e.get('file', '')
160 cmd = e.get('command', '')
161 if not cmd or cmd.lstrip().startswith(':'):
162 continue # link/utility line, not a compile
163 try:
164 argv = shlex.split(cmd)
165 except ValueError:
166 continue
167 flags = _relevant_flags(argv)
168 for src in _source_for(f):
169 out.setdefault(src, flags)
170 return out
171
172
174 out = []
175 for p in PKGS:
176 r = subprocess.run(['mingw64-pkg-config', '--cflags', p],
177 capture_output=True, text=True)
178 if r.returncode == 0:
179 out += r.stdout.split()
180 # de-duplicate, keep order
181 seen, flags = set(), []
182 for f in out:
183 if f not in seen:
184 seen.add(f)
185 flags.append(f)
186 return flags
187
188
189def sources(args):
190 explicit = [a for a in args if a.endswith(('.c', '.cc', '.cpp'))]
191 if explicit:
192 return explicit
193 if '--changed' in args:
194 base = args[args.index('--changed') + 1]
195 r = subprocess.run(['git', 'diff', '--name-only', '%s..HEAD' % base, '--', 'src'],
196 capture_output=True, text=True)
197 return [f for f in r.stdout.split() if f.endswith(('.c', '.cc', '.cpp'))]
198 out = []
199 for root, dirs, names in os.walk('src'):
200 dirs[:] = [d for d in dirs if d != 'external']
201 for n in names:
202 if n.endswith(('.c', '.cc', '.cpp')):
203 out.append(os.path.join(root, n))
204 return sorted(out)
205
206
208 """The header name if this failure is only 'we do not have it cross-built'."""
209 m = MISSING_HEADER.search(stderr)
210 if not m:
211 e = EXPLICIT_ERROR.search(stderr)
212 if e and UNAVAILABLE.search(e.group(1)):
213 return e.group(1).strip()
214 return None
215 if UNAVAILABLE.search(m.group(1)) or not os.path.exists(os.path.join('src', m.group(1))):
216 return m.group(1)
217 return None
218
219
220def _unverifiable_with_full_flags(cmd, own, fmap, path):
221 """Dropping a feature define can hide declarations a feature-only file legitimately
222 uses (src/libs/map_locations.c is entirely map code). Retry with the ORIGINAL flags:
223 if it then dies on a header we do not have cross-built, the file is unverifiable
224 here rather than broken."""
225 full = fmap.get(os.path.normpath(path))
226 if full is None or full == own or not own:
227 return None
228 head = cmd[:cmd.index(own[0])]
229 tail = cmd[cmd.index(own[0]) + len(own):]
230 r = subprocess.run(head + full + tail, capture_output=True, text=True)
231 if r.returncode == 0:
232 return None
233 return _missing_unavailable_header(r.stderr)
234
235
236def check(path, flags, fmap):
237 cxx = path.endswith(('.cc', '.cpp'))
238 own = fmap.get(os.path.normpath(path))
239 own = _drop_unbuildable(own) if own is not None else None
240 if own is None:
241 return ('skip', path, '<not compiled by this build>')
242 has_std = any(f.startswith('-std=') for f in (own or []))
243 cmd = [CXX if cxx else CC, '-fsyntax-only'] + \
244 ([] if has_std else ['-std=gnu++17' if cxx else '-std=gnu11']) + [
245 '-I', 'src', '-I', 'build/src', '-I', 'src/external/OpenCL'] + own + WIN_DEFINES + [
246 '-fopenmp', '-Wno-attributes', '-Wno-unknown-pragmas'] + flags + [path]
247 r = subprocess.run(cmd, capture_output=True, text=True)
248 if r.returncode == 0:
249 return ('ok', path, '')
250 log = r.stderr
251 m = MISSING_HEADER.search(log)
252 if m and UNAVAILABLE.search(m.group(1)):
253 return ('skip', path, m.group(1))
254 e = EXPLICIT_ERROR.search(log)
255 if e and UNAVAILABLE.search(e.group(1)):
256 return ('skip', path, e.group(1).strip())
257 if m and not os.path.exists(os.path.join('src', m.group(1))):
258 # some other header we simply do not have cross-built
259 return ('skip', path, m.group(1))
260 unverifiable = _unverifiable_with_full_flags(cmd, own, fmap, path)
261 if unverifiable:
262 return ('skip', path, unverifiable)
263
264 first = [ln for ln in log.splitlines() if ' error:' in ln][:3]
265 return ('fail', path, '\n'.join(first) or log.strip().splitlines()[-1] if log.strip() else '?')
266
267
268def main():
269 if subprocess.run(['which', CC], capture_output=True).returncode != 0:
270 print('%s not found -- install mingw64-gcc (see the docstring)' % CC, file=sys.stderr)
271 return 2
272 args = sys.argv[1:]
273 jobs = int(args[args.index('--jobs') + 1]) if '--jobs' in args else os.cpu_count() or 4
274 flags = pkg_cflags()
275 fmap = build_flag_map()
276 files = sources(args)
277 print('per-file flags recovered for %d translation units' % len(fmap))
278 print('checking %d files with %s (%d jobs)' % (len(files), CC, jobs))
279
280 ok = skipped = 0
281 fails, skips = [], {}
282 with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as ex:
283 for status, path, info in ex.map(lambda f: check(f, flags, fmap), files):
284 if status == 'ok':
285 ok += 1
286 elif status == 'skip':
287 skipped += 1
288 skips.setdefault(info, []).append(path)
289 else:
290 fails.append((path, info))
291
292 if fails:
293 print('\n=== FAILURES (%d) ===' % len(fails))
294 for p, info in fails:
295 print('\n%s\n%s' % (p, info))
296 if skips:
297 print('\n=== SKIPPED: no mingw64 package for these headers ===')
298 for h, ps in sorted(skips.items(), key=lambda kv: -len(kv[1])):
299 print(' %-34s %d file(s)' % (h, len(ps)))
300
301 print('\nchecked=%d ok=%d skipped=%d FAILED=%d' % (len(files), ok, skipped, len(fails)))
302 return 1 if fails else 0
303
304
305if __name__ == '__main__':
306 sys.exit(main())
_unverifiable_with_full_flags(cmd, own, fmap, path)
build_flag_map(compdb='build/compile_commands.json')
check(path, flags, fmap)