mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
[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:
parent
d603a2bb6a
commit
870ab23608
3 changed files with 2905 additions and 0 deletions
226
iwyu.sh
Executable file
226
iwyu.sh
Executable 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
|
||||
208
tools/generate_qt_mappings.py
Executable file
208
tools/generate_qt_mappings.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue