ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_c23_patterns.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""check_c23_patterns.py -- enforce four C23 source patterns on first-party code.
5
6These four rules previously lived ONLY as inline ``grep`` loops inside the
7local ``scripts/git/pre-commit`` hook and were never run by the CI gate
8``pre-commit-checks`` (``gate_pre_commit_checks`` in
9``scripts/ci/gates/checks.sh``). The workflow claimed the gate mirrored the
10hook, but these four checks were absent from it -- a "local green, CI red"
11drift in the other direction, where a violation the hook rejects sails through
12CI on a machine whose hook is not installed. This checker is the single
13first-party implementation both the hook and the gate now call, so there is
14exactly one definition of each rule.
15
16The rules (CLAUDE.md "C23 Syntax" and "Constants and Macros"):
17
18 1. ``_Static_assert(`` -- C11 spelling; C23 provides ``static_assert``.
19 2. ``= {0}`` and typed variants such as ``= {0U}`` -- legacy
20 zero-initializers; C23 uses ``= {}``.
21 3. ``#include <stdbool.h>`` -- unnecessary; ``bool`` is a C23 keyword.
22 4. Object-like ``#define NAME <bare-numeric-literal>`` -- the value must be
23 paren-wrapped (``#define NAME (1000)``) so it stays a single token in
24 every expression context. Function-like macros, bare feature flags, and
25 already-parenthesised values are ignored; hex / binary / float / suffix
26 literal forms are all recognised.
27
28Scope:
29 C / C++ sources (.c .h .cpp .hpp) under libs/, src/, examples/, port/,
30 tools/, tests/. Vendored SOUP (libs/third_party/) and generated font data
31 (libs/ra8_fonts/) are exempt, as are build trees.
32
33Matches inside comments and string / character literals are ignored. All four
34rules share one raw-aware phase-2 logical lexer and physical-source origin map.
35Its zero-initializer view retains each literal as a non-whitespace token, so a
36string or character literal remains a real second initializer.
37
38Usage:
39 python3 scripts/checks/check_c23_patterns.py FILE [FILE ...]
40 python3 scripts/checks/check_c23_patterns.py # staged files
41 python3 scripts/checks/check_c23_patterns.py --all # every tracked file
42 python3 scripts/checks/check_c23_patterns.py --selftest # prove the rules fire
43
44Returns 0 on clean, 1 on findings, 2 on usage / selftest failure.
45"""
46
47from __future__ import annotations
48
49import argparse
50import bisect
51import re
52import subprocess
53import sys
54import tempfile
55from pathlib import Path
56
57sys.path.insert(0, str(Path(__file__).resolve().parent))
58
59from lint_targets import is_build_output_path
60
61REPO_ROOT = Path(__file__).resolve().parents[2]
62
63# First-party roots that carry hand-authored C. Mirrors check_no_gnu_attribute.
64ROOTS = ("libs", "examples", "port", "tools", "apps", "tests")
65EXTS = (".c", ".h", ".cpp", ".hpp")
66# Path fragments that exclude a file: vendored SOUP and generated font tables.
67EXEMPT_DIRS = ("/third_party/", "/ra8_fonts/")
68
69# Rule 1: C11 _Static_assert at the start of a line (after leading whitespace).
70# C23 spells it `static_assert`.
71_STATIC_ASSERT_RE = re.compile(r"^\s*_Static_assert\s*\‍(")
72
73# Rule 2: legacy single-zero initializers. The literal may carry an integer
74# type suffix (0U, 0UL, 0ULL, 0wb, 0z) or use an all-zero
75# decimal/octal/hexadecimal/binary spelling with C23 digit separators. C23's
76# empty initializer is the one canonical first-party form. The scanner owns
77# both C23 and C++23 sources, so the suffix union includes C++'s z/Z forms.
78_STANDARD_INT_SUFFIX = r"(?:[uU](?:(?:ll|LL)|[lL])?|(?:(?:ll|LL)|[lL])[uU]?|)"
79_BIT_PRECISE_SUFFIX = r"(?:[uU]?(?:wb|WB)|(?:wb|WB)[uU]?)"
80_SIZE_SUFFIX = r"(?:[uU]?[zZ]|[zZ][uU]?)"
81_ZERO_SUFFIX = rf"(?:{_STANDARD_INT_SUFFIX}|{_BIT_PRECISE_SUFFIX}|{_SIZE_SUFFIX})"
82_ZERO_DIGITS = r"0(?:'?0)*"
83_ZERO_BODY = rf"(?:{_ZERO_DIGITS}|0[xX]{_ZERO_DIGITS}|0[bB]{_ZERO_DIGITS})"
84_ZERO_INIT_RE = re.compile(rf"=\s*\{{\s*{_ZERO_BODY}{_ZERO_SUFFIX}\s*,?\s*\}}")
85
86# Rule 3: `#include <stdbool.h>` -- unnecessary, `bool` is a C23 keyword.
87_STDBOOL_RE = re.compile(r"^\s*#\s*include\s+<stdbool\.h>")
88
89# Rule 4: object-like `#define NAME <bare-numeric-literal>` whose value is not
90# paren-wrapped. Faithful translation of the ERE in scripts/git/pre-commit:
91# a bare integer or float literal (with optional U/L/F suffix, and hex / binary
92# / exponent forms), ignoring function-like macros, bare feature flags, and
93# already-parenthesised values. The trailing comment group is retained for
94# fidelity; the blanking below turns any real comment to spaces, which the
95# leading `\s*` absorbs.
96_BARE_DEFINE_RE = re.compile(
97 r"^\s*#\s*define\s+[A-Za-z_][A-Za-z0-9_]*\s+"
98 r"[+-]?(?:"
99 r"0[xX][0-9a-fA-F]+[uUlL]*"
100 r"|0[bB][01]+[uUlL]*"
101 r"|[0-9]+\.[0-9]*(?:[eE][+-]?[0-9]+)?[fFlL]?"
102 r"|\.[0-9]+(?:[eE][+-]?[0-9]+)?[fFlL]?"
103 r"|[0-9]+(?:[eE][+-]?[0-9]+)?[fFlLuU]*"
104 r")\s*(?:/[/*].*)?$"
105)
106
107# Each rule: (id, compiled regex, human-facing message). The id doubles as the
108# selftest key so a rule cannot be added without a fixture proving both
109# directions.
110_RULES: tuple[tuple[str, re.Pattern[str], str], ...] = (
111 ("static_assert", _STATIC_ASSERT_RE, "C11 _Static_assert -- use C23 static_assert"),
112 ("zero_init", _ZERO_INIT_RE, "legacy single-zero initializer -- use C23 = {}"),
113 ("stdbool", _STDBOOL_RE, "unnecessary #include <stdbool.h> -- bool is a C23 keyword"),
114 ("bare_define", _BARE_DEFINE_RE, "bare numeric #define value -- wrap with parens, e.g. (1000)"),
115)
116
117_RAW_PREFIXES = ('u8R"', 'uR"', 'UR"', 'LR"', 'R"')
118_RAW_DELIMITER_FORBIDDEN = frozenset(" ()\\\t\v\f\r\n")
119_RAW_DELIMITER_MAX = 16
120_DIGIT_CHARS = frozenset("0123456789abcdefABCDEF")
121_PP_NUMBER_CHARS = frozenset("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.'")
122
123
124def _is_digit_separator(text: str, index: int) -> bool:
125 """Return whether one apostrophe separates two preprocessing digits."""
126 if text[index] != "'" or index == 0 or index + 1 >= len(text):
127 return False
128 if text[index - 1] not in _DIGIT_CHARS or text[index + 1] not in _DIGIT_CHARS:
129 return False
130 start = index - 1
131 while start > 0 and text[start - 1] in _PP_NUMBER_CHARS:
132 start -= 1
133 prefix = text[start:index]
134 return prefix[0].isdigit() or (
135 prefix.startswith(".") and len(prefix) > 1 and prefix[1].isdigit()
136 )
137
138
139def _mask_position(
140 text: str,
141 code_out: list[str],
142 zero_out: list[str],
143 index: int,
144 *,
145 literal: bool,
146) -> None:
147 """Mask one logical byte in both policy views."""
148 if text[index] == "\n":
149 code_out[index] = zero_out[index] = "\n"
150 return
151 code_out[index] = " "
152 zero_out[index] = "L" if literal else " "
153
154
155def _mask_literal_byte(
156 text: str,
157 code_out: list[str],
158 zero_out: list[str],
159 index: int,
160 quote: str,
161) -> tuple[int, str]:
162 """Mask one literal byte and return the next offset and lexical state."""
163 char = text[index]
164 _mask_position(text, code_out, zero_out, index, literal=True)
165 if char == "\\" and index + 1 < len(text):
166 index += 1
167 _mask_position(text, code_out, zero_out, index, literal=True)
168 return index + 1, quote
169 if char == quote:
170 return index + 1, "code"
171 return index + 1, quote
172
173
174def _phase2_source(text: str) -> tuple[str, tuple[int, ...]]:
175 """Remove phase-2 line splices and retain each logical byte's origin."""
176 logical: list[str] = []
177 origins: list[int] = []
178 index = 0
179 while index < len(text):
180 if text[index] == "\\" and index + 1 < len(text) and text[index + 1] == "\n":
181 index += 2
182 continue
183 logical.append(text[index])
184 origins.append(index)
185 index += 1
186 return "".join(logical), tuple(origins)
187
188
189def _raw_literal_end(
190 text: str,
191 index: int,
192 physical_text: str,
193 origins: tuple[int, ...],
194) -> int | None:
195 """Return the end of a C++ raw literal, masking malformed forms to EOF.
196
197 ``None`` means no raw-literal prefix starts at ``index``. Once a standalone
198 prefix is present, malformed delimiters or absent terminators return EOF so
199 fake code inside an invalid literal cannot leak into the detector.
200 """
201 if index > 0 and (text[index - 1].isalnum() or text[index - 1] == "_"):
202 return None
203 prefix = next((item for item in _RAW_PREFIXES if text.startswith(item, index)), None)
204 if prefix is None:
205 return None
206 cursor = index + len(prefix)
207 delimiter: list[str] = []
208 terminator: str | None = None
209 for _bound in range(_RAW_DELIMITER_MAX + 1):
210 if cursor >= len(text):
211 break
212 char = text[cursor]
213 if char == "(":
214 terminator = f'){"".join(delimiter)}"'
215 break
216 if char in _RAW_DELIMITER_FORBIDDEN:
217 break
218 delimiter.append(char)
219 cursor += 1
220 if terminator is None:
221 return len(text)
222 physical_open = origins[cursor]
223 close = physical_text.find(terminator, physical_open + 1)
224 if close < 0:
225 return len(text)
226 physical_stop = close + len(terminator)
227 return bisect.bisect_left(origins, physical_stop)
228
229
230def _mask_token_range(
231 text: str, code_out: list[str], zero_out: list[str], start: int, stop: int
232) -> None:
233 """Mask one raw-literal range in both policy views."""
234 for offset in range(start, stop):
235 _mask_position(text, code_out, zero_out, offset, literal=True)
236
237
238def _mask_comment_byte(
239 text: str,
240 code_out: list[str],
241 zero_out: list[str],
242 index: int,
243 state: str,
244) -> tuple[int, str]:
245 """Mask one comment byte and return the next offset and lexical state."""
246 char = text[index]
247 nxt = text[index + 1] if index + 1 < len(text) else ""
248 if state == "line_comment":
249 if char == "\n":
250 return index + 1, "code"
251 _mask_position(text, code_out, zero_out, index, literal=False)
252 return index + 1, state
253 if char == "*" and nxt == "/":
254 _mask_position(text, code_out, zero_out, index, literal=False)
255 _mask_position(text, code_out, zero_out, index + 1, literal=False)
256 return index + 2, "code"
257 _mask_position(text, code_out, zero_out, index, literal=False)
258 return index + 1, state
259
260
261def _c23_lexical_views(text: str) -> tuple[str, str, tuple[int, ...]]:
262 """Build shared line-rule and zero-rule views of phase-2 source.
263
264 The line-rule view blanks comments and literals. The zero-rule view blanks
265 comments but retains literals as non-whitespace ``L`` tokens so a literal
266 remains a real second initializer. Both views share one raw-aware lexer,
267 and the origin table maps each logical byte to its physical source offset.
268
269 Args:
270 text: Original C-family source text.
271
272 Returns:
273 The line-rule view, zero-rule view, and physical-source origin table.
274 """
275 logical_text, origins = _phase2_source(text)
276 code_out = list(logical_text)
277 zero_out = list(logical_text)
278 state = "code"
279 index = 0
280 while index < len(logical_text):
281 char = logical_text[index]
282 nxt = logical_text[index + 1] if index + 1 < len(logical_text) else ""
283 if state == "code":
284 raw_end = _raw_literal_end(logical_text, index, text, origins)
285 if raw_end is not None:
286 _mask_token_range(logical_text, code_out, zero_out, index, raw_end)
287 index = raw_end
288 continue
289 if char == "/" and nxt in {"/", "*"}:
290 _mask_position(logical_text, code_out, zero_out, index, literal=False)
291 _mask_position(logical_text, code_out, zero_out, index + 1, literal=False)
292 state = "line_comment" if nxt == "/" else "block_comment"
293 index += 2
294 continue
295 if char == "'" and _is_digit_separator(logical_text, index):
296 index += 1
297 continue
298 if char in {'"', "'"}:
299 state = char
300 _mask_position(logical_text, code_out, zero_out, index, literal=True)
301 elif state in {"line_comment", "block_comment"}:
302 index, state = _mask_comment_byte(logical_text, code_out, zero_out, index, state)
303 continue
304 else:
305 index, state = _mask_literal_byte(logical_text, code_out, zero_out, index, state)
306 continue
307 index += 1
308 return "".join(code_out), "".join(zero_out), origins
309
310
311def _physical_line(text: str, origins: tuple[int, ...], logical_offset: int) -> int:
312 """Map one surviving logical byte to its 1-based physical source line."""
313 return text.count("\n", 0, origins[logical_offset]) + 1
314
315
316def _line_rule_violations(
317 text: str,
318 code_text: str,
319 origins: tuple[int, ...],
320 orig_lines: list[str],
321) -> list[tuple[int, str, str]]:
322 """Evaluate the three line rules on shared phase-2 logical source."""
323 violations: list[tuple[int, str, str]] = []
324 logical_offset = 0
325 for code in code_text.split("\n"):
326 first_token = len(code) - len(code.lstrip())
327 for rule_id, pattern, _msg in _RULES:
328 if rule_id == "zero_init" or pattern.search(code) is None:
329 continue
330 line_no = _physical_line(text, origins, logical_offset + first_token)
331 snippet = (orig_lines[line_no - 1] if line_no <= len(orig_lines) else code).strip()
332 violations.append((line_no, rule_id, snippet))
333 logical_offset += len(code) + 1
334 return violations
335
336
337def find_violations(path: Path) -> list[tuple[int, str, str]]:
338 """Report every C23-pattern violation in one file.
339
340 The scan runs over a comment/string-blanked view of the source so a match
341 inside a comment or a string literal is never reported. The phase-2 origin
342 table maps logical matches back to exact physical source lines.
343
344 Args:
345 path: File to scan.
346
347 Returns:
348 A list of ``(line_no, rule_id, snippet)`` tuples, one per finding;
349 an unreadable file yields an empty list rather than raising.
350 """
351 try:
352 text = path.read_text(encoding="utf-8", errors="replace")
353 except OSError:
354 return []
355 orig_lines = text.splitlines()
356 code_text, zero_init_text, origins = _c23_lexical_views(text)
357 violations = _line_rule_violations(text, code_text, origins, orig_lines)
358 for match in _ZERO_INIT_RE.finditer(zero_init_text):
359 line_no = _physical_line(text, origins, match.start())
360 snippet = (
361 orig_lines[line_no - 1] if line_no <= len(orig_lines) else match.group(0)
362 ).strip()
363 violations.append((line_no, "zero_init", snippet))
364 violations.sort(key=lambda finding: finding[0])
365 return violations
366
367
368def needs_check(path: Path) -> bool:
369 """Whether a path is first-party C/C++ subject to the C23 pattern rules.
370
371 Args:
372 path: Candidate file path.
373
374 Returns:
375 True when the suffix is a C/C++ one and the path is neither a build
376 artifact nor under a vendored / generated tree.
377 """
378 if path.suffix.lower() not in EXTS:
379 return False
380 posix = path.as_posix()
381 if is_build_output_path(posix):
382 return False
383 return not any(frag in f"/{posix}/" for frag in EXEMPT_DIRS)
384
385
386def _git_lines(*pathspec: str) -> list[str]:
387 """Return tracked repo-relative paths matching `pathspec`.
388
389 Args:
390 pathspec: git pathspec arguments (e.g. ``"*.c"``).
391
392 Returns:
393 Repo-relative path strings; exits 2 on a git failure.
394 """
395 # subprocess-security waivers for this one call:
396 # S603 -- the argv is a fixed literal list and shell is never used;
397 # `pathspec` contributes further git pathspec words only, never
398 # an executable name.
399 # S607 -- "git" is left partial deliberately. The gate must run whichever
400 # git the surrounding toolchain resolves (Homebrew on macOS,
401 # /usr/bin/git on the Ubuntu runners, a third path inside the
402 # devcontainer image), and infra/fleet.yml pins no absolute git
403 # path for any declared host.
404 proc = subprocess.run( # noqa: S603 -- fixed literal argv, shell is never used
405 ["git", "ls-files", "-z", "--", *pathspec], # noqa: S607 -- PATH-resolved git is intended
406 cwd=REPO_ROOT,
407 capture_output=True,
408 text=True,
409 check=False,
410 )
411 if proc.returncode != 0:
412 sys.stderr.write(proc.stderr)
413 sys.stderr.write(f"git ls-files failed (exit {proc.returncode})\n")
414 sys.exit(2)
415 return [p for p in proc.stdout.split("\0") if p]
416
417
418def iter_all_files() -> list[Path]:
419 """Every tracked first-party C/C++ file, for the ``--all`` sweep.
420
421 Returns:
422 Absolute paths under the first-party roots that pass ``needs_check``.
423 """
424 out: list[Path] = []
425 for root in ROOTS:
426 for rel in _git_lines(*(f"{root}/**/*{ext}" for ext in EXTS)):
427 p = REPO_ROOT / rel
428 if needs_check(p):
429 out.append(p)
430 return sorted(set(out))
431
432
433def iter_staged_files() -> list[Path]:
434 """Every staged first-party C/C++ file, for the default (hook) mode.
435
436 Returns:
437 Absolute paths of added / copied / modified / renamed staged files
438 that pass ``needs_check`` and still exist on disk.
439 """
440 proc = subprocess.run(
441 ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], # noqa: S607 -- trusted git
442 cwd=REPO_ROOT,
443 capture_output=True,
444 text=True,
445 check=False,
446 )
447 if proc.returncode != 0:
448 sys.stderr.write(proc.stderr)
449 sys.stderr.write(f"git diff --cached failed (exit {proc.returncode})\n")
450 sys.exit(2)
451 out: list[Path] = []
452 for rel in proc.stdout.split("\0"):
453 if not rel:
454 continue
455 p = REPO_ROOT / rel
456 if needs_check(p) and p.is_file():
457 out.append(p)
458 return out
459
460
461# ---------------------------------------------------------------------------
462# Selftest
463#
464# Every rule is asserted in BOTH directions: a bad fixture must fire exactly
465# that rule, and the matching correct-C23 form must stay silent. A comment and
466# a string-literal fixture prove the blanking suppresses matches that are not
467# really code. Fixtures are scanned in-memory via a temp file, so nothing a
468# checker's own tree scan could later trip over is written into the repo.
469# ---------------------------------------------------------------------------
470
471# (rule_id, source, should_fire). ``should_fire`` names the rule the source is
472# expected to trip; the negative cases assert a clean scan of the whole file.
473_SELFTEST_CASES: tuple[tuple[str, str, bool], ...] = (
474 # Rule 1: _Static_assert.
475 ("static_assert", '_Static_assert(sizeof(int) == 4, "width");\n', True),
476 ("static_assert", ' _Static_assert(1, "x");\n', True),
477 ("static_assert", 'static_assert(sizeof(int) == 4, "width");\n', False),
478 # Rule 2: legacy single-zero initializers, including typed spellings.
479 ("zero_init", "int table[4] = {0};\n", True),
480 ("zero_init", "unsigned table[4] = {0U};\n", True),
481 ("zero_init", "unsigned long table[4] = { 0UL };\n", True),
482 ("zero_init", "unsigned long long table[4]={0ULL};\n", True),
483 ("zero_init", "unsigned table[4] = {0x00u};\n", True),
484 ("zero_init", "unsigned table[4] = {0b00U};\n", True),
485 ("zero_init", "unsigned _BitInt(2) table[1] = {0wb};\n", True),
486 ("zero_init", "unsigned _BitInt(2) table[1] = {0uwb};\n", True),
487 ("zero_init", "unsigned _BitInt(2) table[1] = {0WBU};\n", True),
488 ("zero_init", "unsigned table[4] = {0x00ULL,};\n", True),
489 ("zero_init", "unsigned table[4] = {\n 0b00UL,\n};\n", True),
490 ("zero_init", "int table[4] = {};\n", False),
491 ("zero_init", "int table[4] = { };\n", False),
492 ("zero_init", "int table[4] = {0U, 1U};\n", False),
493 ("zero_init", "int table[4] = {\n 0U,\n 1U,\n};\n", False),
494 ("zero_init", "unsigned table[1] = {0'1U};\n", False),
495 ("zero_init", "unsigned table[1] = {0x0'1U};\n", False),
496 ("zero_init", "unsigned table[1] = {0b0'1U};\n", False),
497 ("zero_init", "long table[2] = {0z, 1z};\n", False),
498 # Rule 3: #include <stdbool.h>.
499 ("stdbool", "#include <stdbool.h>\n", True),
500 ("stdbool", "# include <stdbool.h>\n", True),
501 ("stdbool", "#include <stdint.h>\n", False),
502 # Rule 4: bare numeric #define.
503 ("bare_define", "#define K_TIMEOUT_MS 1000\n", True),
504 ("bare_define", "#define K_MASK 0xFF\n", True),
505 ("bare_define", "#define K_FLAGS 0b1010u\n", True),
506 ("bare_define", "#define K_SCALE 1.5f\n", True),
507 ("bare_define", "#define K_TIMEOUT_MS (1000)\n", False),
508 ("bare_define", "#define K_TIMEOUT_MS 1000 // trailing comment ok\n", True),
509 ("bare_define", "#define RA8_HAS_MVE\n", False),
510 ("bare_define", "#define MAX(a, b) ((a) > (b) ? (a) : (b))\n", False),
511 # Comment / string suppression: no rule may fire on non-code text.
512 ("_clean", "/* _Static_assert(x); int a[1] = {0U}; #define K 5 */\n", False),
513 ("_clean", 'const char* s = "= {0ULL}";\n', False),
514 ("_clean", "// #include <stdbool.h>\n", False),
515)
516
517# (source, exact opening line, should_fire). These fixtures exercise the
518# multiline lexical view independently of the rule-presence table above.
519_ZERO_INIT_LINE_CASES: tuple[tuple[str, int, bool], ...] = (
520 ('unsigned table[2] = {0U, "x"};\n', 1, False),
521 ('unsigned table[2] = {\n 0U,\n "x",\n};\n', 2, False),
522 ("unsigned table[2] = {0U, 'x'};\n", 1, False),
523 ("unsigned table[2] = {\n 0U,\n 'x',\n};\n", 2, False),
524 ("\nunsigned table[1] = {\n 0U, // only a comment follows\n};\n", 2, True),
525 ("\nunsigned table[1] = { /* before */ 0U, /* after */ };\n", 2, True),
526 ("\nunsigned table[1] = {\n 0x00ULL,\n};\n", 2, True),
527 ('const char *s = "unsigned table[1] = {0U};";\n', 1, False),
528 ("/* unsigned table[1] = {0U}; */\n", 1, False),
529 ("// unsigned table[1] = {0U};\n", 1, False),
530 ('const char *s = R"(before " = {0U} after)";\n', 1, False),
531 ('const char *s = u8R"tag(before ")" = {0U} after)tag";\n', 1, False),
532 ('const char *s = uR"tag(before = {0U} after)tag";\n', 1, False),
533 ('const char *s = UR"tag(before = {0U} after)tag";\n', 1, False),
534 ('const char *s = LR"tag(before = {0U} after)tag";\n', 1, False),
535 ('const char *s = R"tag(\nbefore " = {0U}\nafter\n)tag";\n', 1, False),
536 ('const char *s = R"unterminated(fake = {0U};\n', 1, False),
537 ('const char *s = R"abcdefghijklmnopq(fake = {0U})abcdefghijklmnopq";\n', 1, False),
538 ('const char *s = R"(fake = {0U})";\nint real[1] = {0U};\n', 2, True),
539 ("unsigned table[1] = {\\\n0U};\n", 1, True),
540 ("/\\\n/ fake = {0U};\nint real;\n", 1, False),
541 ("/\\\n* fake = {0U}; */\nint real;\n", 1, False),
542 ("/* fake = {0U}; *\\\n/\nint real;\n", 1, False),
543 ('const char *s = R\\\n"(fake = {0U})";\n', 1, False),
544 ('const char *s = R"tag(fake )tag\\\n" = {0U})tag";\n', 1, False),
545 ("// fake \\\nint hidden[1] = {0U};\n", 1, False),
546 ("// fake \\\nstill hidden\nint real[1] = {0U};\n", 3, True),
547)
548
549_PHASE2_CASES: tuple[tuple[str, str, tuple[int, ...]], ...] = (
550 ("plain", "plain", (0, 1, 2, 3, 4)),
551 ("\\", "\\", (0,)),
552 ("\\n", "\\n", (0, 1)),
553 ("\\\n", "", ()),
554 ("a\\\nb", "ab", (0, 3)),
555)
556
557_VALID_ZERO_SUFFIXES = (
558 "",
559 "u",
560 "U",
561 "l",
562 "L",
563 "ll",
564 "LL",
565 "ul",
566 "uL",
567 "Ul",
568 "UL",
569 "ull",
570 "uLL",
571 "Ull",
572 "ULL",
573 "lu",
574 "lU",
575 "Lu",
576 "LU",
577 "llu",
578 "llU",
579 "LLu",
580 "LLU",
581 "wb",
582 "WB",
583 "uwb",
584 "Uwb",
585 "uWB",
586 "UWB",
587 "wbu",
588 "wbU",
589 "WBu",
590 "WBU",
591 "z",
592 "Z",
593 "uz",
594 "Uz",
595 "uZ",
596 "UZ",
597 "zu",
598 "zU",
599 "Zu",
600 "ZU",
601)
602
603_VALID_ZERO_LITERALS = (
604 *(f"0{suffix}" for suffix in _VALID_ZERO_SUFFIXES),
605 "0'0U",
606 "0x0'0ULL",
607 "0b0'0uwb",
608 "0x0'0z",
609)
610
611_LINE_RULE_CASES: tuple[tuple[str, tuple[tuple[int, str], ...]], ...] = (
612 ('_Sta\\\ntic_assert(1, "ok");\n', ((1, "static_assert"),)),
613 ('\\\n_Static_assert(1, "ok");\n', ((2, "static_assert"),)),
614 ("#inc\\\nlude <stdbool.h>\n", ((1, "stdbool"),)),
615 ("#define K_TIME \\\n1000\n", ((1, "bare_define"),)),
616 ("/\\\n/ _Static_assert(1, x);\n", ()),
617 ("/\\\n* #include <stdbool.h> */\n", ()),
618 ("/* #define K 1000 *\\\n/\n", ()),
619 ('const char*s=R"(before "\n_Static_assert(1, x);\n)";\n', ()),
620 ('const char*s=R"(before "\n#include <stdbool.h>\n)";\n', ()),
621 ('const char*s=R"(before "\n#define K 1000\n)";\n', ()),
622 ('const char*s=R"(one\ntwo)";\n\n_Static_assert(1, "ok");\n', ((4, "static_assert"),)),
623 ("char8_t c = u8'0';\n_Static_assert(1, \"ok\");\n", ((2, "static_assert"),)),
624)
625
626
627def _zero_literal_failures(tmp: Path) -> list[str]:
628 """Return failures from the complete valid-zero literal table."""
629 failures: list[str] = []
630 for i, literal in enumerate(_VALID_ZERO_LITERALS):
631 fixture = tmp / f"zero_literal_case_{i}.cpp"
632 fixture.write_text(f"long table[1] = {{{literal}}};\n", encoding="utf-8")
633 fired = [rule_id for _line, rule_id, _snippet in find_violations(fixture)]
634 if fired != ["zero_init"]:
635 failures.append(f" zero literal case {i}: got {fired} for {literal!r}")
636 return failures
637
638
639def _line_rule_failures(tmp: Path) -> list[str]:
640 """Return failures from phase-2, raw-literal, and line-map cases."""
641 failures: list[str] = []
642 for i, (source, expected) in enumerate(_LINE_RULE_CASES):
643 fixture = tmp / f"line_rule_case_{i}.cpp"
644 fixture.write_text(source, encoding="utf-8")
645 actual = tuple((line, rule_id) for line, rule_id, _snippet in find_violations(fixture))
646 if actual != expected:
647 failures.append(
648 f" line-rule case {i}: got {actual!r}, expected {expected!r}: {source!r}"
649 )
650 return failures
651
652
653def selftest(tmp: Path) -> int:
654 """Prove each rule fires on a bad fixture and stays silent on the C23 form.
655
656 Args:
657 tmp: Writable scratch directory for the fixture files.
658
659 Returns:
660 0 when every case matches its expectation, 1 otherwise.
661 """
662 failures: list[str] = []
663 for i, (expect_id, source, should_fire) in enumerate(_SELFTEST_CASES):
664 fixture = tmp / f"case_{i}.c"
665 fixture.write_text(source, encoding="utf-8")
666 fired = {rule_id for _line, rule_id, _snip in find_violations(fixture)}
667 if should_fire:
668 if expect_id not in fired:
669 failures.append(f" case {i}: rule '{expect_id}' did not fire on: {source!r}")
670 elif fired:
671 failures.append(
672 f" case {i}: rules {sorted(fired)} fired but none expected: {source!r}"
673 )
674 for i, (source, expected_line, should_fire) in enumerate(_ZERO_INIT_LINE_CASES):
675 fixture = tmp / f"zero_line_case_{i}.c"
676 fixture.write_text(source, encoding="utf-8")
677 lines = [
678 line for line, rule_id, _snippet in find_violations(fixture) if rule_id == "zero_init"
679 ]
680 expected = [expected_line] if should_fire else []
681 if lines != expected:
682 failures.append(
683 f" zero-line case {i}: got lines {lines}, expected {expected}: {source!r}"
684 )
685 for i, (source, expected_text, expected_origins) in enumerate(_PHASE2_CASES):
686 actual_text, actual_origins = _phase2_source(source)
687 if (actual_text, actual_origins) != (expected_text, expected_origins):
688 failures.append(
689 f" phase-2 case {i}: got {(actual_text, actual_origins)!r}, "
690 f"expected {(expected_text, expected_origins)!r}"
691 )
692 failures.extend(_zero_literal_failures(tmp))
693 failures.extend(_line_rule_failures(tmp))
694 if failures:
695 print("check_c23_patterns.py --selftest: FAILED\n", file=sys.stderr)
696 print("\n".join(failures), file=sys.stderr)
697 return 1
698 fires = sum(1 for c in _SELFTEST_CASES if c[2])
699 total = (
700 len(_SELFTEST_CASES)
701 + len(_ZERO_INIT_LINE_CASES)
702 + len(_PHASE2_CASES)
703 + len(_VALID_ZERO_LITERALS)
704 + len(_LINE_RULE_CASES)
705 )
706 fires += sum(1 for case in _ZERO_INIT_LINE_CASES if case[2])
707 fires += len(_VALID_ZERO_LITERALS)
708 fires += sum(1 for _source, expected in _LINE_RULE_CASES if expected)
709 print(
710 f"check_c23_patterns.py --selftest: PASS "
711 f"({total} cases: {fires} must fire, {total - fires} must stay silent)"
712 )
713 return 0
714
715
716def _resolve_targets(args: argparse.Namespace) -> list[Path]:
717 """Decide which files to scan from the parsed arguments.
718
719 Args:
720 args: Parsed command-line arguments.
721
722 Returns:
723 The list of files to scan, filtered through ``needs_check``.
724 """
725 if args.all:
726 return iter_all_files()
727 if args.files:
728 return [Path(p) for p in args.files if needs_check(Path(p)) and Path(p).is_file()]
729 return iter_staged_files()
730
731
732def main(argv: list[str]) -> int:
733 """Scan first-party C/C++ for the four C23 patterns, or run the selftest.
734
735 With no ``--all`` and no explicit files the staged set is scanned, which is
736 how the pre-commit hook stays fast; ``--all`` sweeps every tracked file,
737 which is how the CI gate covers the whole tree.
738
739 Args:
740 argv: Full argument vector (``sys.argv``).
741
742 Returns:
743 0 when clean, 1 on findings or a failing selftest, 2 on a usage error.
744 """
745 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
746 parser.add_argument("--all", action="store_true", help="scan all tracked C/C++ files")
747 parser.add_argument(
748 "--selftest", action="store_true", help="prove each rule fires and stays silent correctly"
749 )
750 parser.add_argument("files", nargs="*", help="explicit file list (e.g. staged files)")
751 args = parser.parse_args(argv[1:])
752
753 if args.selftest:
754 with tempfile.TemporaryDirectory() as td:
755 return selftest(Path(td))
756
757 targets = _resolve_targets(args)
758
759 messages = {rule_id: msg for rule_id, _pat, msg in _RULES}
760 total = 0
761 for path in targets:
762 rel = path.relative_to(REPO_ROOT) if path.is_relative_to(REPO_ROOT) else path
763 for line_no, rule_id, snippet in find_violations(path):
764 print(f"{rel}:{line_no}: {messages[rule_id]}: {snippet}", file=sys.stderr)
765 total += 1
766
767 if total:
768 print(
769 f"\ncheck_c23_patterns.py: {total} C23-pattern violation(s). "
770 "Use static_assert, `= {}`, drop <stdbool.h>, and wrap bare "
771 "numeric #define values in parens.",
772 file=sys.stderr,
773 )
774 return 1
775 print(f"check_c23_patterns.py: {len(targets)} file(s) scanned, 0 findings.")
776 return 0
777
778
779if __name__ == "__main__":
780 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298