diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml index 2385f20..7e16060 100644 --- a/.github/workflows/cpp.yml +++ b/.github/workflows/cpp.yml @@ -63,6 +63,12 @@ jobs: run: | ci-extra/update-tests.sh + - name: Install iwyu for checks + run: | + sudo apt-get update + sudo apt-get install -y python3 + sudo apt-get install -y iwyu + - name: Build run: | ci-extra/build.sh "$CT_CMAKE_PRESET" diff --git a/CMakeLists.txt b/CMakeLists.txt index 5551c0f..cf68db7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,9 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) ct_configure_compiler() include(Dependencies) +include(IWYU-Implementation) +ct_setup_iwyu() + # Setup a 'solution' target file(GLOB SOLUTION_SOURCES CONFIGURE_DEPENDS src/*.cpp) list(LENGTH SOLUTION_SOURCES SOLUTION_SOURCES_LENGTH) diff --git a/ci-extra/build.sh b/ci-extra/build.sh index f407573..8be291e 100755 --- a/ci-extra/build.sh +++ b/ci-extra/build.sh @@ -9,4 +9,8 @@ cmake -S . \ -D CT_TREAT_WARNINGS_AS_ERRORS=ON # Build -cmake --build "build/${PRESET_NAME}" -j +cmake --build "build/${PRESET_NAME}" -j | tee "build/build_log.out" + +if grep -Eq "include-what-you-use reported diagnostics" "build/build_log.out"; then + exit 1 +fi diff --git a/cmake/IWYU-Implementation.cmake b/cmake/IWYU-Implementation.cmake new file mode 100644 index 0000000..462ebc0 --- /dev/null +++ b/cmake/IWYU-Implementation.cmake @@ -0,0 +1,39 @@ +function(ct_setup_iwyu) + find_package(Python3 COMPONENTS Interpreter) + find_program(IWYU_BIN NAMES include-what-you-use iwyu) + + set(IWYU_MAPPING_ARGS "") + if (Python3_Interpreter_FOUND) + execute_process(COMMAND ${CMAKE_CXX_COMPILER} -dumpfullversion + OUTPUT_VARIABLE _gcc_ver_full + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + execute_process(COMMAND ${CMAKE_CXX_COMPILER} -dumpmachine + OUTPUT_VARIABLE _triple + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + string(REGEX REPLACE "^([0-9]+)\\..*" "\\1" _gcc_ver "${_gcc_ver_full}") + execute_process( + COMMAND ${Python3_EXECUTABLE} "${CMAKE_SOURCE_DIR}/cmake/iwyu-mapgen-libstdcxx.py" + --lang imp "/usr/include/c++/${_gcc_ver}" "/usr/include/${_triple}/c++/${_gcc_ver}" + OUTPUT_FILE "${CMAKE_SOURCE_DIR}/cmake/libstdcxx.imp" + ) + list(APPEND IWYU_MAPPING_ARGS "-Xiwyu" "--mapping_file=${CMAKE_SOURCE_DIR}/cmake/libstdcxx.imp") + else () + message(WARNING "python3 not found, running without mapping_file for iwyu") + endif () + + if (IWYU_BIN) + if (MSVC) + set(CMAKE_CXX_INCLUDE_WHAT_YOU_USE "${IWYU_BIN};--driver-mode=cl" + "-Wno-unknown-warning-option" + ${IWYU_MAPPING_ARGS} PARENT_SCOPE) + else () + set(CMAKE_CXX_INCLUDE_WHAT_YOU_USE "${IWYU_BIN}" + "-Wno-unknown-warning-option" + ${IWYU_MAPPING_ARGS} PARENT_SCOPE) + endif () + else () + message(WARNING "include-what-you-use not found, running without it") + endif () +endfunction() diff --git a/cmake/iwyu-mapgen-libstdcxx.py b/cmake/iwyu-mapgen-libstdcxx.py new file mode 100644 index 0000000..4ff0c80 --- /dev/null +++ b/cmake/iwyu-mapgen-libstdcxx.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 + +##===--- iwyu-mapgen-libstdcxx.py -----------------------------------------===## +# +# The LLVM Compiler Infrastructure +# +# This file is distributed under the University of Illinois Open Source +# License. See LICENSE.TXT for details. +# +##===----------------------------------------------------------------------===## + +"""Generates mappings for libstdc++ headers. + +The GNU libstdc++ standard library has fairly strong conventions for private +vs. public headers: + +- The library is split into a portable part in /usr/include/c++ and a + target-specific part in /usr/include/$target/c++ +- Private headers with a designated public header usually use a Doxygen + @headername directive to say which public header should be used instead +- Inline reusable template code is in .tcc files, all considered private +- Most private headers are in conventionally named subdirectories (bits/, + detail/ or debug/) + +IWYU dynamically maps @headername directives, so we don't need to (and +shouldn't) generate mappings for them. But we can use the presence of +@headername to decide this is a private header with an unambiguous public +mapping. + +For all other headers, we look at #include directives and map backwards from any +private header to any public header that includes it. + +To handle transitive mappings, we also map from any private header to any other +private header that includes it, except for the ones already mapped to public. +""" + +import argparse +import json +import os +import re +import sys +import textwrap + + +OUTPUT_HEADER = """ +GNU libstdc++ mappings generated with: + +%s + +Do not edit! +""" + +IGNORE_HEADERS = frozenset(( + # These internal headers are just textual includes to generate + # warnings. They do not define any symbols, so ignore them for mappings. + "backward/backward_warning.h", + "bits/c++0x_warning.h", +)) + +# These private headers are included by multiple public headers, but should +# always map to a single one. +EXPLICIT_MAPPINGS = { + "bits/exception.h": "exception", + # Only ambiguous in libstdc++-14, but override always. + "debug/vector": "vector", +} + +class Header: + """ Carries information about a single libstdc++ header. """ + def __init__(self, includename, has_headername, includes): + self.includename = includename + self.has_headername = has_headername + self.includes = includes + + @classmethod + def parse(cls, path, includename): + """ Parse a single file into a Header. """ + with open(path, "r") as fobj: + text = fobj.read() + + # Some private headers use Doxygen directive '@headername{xyz}' to + # indicate which is the public header. + has_headername = bool(re.search(r".*@headername{.*}", text)) + + # Parse all #include directives + included_names = re.finditer(r'^\s*#\s*include\s*["<](.*)[">]', + text, re.MULTILINE) + includes = [m.group(1) for m in included_names] + + return Header(includename, has_headername, includes) + + def is_private(self): + """ Return True if this Header has any private indicator. """ + # If the file contains @headername directives, it is a private header. + if self.has_headername: + return True + # All .tcc files are private. + if self.includename.endswith(".tcc"): + return True + # All debug/ headers are private. + if self.includename.startswith("debug/"): + return True + # The Policy-Based Data Structures ext library has all its private + # headers in detail/. + if self.includename.startswith("ext/pb_ds/detail/"): + return True + + # All headers immediately under bits/ are private. + dirpath = os.path.dirname(self.includename) + lastdir = os.path.basename(dirpath) + if lastdir == "bits": + return True + + return False + + +def shell_wrap(argv, width): + """ Wrap a shell command to width with proper line continuation chars + (assumes no quoted arguments with spaces). + """ + # Remove 2 chars for potential line continuation. + width -= 2 + + # Wrap the command text as a single paragraph. + command_text = " ".join(argv) + wrapped = textwrap.wrap(command_text, width=width, break_long_words=False, + break_on_hyphens=False, initial_indent="", + subsequent_indent=" ") + + # Add line continuation for all lines except last. + wrapped = [line + " \\" for line in wrapped[:-1]] + [wrapped[-1]] + return "\n".join(wrapped) + + +def output_header(comment_prefix): + """Return a header comment containing the exact command invocation, wrapped + to column width and commented with a prefix of choice. + """ + # Comment prefix will occupy character(s) + one space. + width = 80 - len(comment_prefix) + 1 + + # Write the argv into the header, nicely wrapped. + hdrtext = OUTPUT_HEADER.strip() % shell_wrap(sys.argv, width) + + def prefix(line): + """ Prefix each line with comment chars (and space if non-empty) """ + if not line: + return comment_prefix + return comment_prefix + " " + line + + hdrlines = [prefix(line) for line in hdrtext.splitlines()] + return "\n".join(hdrlines) + + +def write_cxx_mappings(public_mappings, private_mappings): + """ Write out mappings as C++ for pasting into iwyu_include_picker.cc. """ + print(output_header("//")) + print("const IncludeMapEntry libstdcpp_include_map[] = {") + print(" // Private-to-public #include mappings.") + for map_from, mapping_list in sorted(public_mappings.items()): + for map_to in sorted(mapping_list): + print(" { \"<%s>\", kPrivate, \"<%s>\", kPublic }," % + (map_from, map_to)) + print("};") + + +def write_imp_mappings(public_mappings, private_mappings): + """ Write out mappings as YAML for .imp mappings. """ + def quoted(name): + return json.dumps("<%s>" % name) + + print(output_header("#")) + print("[") + print(" # Private-to-public #include mappings.") + for map_from, mapping_list in sorted(public_mappings.items()): + for map_to in sorted(mapping_list): + print(' { "include": [%s, "private", %s, "public"] },' % + (quoted(map_from), quoted(map_to))) + print("]") + + +def main(rootdirs, lang, verbose): + """ Entry point. """ + public_headers = {} + private_headers = {} + + # Collect all headers. + for rootdir in rootdirs: + for root, dirs, files in os.walk(rootdir): + for name in files: + headerpath = os.path.join(root, name) + includename = os.path.relpath(headerpath, rootdir) + if includename in IGNORE_HEADERS: + continue + + header = Header.parse(headerpath, includename) + if header.is_private(): + private_headers[header.includename] = header + else: + public_headers[header.includename] = header + + # There must be no overlap between public and private headers. + assert public_headers.keys().isdisjoint(private_headers.keys()) + + # Build private-to-public mappings for all private headers without + # @headername included by a public header. + raw_public_mappings = {} + for header in public_headers.values(): + for include in header.includes: + included_header = private_headers.get(include) + if included_header and not included_header.has_headername: + raw_public_mappings.setdefault(include, set()).add( + header.includename) + + # Overwrite any explicit mappings. + public_mappings = {} + for private, public in raw_public_mappings.items(): + override = EXPLICIT_MAPPINGS.get(private) + if override: + public_mappings[private] = {override} + else: + public_mappings[private] = public + + # Keep only unambiguous mappings. + public_mappings = {k: v for k, v in public_mappings.items() if len(v) == 1} + + # Print suppressed mappings in verbose mode. + if verbose: + for private, public in raw_public_mappings.items(): + if private in public_mappings: + continue + print("suppressed ambiguous mapping: %s -> %s" % + (private, public), file=sys.stderr) + + # Then add private-to-private mappings for all private headers including + # another private header. + private_mappings = {} + for header in private_headers.values(): + for include in header.includes: + included_header = private_headers.get(include) + if included_header and not included_header.has_headername: + private_mappings.setdefault(include, set()).add( + header.includename) + + # Write out format depending on --lang switch + if lang == "c++": + write_cxx_mappings(public_mappings, private_mappings) + elif lang == "imp": + write_imp_mappings(public_mappings, private_mappings) + else: + print("error: unsupported language: %s" % lang) + return 1 + + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--lang", choices=["c++", "imp"], default="c++", + help="output language") + parser.add_argument("--verbose", "-v", action="store_true", + help="verbose output") + parser.add_argument("rootdirs", + nargs="+", + help=("include roots (usually /usr/include/c++/11 " + "/usr/include/x86_64-linux-gnu/c++/11/)")) + args = parser.parse_args() + sys.exit(main(args.rootdirs, args.lang, args.verbose))