|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 4 | +# |
| 5 | +# abi-ffi-gate.py — fail (exit 1) if the Zig FFI does not conform to the Idris2 |
| 6 | +# ABI. The Idris2 ABI is the source of truth. Checks, with no toolchain needed: |
| 7 | +# |
| 8 | +# 1. the Zig FFI carries no unrendered `{{...}}` template tokens; |
| 9 | +# 2. every `%foreign "C:<name>"` symbol declared anywhere in the ABI .idr |
| 10 | +# sources is exported by the Zig FFI (`export fn <name>`); |
| 11 | +# 3. the Zig `Result = enum(c_int)` and the Idris `resultToInt` agree on BOTH |
| 12 | +# names and integer values (the `Error`/`err` spelling is treated as one). |
| 13 | +# |
| 14 | +# Usage: python3 scripts/abi-ffi-gate.py [repo_root] (defaults to cwd) |
| 15 | + |
| 16 | +import os |
| 17 | +import re |
| 18 | +import sys |
| 19 | +import glob |
| 20 | + |
| 21 | + |
| 22 | +def camel_to_snake(s): |
| 23 | + return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower() |
| 24 | + |
| 25 | + |
| 26 | +def canon_rc(name): |
| 27 | + n = name.lower() |
| 28 | + return "error" if n in ("err", "error") else n |
| 29 | + |
| 30 | + |
| 31 | +def find_result_enum(zig): |
| 32 | + """Return {variant: value} for the C-ABI Result enum, or {}.""" |
| 33 | + best = {} |
| 34 | + for m in re.finditer(r"enum\s*\(\s*c_int\s*\)\s*\{(.*?)\}", zig, re.S): |
| 35 | + body = m.group(1) |
| 36 | + variants = {} |
| 37 | + for vm in re.finditer(r'@?"?([A-Za-z_][A-Za-z0-9_]*)"?\s*=\s*(\d+)', body): |
| 38 | + variants[canon_rc(vm.group(1))] = int(vm.group(2)) |
| 39 | + # The Result enum is the one starting at ok = 0. |
| 40 | + if variants.get("ok") == 0 and len(variants) > len(best): |
| 41 | + best = variants |
| 42 | + return best |
| 43 | + |
| 44 | + |
| 45 | +def main(): |
| 46 | + root = sys.argv[1] if len(sys.argv) > 1 else "." |
| 47 | + name = os.path.basename(os.path.abspath(root)) |
| 48 | + abi_dir = os.path.join(root, "src/interface/abi") |
| 49 | + zig_path = os.path.join(root, "src/interface/ffi/src/main.zig") |
| 50 | + errs = [] |
| 51 | + |
| 52 | + idr_files = [ |
| 53 | + p for p in glob.glob(os.path.join(abi_dir, "**", "*.idr"), recursive=True) |
| 54 | + if os.sep + "build" + os.sep not in p |
| 55 | + ] |
| 56 | + if not idr_files: |
| 57 | + print(f"ABI-FFI GATE: SKIP ({name}) — no Idris2 ABI .idr files under {abi_dir}") |
| 58 | + return 0 |
| 59 | + if not os.path.exists(zig_path): |
| 60 | + print(f"ABI-FFI GATE: FAIL ({name}) — no Zig FFI at {zig_path}") |
| 61 | + return 1 |
| 62 | + |
| 63 | + idr = "\n".join(open(p, encoding="utf-8").read() for p in idr_files) |
| 64 | + zig = open(zig_path, encoding="utf-8").read() |
| 65 | + |
| 66 | + # 1. unrendered template tokens |
| 67 | + toks = sorted(set(re.findall(r"\{\{[A-Za-z0-9_]+\}\}", zig))) |
| 68 | + if toks: |
| 69 | + errs.append(f"Zig FFI has unrendered template tokens: {toks}") |
| 70 | + |
| 71 | + # 2. foreign C symbols must be exported |
| 72 | + csyms = sorted(set(re.findall(r"C:([A-Za-z0-9_]+)", idr))) |
| 73 | + exports = set(re.findall(r"export fn ([A-Za-z0-9_]+)", zig)) |
| 74 | + missing = [s for s in csyms if s not in exports] |
| 75 | + if missing: |
| 76 | + errs.append(f"{len(missing)} ABI function(s) not exported by the Zig FFI: {missing}") |
| 77 | + |
| 78 | + # 3. result-code map (names + values) must agree |
| 79 | + idr_rc = {} |
| 80 | + for m in re.finditer(r"resultToInt\s+([A-Za-z0-9]+)\s*=\s*(\d+)", idr): |
| 81 | + idr_rc[canon_rc(camel_to_snake(m.group(1)))] = int(m.group(2)) |
| 82 | + zig_rc = find_result_enum(zig) |
| 83 | + if idr_rc and not zig_rc: |
| 84 | + errs.append("no Zig `enum(c_int)` Result block (with `ok = 0`) found to compare result codes") |
| 85 | + elif idr_rc and zig_rc and idr_rc != zig_rc: |
| 86 | + errs.append( |
| 87 | + "Result-code map differs (name or value):\n" |
| 88 | + f" Idris resultToInt: {dict(sorted(idr_rc.items()))}\n" |
| 89 | + f" Zig Result enum: {dict(sorted(zig_rc.items()))}" |
| 90 | + ) |
| 91 | + |
| 92 | + if errs: |
| 93 | + print(f"ABI-FFI GATE: FAIL ({name})") |
| 94 | + for e in errs: |
| 95 | + print(" - " + e) |
| 96 | + return 1 |
| 97 | + print(f"ABI-FFI GATE: OK ({name}) — {len(csyms)} ABI functions exported, " |
| 98 | + f"{len(idr_rc)} result codes match") |
| 99 | + return 0 |
| 100 | + |
| 101 | + |
| 102 | +if __name__ == "__main__": |
| 103 | + sys.exit(main()) |
0 commit comments