|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# |
| 3 | +# Check the include style of exported headers |
| 4 | +# Copyright (C) 2026 L. Toniolo |
| 5 | +# |
| 6 | +# This program is free software; you can redistribute it and/or modify it under |
| 7 | +# the terms of the GNU General Public License version 2 or later. |
| 8 | +# |
| 9 | +# The build copies every SRCHEADERS entry into include/, so an exported header |
| 10 | +# exists twice: the source under src/ and the copy every module compiles |
| 11 | +# against. A quoted include searches the includer's own directory first, an |
| 12 | +# angled include does not, so the two forms can reach different copies and both |
| 13 | +# compile. |
| 14 | +# |
| 15 | +# An exported header travels to include/ and must take its siblings with it, so |
| 16 | +# it includes them with quotes and finds the copies beside it wherever it ends |
| 17 | +# up. Its implementation does the same, wanting the source next to it rather |
| 18 | +# than a stale export. Everything else is a user, builds out of tree where only |
| 19 | +# the exported copies exist, and uses angle brackets. |
| 20 | +# |
| 21 | +import sys |
| 22 | +import os |
| 23 | +import re |
| 24 | +import getopt |
| 25 | +import subprocess |
| 26 | + |
| 27 | +error_on_warning = False |
| 28 | + |
| 29 | +# The script lives in scripts/ and the sources are one level up, so it runs |
| 30 | +# from anywhere. |
| 31 | +topdir = os.path.normpath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) |
| 32 | + |
| 33 | +SUFFIXES = (".c", ".cc", ".cpp", ".h", ".hh", ".comp") |
| 34 | + |
| 35 | +# Headers whose own directory also holds code that merely uses them. Sitting |
| 36 | +# beside the header says nothing there, so name the implementation and hold |
| 37 | +# every other file in that directory to the angled form. |
| 38 | +INTERFACES = { |
| 39 | + "src/emc/ini/inifile.hh": ( |
| 40 | + "src/emc/ini/inifile.cc", |
| 41 | + ), |
| 42 | + "src/hal/hal.h": ( |
| 43 | + "src/hal/hal_lib.c", |
| 44 | + "src/hal/hal_lib_extra.c", |
| 45 | + "src/hal/hal_lib_query.c", |
| 46 | + ), |
| 47 | +} |
| 48 | + |
| 49 | +RE_QUOTED = re.compile(r'^[ \t]*#[ \t]*include[ \t]*"([^"]+)"', re.M) |
| 50 | +RE_SRCHEADERS = re.compile(r"^SRCHEADERS\s*:=\s*\\\n((?:.*\\\n)*.*)$", re.M) |
| 51 | + |
| 52 | + |
| 53 | +def usage(): |
| 54 | + print("""Check the include style of exported headers. |
| 55 | +Usage: |
| 56 | + include-style-check.py [-e] [-h] [file...] |
| 57 | +
|
| 58 | +Checks every source file under src/ when given no file, which is how CI runs |
| 59 | +it. Named files are useful from a pre-commit hook. |
| 60 | +
|
| 61 | +Options: |
| 62 | + -e|--error Treat findings as errors (--enforce is accepted as well) |
| 63 | + -h|--help This message |
| 64 | +""") |
| 65 | + sys.exit(2) |
| 66 | + |
| 67 | + |
| 68 | +messages = [] |
| 69 | + |
| 70 | +# |
| 71 | +# Collect messages |
| 72 | +# |
| 73 | +def pfind(path, lineno, msg): |
| 74 | + global messages |
| 75 | + messages.append((path, lineno, msg)) |
| 76 | + |
| 77 | +def flush_messages(): |
| 78 | + kind = "error" if error_on_warning else "warning" |
| 79 | + for path, lineno, msg in messages: |
| 80 | + print("{}:{}: {}: {}".format(path, lineno, kind, msg)) |
| 81 | + # Annotate the offending lines when running under CI |
| 82 | + if os.environ.get("GITHUB_ACTIONS"): |
| 83 | + for path, lineno, msg in messages: |
| 84 | + print("::{} file={},line={},title=Include style::{}".format(kind, path, lineno, msg)) |
| 85 | + if not messages: |
| 86 | + return 0 |
| 87 | + return 1 if error_on_warning else 0 |
| 88 | + |
| 89 | + |
| 90 | +def exported_headers(): |
| 91 | + """Map each exported header's basename onto its path under src/, taken |
| 92 | + from the SRCHEADERS list the build installs into include/.""" |
| 93 | + with open(os.path.join(topdir, "src", "Makefile"), encoding="utf-8", errors="replace") as f: |
| 94 | + m = RE_SRCHEADERS.search(f.read()) |
| 95 | + if not m: |
| 96 | + print("No SRCHEADERS list found in src/Makefile", file=sys.stderr) |
| 97 | + sys.exit(2) |
| 98 | + headers = {} |
| 99 | + for line in m.group(1).split("\n"): |
| 100 | + entry = line.strip().rstrip("\\").strip() |
| 101 | + if entry: |
| 102 | + headers[os.path.basename(entry)] = "src/" + entry |
| 103 | + return headers |
| 104 | + |
| 105 | + |
| 106 | +def tracked_files(): |
| 107 | + """All tracked files under src/, and of those the ones worth reading.""" |
| 108 | + try: |
| 109 | + out = subprocess.run(["git", "-C", topdir, "ls-files", "src"], |
| 110 | + capture_output=True, text=True, check=True).stdout |
| 111 | + except (OSError, subprocess.CalledProcessError) as err: |
| 112 | + print(err, file=sys.stderr) |
| 113 | + sys.exit(2) |
| 114 | + tracked = set(out.split("\n")) |
| 115 | + return tracked, sorted(f for f in tracked if f.endswith(SUFFIXES)) |
| 116 | + |
| 117 | + |
| 118 | +def check_quoted_includes(tracked, files, headers): |
| 119 | + exported = set(headers.values()) |
| 120 | + for path in files: |
| 121 | + if path in exported: |
| 122 | + # An exported header is copied to include/ and has to keep finding |
| 123 | + # its siblings there, so it includes them with quotes. |
| 124 | + continue |
| 125 | + directory = os.path.dirname(path) |
| 126 | + with open(os.path.join(topdir, path), encoding="utf-8", errors="replace") as f: |
| 127 | + text = f.read() |
| 128 | + for m in RE_QUOTED.finditer(text): |
| 129 | + name = m.group(1) |
| 130 | + source = headers.get(os.path.basename(name)) |
| 131 | + if source is None: |
| 132 | + continue # not an exported header, nothing to say about it |
| 133 | + # A file of that name beside the includer is a different header |
| 134 | + # that happens to share the basename, not this one. |
| 135 | + local = os.path.normpath(os.path.join(directory, name)) |
| 136 | + if local != source and local in tracked: |
| 137 | + continue |
| 138 | + if source in INTERFACES: |
| 139 | + if path in INTERFACES[source]: |
| 140 | + continue # the implementation, taking its own header |
| 141 | + why = "used from outside its implementation" |
| 142 | + elif directory == os.path.dirname(source): |
| 143 | + continue # the implementation, taking the header beside it |
| 144 | + else: |
| 145 | + why = "exported by {}".format(os.path.dirname(source)) |
| 146 | + lineno = text[:m.start()].count("\n") + 1 |
| 147 | + pfind(path, lineno, |
| 148 | + 'include "{}" is {}, use <{}>'.format(name, why, os.path.basename(name))) |
| 149 | + |
| 150 | + |
| 151 | +def main(): |
| 152 | + try: |
| 153 | + opts, args = getopt.getopt(sys.argv[1:], "eh", ["error", "enforce", "help"]) |
| 154 | + except getopt.GetoptError as err: |
| 155 | + print(err, file=sys.stderr) |
| 156 | + usage() |
| 157 | + |
| 158 | + global error_on_warning |
| 159 | + for o, unused_a in opts: |
| 160 | + if o in ("-e", "--error", "--enforce"): |
| 161 | + error_on_warning = True |
| 162 | + elif o in ("-h", "--help"): |
| 163 | + usage() |
| 164 | + |
| 165 | + if os.environ.get("INCLUDE_STYLE_CHECK_ENFORCE"): |
| 166 | + error_on_warning = True |
| 167 | + |
| 168 | + # From here on we collect findings with pfind(). They get flushed when we |
| 169 | + # are done. The program's return value depends on whether findings are |
| 170 | + # treated as errors or not. |
| 171 | + headers = exported_headers() |
| 172 | + tracked, files = tracked_files() |
| 173 | + if args: |
| 174 | + # Named files, as a pre-commit hook would pass them. Anything outside |
| 175 | + # the set the whole-tree run covers has nothing to say about it. |
| 176 | + named = {os.path.relpath(os.path.abspath(a), topdir) for a in args} |
| 177 | + files = [f for f in files if f in named] |
| 178 | + check_quoted_includes(tracked, files, headers) |
| 179 | + |
| 180 | + return |
| 181 | + |
| 182 | +if __name__ == "__main__": |
| 183 | + main() |
| 184 | + sys.exit(flush_messages()) # Exit value depends on findings being errors |
0 commit comments