Skip to content

Commit 4ef0fe5

Browse files
committed
scripts: check the include style of exported headers
Nothing catches the wrong form today, since both compile, and the quoted one silently resolves to whichever copy sits nearest. The check reads the SRCHEADERS list, so it follows whatever the build exports. Exported headers and anything beside them are left alone: they are copied to include/ and have to keep finding their siblings there. Everyone else is a user and takes the angled form. There are no per-file exceptions. Findings are warnings by default and errors with --error, which is how CI runs it, and named files can be passed for use from a pre-commit hook.
1 parent ab93007 commit 4ef0fe5

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ jobs:
190190
run: |
191191
set -x
192192
scripts/cppcheck.sh
193+
- name: Check include style of exported headers
194+
run: scripts/include-style-check.py --enforce
193195

194196
shellcheck:
195197
runs-on: ubuntu-24.04

‎scripts/include-style-check.py‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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. So does anything else in its directory, wanting the source next to it
18+
# rather than a stale export. Everything else is a user, builds out of tree
19+
# where only 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", ".icomp")
34+
35+
RE_QUOTED = re.compile(r'^[ \t]*#[ \t]*include[ \t]*"([^"]+)"', re.M)
36+
# A .comp only becomes C below its ;; line. Above it halcompile takes its own
37+
# include directive, 'include "bla";', which is not a preprocessor line.
38+
RE_COMP_QUOTED = re.compile(r'^[ \t]*include[ \t]*"([^"]+)"[ \t]*;', re.M)
39+
RE_SRCHEADERS = re.compile(r"^SRCHEADERS\s*:=\s*\\\n((?:.*\\\n)*.*)$", re.M)
40+
41+
42+
def usage():
43+
print("""Check the include style of exported headers.
44+
Usage:
45+
include-style-check.py [-e] [-h] [file...]
46+
47+
Checks every source file under src/ when given no file, which is how CI runs
48+
it. Named files are useful from a pre-commit hook.
49+
50+
Options:
51+
-e|--error Treat findings as errors (--enforce is accepted as well)
52+
-h|--help This message
53+
""")
54+
sys.exit(2)
55+
56+
57+
messages = []
58+
59+
#
60+
# Collect messages
61+
#
62+
def pfind(path, lineno, msg):
63+
global messages
64+
messages.append((path, lineno, msg))
65+
66+
def flush_messages():
67+
kind = "error" if error_on_warning else "warning"
68+
for path, lineno, msg in messages:
69+
print("{}:{}: {}: {}".format(path, lineno, kind, msg))
70+
# Annotate the offending lines when running under CI
71+
if os.environ.get("GITHUB_ACTIONS"):
72+
for path, lineno, msg in messages:
73+
print("::{} file={},line={},title=Include style::{}".format(kind, path, lineno, msg))
74+
if not messages:
75+
return 0
76+
return 1 if error_on_warning else 0
77+
78+
79+
def exported_headers():
80+
"""Map each exported header's basename onto its path under src/, taken
81+
from the SRCHEADERS list the build installs into include/."""
82+
with open(os.path.join(topdir, "src", "Makefile"), encoding="utf-8", errors="replace") as f:
83+
m = RE_SRCHEADERS.search(f.read())
84+
if not m:
85+
print("No SRCHEADERS list found in src/Makefile", file=sys.stderr)
86+
sys.exit(2)
87+
headers = {}
88+
for line in m.group(1).split("\n"):
89+
entry = line.strip().rstrip("\\").strip()
90+
if entry:
91+
headers[os.path.basename(entry)] = "src/" + entry
92+
return headers
93+
94+
95+
def tracked_files():
96+
"""All tracked files under src/, and of those the ones worth reading."""
97+
try:
98+
out = subprocess.run(["git", "-C", topdir, "ls-files", "src"],
99+
capture_output=True, text=True, check=True).stdout
100+
except (OSError, subprocess.CalledProcessError) as err:
101+
print(err, file=sys.stderr)
102+
sys.exit(2)
103+
# A tracked path can be absent from the working tree, deleted but not yet
104+
# committed or mid-rebase. The compiler would not see it either, so drop it
105+
# rather than fail on the open() below.
106+
tracked = {f for f in out.split("\n")
107+
if f and os.path.isfile(os.path.join(topdir, f))}
108+
return tracked, sorted(f for f in tracked if f.endswith(SUFFIXES))
109+
110+
111+
def check_quoted_includes(tracked, files, headers):
112+
exported = set(headers.values())
113+
for path in files:
114+
if path in exported:
115+
# An exported header is copied to include/ and has to keep finding
116+
# its siblings there, so it includes them with quotes.
117+
continue
118+
directory = os.path.dirname(path)
119+
with open(os.path.join(topdir, path), encoding="utf-8", errors="replace") as f:
120+
text = f.read()
121+
found = list(RE_QUOTED.finditer(text))
122+
if path.endswith((".comp", ".icomp")):
123+
head, sep, _ = text.partition("\n;;\n")
124+
found += RE_COMP_QUOTED.finditer(head if sep else text)
125+
found.sort(key=lambda m: m.start())
126+
for m in found:
127+
name = m.group(1)
128+
source = headers.get(os.path.basename(name))
129+
if source is None:
130+
continue # not an exported header, nothing to say about it
131+
# A file of that name beside the includer is a different header
132+
# that happens to share the basename, not this one.
133+
local = os.path.normpath(os.path.join(directory, name))
134+
if local != source and local in tracked:
135+
continue
136+
if directory == os.path.dirname(source):
137+
continue # beside the header, taking the source copy
138+
why = "exported by {}".format(os.path.dirname(source))
139+
lineno = text[:m.start()].count("\n") + 1
140+
pfind(path, lineno,
141+
'include "{}" is {}, use <{}>'.format(name, why, os.path.basename(name)))
142+
143+
144+
def main():
145+
try:
146+
opts, args = getopt.getopt(sys.argv[1:], "eh", ["error", "enforce", "help"])
147+
except getopt.GetoptError as err:
148+
print(err, file=sys.stderr)
149+
usage()
150+
151+
global error_on_warning
152+
for o, unused_a in opts:
153+
if o in ("-e", "--error", "--enforce"):
154+
error_on_warning = True
155+
elif o in ("-h", "--help"):
156+
usage()
157+
158+
if os.environ.get("INCLUDE_STYLE_CHECK_ENFORCE"):
159+
error_on_warning = True
160+
161+
# From here on we collect findings with pfind(). They get flushed when we
162+
# are done. The program's return value depends on whether findings are
163+
# treated as errors or not.
164+
headers = exported_headers()
165+
tracked, files = tracked_files()
166+
if args:
167+
# Named files, as a pre-commit hook would pass them. Anything outside
168+
# the set the whole-tree run covers has nothing to say about it.
169+
named = {os.path.relpath(os.path.abspath(a), topdir) for a in args}
170+
files = [f for f in files if f in named]
171+
check_quoted_includes(tracked, files, headers)
172+
173+
return
174+
175+
if __name__ == "__main__":
176+
main()
177+
sys.exit(flush_messages()) # Exit value depends on findings being errors

0 commit comments

Comments
 (0)