[Tooling] Add IWYU Qt6 mapping, generator, and runner script (#7454)

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-10-08 11:17:01 +02:00 • committed by GitHub
parent d603a2bb6a
commit 870ab23608
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 2905 additions and 0 deletions

226
iwyu.sh Executable file
View file

@ -0,0 +1,226 @@
#!/bin/bash
# This script runs include-what-you-use on the given source files or
# directories, applies its suggestions (forward declarations, minimal and
# canonical #includes), then reformats the touched files with clang-format.
#
# Uses include-what-you-use, iwyu_tool.py, fix_includes.py, clang-format and
# python3. Requires a build directory configured with
# -DCMAKE_EXPORT_COMPILE_COMMANDS=ON to provide compile_commands.json.
#
# The Qt 6 mapping (qt6.imp) makes IWYU prefer capital includes like
# #include <QWidget> over #include <qwidget.h> and keeps forward declarations
# where possible. Regenerate it with tools/generate_qt_mappings.py whenever the
# installed Qt version changes.
#
# Usage:
# ./iwyu.sh [-b <build-dir>] [-m <mapping>] [-j <jobs>] [-n] <files|dirs...>
#
# Options:
# -b, --build-dir DIR build dir with compile_commands.json (default: build)
# -m, --mapping FILE IWYU mapping file (default: qt6.imp)
# -j, --jobs N IWYU parallel jobs (default: 2)
# -n, --dry-run only print IWYU output, do not modify any file
set -o pipefail
# go to the project root directory, this file should be located in the project root directory
cd "${BASH_SOURCE%/*}/" || exit 2 # could not find path, this could happen with special links etc.
# defaults
build_dir="build"
mapping="qt6.imp"
jobs=2
dry_run=0
# 3rd party code that must not be touched by IWYU or clang-format
excludes=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
"libcockatrice_utility/libcockatrice/utility/peglib.h" \
"oracle/src/lzma/" \
"oracle/src/qt-json/" \
"oracle/src/zip/" \
"servatrice/src/smtp/")
usage() {
cat <<EOM
Usage: $0 [-b <build-dir>] [-m <mapping>] [-j <jobs>] [-n] <files|dirs...>
Runs include-what-you-use on the given files/directories, applies the
suggested include changes in place, and reformats touched files.
Options:
-b, --build-dir DIR build dir with compile_commands.json (default: build)
-m, --mapping FILE IWYU mapping file (default: qt6.imp)
-j, --jobs N IWYU parallel jobs (default: 2)
-n, --dry-run only print IWYU output, do not modify any file
-h, --help show this help
EOM
}
# parse options
while [[ $* ]]; do
case "$1" in
'-b'|'--build-dir')
build_dir=$2
shift 2
;;
'-m'|'--mapping')
mapping=$2
shift 2
;;
'-j'|'--jobs')
jobs=$2
shift 2
;;
'-n'|'--dry-run')
dry_run=1
shift
;;
'-h'|'--help')
usage
exit 0
;;
*)
targets+=("$1")
shift
;;
esac
done
# find the IWYU tools, preferring PATH and falling back to the LLVM dir
find_tool() {
local tool=$1
local path
if path=$(command -v "$tool" 2>/dev/null); then
echo "$path"
elif [ -x "/usr/lib/llvm/22/bin/$tool" ]; then
echo "/usr/lib/llvm/22/bin/$tool"
else
return 1
fi
}
find_tool include-what-you-use > /dev/null || { echo "error: include-what-you-use not found"; exit 1; }
IWYU_TOOL=$(find_tool iwyu_tool.py) || { echo "error: iwyu_tool.py not found"; exit 1; }
FIX_INCLUDES=$(find_tool fix_includes.py) || { echo "error: fix_includes.py not found"; exit 1; }
command -v clang-format >/dev/null 2>&1 || { echo "error: clang-format not found"; exit 1; }
if [ "${#targets[@]}" -eq 0 ]; then
echo "error: specify at least one source file or directory" >&2
usage
exit 1
fi
if [ ! -f "$mapping" ]; then
echo "error: mapping file '$mapping' not found" >&2
exit 1
fi
if [ ! -f "$build_dir/compile_commands.json" ]; then
echo "error: '$build_dir/compile_commands.json' not found" >&2
echo "configure the build with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON first" >&2
exit 1
fi
tmpdir=$(mktemp -d /tmp/iwyu.XXXXXX)
trap 'rm -rf "$tmpdir"' EXIT
# filter the compilation database: drop excluded 3rd party code and strip the
# GCC-only flags that clang/include-what-you-use rejects
root="$PWD"
python3 - "$build_dir/compile_commands.json" "$root" "${excludes[@]}" <<'PYEOF' > "$tmpdir/compile_commands.json"
import json
import os
import re
import sys
cc_path, root = sys.argv[1], sys.argv[2]
excludes = sys.argv[3:]
root = os.path.normpath(root)
excl_norm = [os.path.normpath(os.path.join(root, e)) for e in excludes]
out = []
for entry in json.load(open(cc_path)):
path = os.path.normpath(entry['file'])
if not path.startswith(root + os.sep):
continue
# skip generated code: protobuf output and moc autogen files
if re.search(r'\.pb\.(h|cc)$', path) or '_autogen/' in path:
continue
if any(path.startswith(e + os.sep) or path == e for e in excl_norm):
continue
command = entry.get('command', '')
command = re.sub(r'-mno-direct-extern-access', '', command)
command = re.sub(r'--coverage', '', command)
out.append(dict(entry, command=command))
json.dump(out, sys.stdout)
PYEOF
mapping_abs="$PWD/$mapping"
if [ "$dry_run" = 1 ]; then
python3 "$IWYU_TOOL" -p "$tmpdir" -o iwyu -j "$jobs" "${targets[@]}" \
-- -Xiwyu "--mapping_file=$mapping_abs"
else
before=$(git diff --name-only 2>/dev/null | sort -u)
python3 "$IWYU_TOOL" -p "$tmpdir" -o iwyu -j "$jobs" "${targets[@]}" \
-- -Xiwyu "--mapping_file=$mapping_abs" |
python3 "$FIX_INCLUDES" --nosafe_headers --nocomments --noreorder --basedir="$root"
# fix_includes can emit absolute paths for headers that the compiler resolved
# through an absolute -I entry. Those are machine-specific and break every
# other checkout and CI, so rewrite them into the project-relative quoted
# form the codebase uses. Must run before clang-format so the regroup/sort
# pass sees the final paths.
python3 - "$root" <<'PYEOF'
import os
import re
import subprocess
import sys
root = os.path.normpath(sys.argv[1])
pattern = re.compile(r'^(\s*#\s*include\s*)"(/)')
# Collect from git so untracked scratch files are left alone.
changed = subprocess.run(
['git', 'diff', '--name-only', '--diff-filter=ACMR'],
capture_output=True, text=True, check=True).stdout.split()
for rel in changed:
if not rel.endswith(('.h', '.cpp', '.cc', '.cxx')):
continue
path = os.path.join(root, rel)
if not os.path.isfile(path):
continue
src = open(path, encoding='utf-8').read()
if '"' + os.sep not in src:
continue
out = []
for line in src.split('\n'):
m = pattern.match(line)
if not m:
out.append(line)
continue
target = os.path.normpath(line[m.end(1) + 1:].rstrip('"'))
if not target.startswith(root + os.sep):
out.append(line)
continue
spelled = os.path.relpath(target, os.path.dirname(path))
out.append(f'{m.group(1)}"{spelled}"')
new = '\n'.join(out)
if new != src:
open(path, 'w', encoding='utf-8').write(new)
PYEOF
# reformat every file this IWYU pass modified (fix_includes also touches
# headers pulled in by the requested files)
touched=$(comm -13 <(printf '%s\n' "$before") <(git diff --name-only 2>/dev/null | sort -u))
for file in $touched; do
case "$file" in
*.h|*.cpp)
clang-format -i "$file"
;;
esac
done
fi

2471
qt6.imp Normal file

File diff suppressed because it is too large Load diff

208
tools/generate_qt_mappings.py Executable file
View file

@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Generate an include-what-you-use mapping file for Qt 6 headers.
Qt 6 ships a capital forwarding header (e.g. <QWidget>) for each lowercase
private header (e.g. qwidget.h). include-what-you-use resolves symbols to the
lowercase header by default, so this script emits a mapping that makes IWYU
prefer the canonical capital includes, mirroring the qt5_11.imp bundled with
IWYU but adapted to the Qt 6 module layout.
For every capital header that forwards to exactly one lowercase header:
* a "symbol" mapping is emitted so IWYU suggests <QClass> when the definition
of a class is needed, and
* an "include" mapping is emitted so a literal `#include <qclass.h>` in source
is rewritten to its canonical capital header.
A lowercase header can be forwarded to by several capital headers (e.g.
qevent.h provides QCloseEvent, QMouseEvent, ...). In that case the include
mapping targets the primary class, i.e. the capital header whose lowercased
name equals the private header (QWidget for qwidget.h); headers with no such
primary (aggregates like qevent.h) get no include mapping and are left alone.
Usage:
tools/generate_qt_mappings.py [QT_INSTALL_HEADERS]
QT_INSTALL_HEADERS defaults to `qmake6 -query QT_INSTALL_HEADERS` (falling back
to common locations). The mapping is written to stdout.
"""
import argparse
import os
import re
import subprocess
import sys
INCLUDE_RE = re.compile(r'^\s*#include\s*<\s*Qt[A-Za-z0-9_]+/+\s*([^>\s]+)>')
CAPITAL_NAME_RE = re.compile(r'^Q[A-Za-z0-9_]+$')
PRIVATE_NAME_RE = re.compile(r'^q[a-z0-9_]+\.h$')
# Private Qt headers whose public spelling has to be named explicitly. Keep an
# entry here only if the target header exists in every Qt we support; IWYU would
# otherwise keep suggesting the private lowercase include, which is correct only
# for headers that have shipped publicly since Qt 5.
PORTABLE_WRAPPERS = {
# Qt 6.5 split the declarations that used to live in qglobal.h out into
# separate headers, under both a QtFoo wrapper and a qfoo.h private name.
# Neither spelling exists on the oldest Qt we support (6.4.2), and qtypes.h
# itself only appeared in Qt 6.9, so all of them are hard compile errors
# there. QtGlobal has been public since Qt 5, defines these declarations
# itself up to Qt 6.4, and includes the split headers from Qt 6.5 on, so it
# is the one spelling that resolves across the whole supported range.
'qtypes.h': 'QtGlobal',
'qtpreprocessorsupport.h': 'QtGlobal',
'qtversionchecks.h': 'QtGlobal',
'qassert.h': 'QtGlobal',
'qminmax.h': 'QtGlobal',
'qtclasshelpermacros.h': 'QtGlobal',
'qtenvironmentvariables.h': 'QtGlobal',
'qttranslation.h': 'QtGlobal',
'qoverload.h': 'QtGlobal',
}
def run_or_empty(cmd):
try:
return subprocess.run(cmd, capture_output=True, text=True,
check=True).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return ''
def default_qt_headers_dir():
for cmd in (['qmake6', '-query', 'QT_INSTALL_HEADERS'],
['qmake', '-query', 'QT_INSTALL_HEADERS']):
path = run_or_empty(cmd)
if path:
return path
for path in ('/usr/include/qt6', '/usr/lib64/qt6/include',
'/usr/local/include/qt6'):
if os.path.isdir(path):
return path
return ''
def collect_mappings(qt_headers_dir):
forwarders = {} # (module_dir, private) -> [capital names]
symbols = {} # capital name -> (module_dir, private)
for entry in sorted(os.listdir(qt_headers_dir)):
module_dir = os.path.join(qt_headers_dir, entry)
if not entry.startswith('Qt') or not os.path.isdir(module_dir):
continue
for name in sorted(os.listdir(module_dir)):
path = os.path.join(module_dir, name)
if not os.path.isfile(path) or not CAPITAL_NAME_RE.match(name):
continue
forwarded = []
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
match = INCLUDE_RE.match(line)
if match:
forwarded.append(match.group(1))
if len(forwarded) > 1:
break
if len(forwarded) != 1:
continue
private = forwarded[0]
if not PRIVATE_NAME_RE.match(private):
continue
forwarders.setdefault((module_dir, private), []).append(name)
symbols[name] = (module_dir, private)
return forwarders, symbols
def primary_include_mappings(forwarders):
mappings = {}
for (module_dir, private), names in sorted(forwarders.items()):
# Curated overrides win over the name rule below. They exist to redirect
# an include to a header that is valid across our whole supported Qt
# range, which the name rule cannot know about because it is generated
# from whatever Qt happens to be installed.
curated = PORTABLE_WRAPPERS.get(private)
if curated is not None:
mappings[(module_dir, private)] = curated
continue
# Prefer the forwarder whose name mirrors the private header
# (QAbstractAnimation -> qabstractanimation.h).
primary = next((n for n in names if n.lower() + '.h' == private), None)
if primary is not None:
mappings[(module_dir, private)] = primary
return mappings
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('qt_headers_dir', nargs='?', default=None,
help='Qt 6 headers dir (default: qmake query)')
args = parser.parse_args()
qt_headers_dir = args.qt_headers_dir or default_qt_headers_dir()
if not qt_headers_dir or not os.path.isdir(qt_headers_dir):
sys.stderr.write('error: could not locate Qt headers, pass the include '
'dir\n')
return 1
forwarders, symbols = collect_mappings(qt_headers_dir)
includes = primary_include_mappings(forwarders)
# A curated key that matches nothing is almost always a typo, and it fails
# silently: the entry just falls back to the name rule and emits the
# version-incompatible wrapper the override was meant to prevent.
known_private = {private for (_module_dir, private) in forwarders}
unknown = sorted(set(PORTABLE_WRAPPERS) - known_private)
if unknown:
sys.stderr.write('error: PORTABLE_WRAPPERS keys not found in the Qt '
'headers: %s\n' % ', '.join(unknown))
return 1
entries = []
# Headers we redirect in PORTABLE_WRAPPERS do not exist on the oldest
# supported Qt, so do not advertise them as the home of a symbol either.
redirected = {name
for (module_dir, private), names in forwarders.items()
if private in PORTABLE_WRAPPERS
for name in names}
for name in sorted(symbols):
if name in redirected:
continue
entries.append(
f' {{ "symbol": [ "{name}", "private", "<{name}>", "public"] }},')
# Free operators of QSharedPointer/QWeakPointer live in qsharedpointer.h.
# IWYU does not apply "include" mappings when *adding* an include for an
# operator or other non-class symbol, so map them to the canonical capital
# header explicitly. A use is only reported when the operator is not
# already covered by an included header, so this does not mis-attribute
# comparison operators of other types (which are provided by their own
# type headers).
for name in ('operator==', 'operator!=', 'operator<', 'operator<=',
'operator>', 'operator>=', 'comparesEqual'):
entries.append(
f' {{ "symbol": [ "{name}", "private", "<QSharedPointer>", "public"] }},')
for (module_dir, private), public in sorted(includes.items()):
module = os.path.basename(module_dir)
# IWYU's .imp tokenizer treats the value as a JSON-ish string:
# '\"' becomes a quote and '\\' becomes a backslash for the regex.
quoted = '\\"' # \" -- a JSON-escaped double quote in the .imp file
dotted = re.escape(private).replace('\\', '\\\\')
pattern = ('@[' + quoted + '<](' + re.escape(module) + r'/)?' +
dotted + '[' + quoted + '>]')
entries.append(
f' {{ "include": [ "{pattern}", "private", "<{public}>", "public"] }},')
if not entries:
sys.stderr.write('error: no Qt header mappings found\n')
return 1
print(f'# Do not edit! Generated by {os.path.basename(__file__)} from')
print(f'# {qt_headers_dir}')
print('[')
for entry in entries[:-1]:
print(entry)
print(entries[-1].rstrip(','))
print(']')
return 0
if __name__ == '__main__':
sys.exit(main())