Skip to content

Commit befa3b3

Browse files
committed
Strip PCH forced-include flags when clang-tidy analyses header files
When a header in the PCH include chain is analysed as the main source file, the `-include` flag causes it to be parsed twice in the same translation unit. `#pragma once` cannot prevent this for the main file, resulting in redefinition errors. Use a sanitized compile_commands.json (with `-include`/`-include-pch` removed) for header files only. Claude's solution to the clang-tidy redefinition errors in scp-fs2open#7258 as well as a few previous PRs over the years.
1 parent e39a900 commit befa3b3

1 file changed

Lines changed: 95 additions & 2 deletions

File tree

ci/linux/clang-tidy-diff.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,86 @@ def start_workers(max_tasks, tidy_caller, task_queue, lock, timeout):
8989
t.daemon = True
9090
t.start()
9191

92+
_HEADER_EXTS = frozenset(('.h', '.hh', '.hpp', '.hxx', '.h++', '.inc'))
93+
94+
# Matches -include/-include-pch and its path argument in a command string,
95+
# including the -Xclang wrapped form used for precompiled header binaries.
96+
_PCH_INCLUDE_RE = re.compile(
97+
r'-Xclang\s+-include-pch\s+-Xclang\s+(?:"[^"]*"|\S+)'
98+
r'|-include(?:-pch)?(?:\s+(?:"[^"]*"|\S+)|\S+)'
99+
)
100+
101+
102+
def _is_header(filename):
103+
_, ext = os.path.splitext(filename)
104+
return ext.lower() in _HEADER_EXTS
105+
106+
107+
def _strip_pch_from_command(command_str):
108+
result = _PCH_INCLUDE_RE.sub('', command_str)
109+
return re.sub(r' +', ' ', result).strip()
110+
111+
112+
def _strip_pch_from_arguments(arguments):
113+
result = []
114+
i = 0
115+
while i < len(arguments):
116+
# -Xclang -include-pch -Xclang <path> (4 args)
117+
if (arguments[i] == '-Xclang' and
118+
i + 3 < len(arguments) and
119+
arguments[i + 1] == '-include-pch' and
120+
arguments[i + 2] == '-Xclang'):
121+
i += 4
122+
continue
123+
# -include <path> or -include-pch <path> (2 args)
124+
if arguments[i] in ('-include', '-include-pch') and i + 1 < len(arguments):
125+
i += 2
126+
continue
127+
# -include<path> or -include-pch<path> (concatenated, 1 arg)
128+
if arguments[i].startswith('-include'):
129+
i += 1
130+
continue
131+
result.append(arguments[i])
132+
i += 1
133+
return result
134+
135+
136+
def _create_sanitized_compile_commands(build_path):
137+
"""Create a copy of compile_commands.json with forced-include flags removed.
138+
139+
When clang-tidy analyses a header file as the main source file, any
140+
-include (PCH) flag causes the header to be parsed twice in the same
141+
translation unit: once via the forced include and once as the main file.
142+
#pragma once cannot prevent this because the main file is not an #include.
143+
Stripping these flags for header-file analysis avoids the resulting
144+
redefinition errors.
145+
"""
146+
src = os.path.join(build_path, 'compile_commands.json')
147+
if not os.path.isfile(src):
148+
return None
149+
try:
150+
with open(src, 'r') as f:
151+
db = json.load(f)
152+
except (ValueError, OSError):
153+
return None
154+
155+
for entry in db:
156+
if 'command' in entry:
157+
entry['command'] = _strip_pch_from_command(entry['command'])
158+
if 'arguments' in entry:
159+
entry['arguments'] = _strip_pch_from_arguments(entry['arguments'])
160+
161+
sanitized_dir = tempfile.mkdtemp(prefix='clang-tidy-no-pch-')
162+
dst = os.path.join(sanitized_dir, 'compile_commands.json')
163+
try:
164+
with open(dst, 'w') as f:
165+
json.dump(db, f)
166+
except OSError:
167+
shutil.rmtree(sanitized_dir, ignore_errors=True)
168+
return None
169+
return sanitized_dir
170+
171+
92172
def merge_replacement_files(tmpdir, mergefile):
93173
"""Merge all replacement files in a directory into a single file"""
94174
# The fixes suggested by clang-tidy >= 4.0.0 are given under
@@ -222,13 +302,18 @@ def main():
222302
common_clang_tidy_args.append('-checks=' + args.checks)
223303
if args.quiet:
224304
common_clang_tidy_args.append('-quiet')
225-
if args.build_path is not None:
226-
common_clang_tidy_args.append('-p=%s' % args.build_path)
227305
for arg in args.extra_arg:
228306
common_clang_tidy_args.append('-extra-arg=%s' % arg)
229307
for arg in args.extra_arg_before:
230308
common_clang_tidy_args.append('-extra-arg-before=%s' % arg)
231309

310+
# When headers are in the changed-file list, create a sanitized compile DB
311+
# with forced-include flags stripped so they don't cause redefinitions.
312+
sanitized_build_path = None
313+
if args.build_path is not None:
314+
if any(_is_header(name) for name in lines_by_file):
315+
sanitized_build_path = _create_sanitized_compile_commands(args.build_path)
316+
232317
for name in lines_by_file:
233318
line_filter_json = json.dumps(
234319
[{"name": name, "lines": lines_by_file[name]}],
@@ -244,6 +329,11 @@ def main():
244329
os.close(handle)
245330
command.append('-export-fixes=' + tmp_name)
246331
command.extend(common_clang_tidy_args)
332+
if args.build_path is not None:
333+
if _is_header(name) and sanitized_build_path is not None:
334+
command.append('-p=%s' % sanitized_build_path)
335+
else:
336+
command.append('-p=%s' % args.build_path)
247337
command.append(name)
248338
command.extend(clang_tidy_args)
249339

@@ -252,6 +342,9 @@ def main():
252342
# Wait for all threads to be done.
253343
task_queue.join()
254344

345+
if sanitized_build_path is not None:
346+
shutil.rmtree(sanitized_build_path, ignore_errors=True)
347+
255348
if yaml and args.export_fixes:
256349
print('Writing fixes to ' + args.export_fixes + ' ...')
257350
try:

0 commit comments

Comments
 (0)