diff --git a/parser/extractors.py b/parser/extractors.py index 17f0ee6..94a7aee 100644 --- a/parser/extractors.py +++ b/parser/extractors.py @@ -244,4 +244,36 @@ def extract_enum(node) -> dict: for v in node.get_children() if v.kind == clang.cindex.CursorKind.ENUM_CONSTANT_DECL ], - } \ No newline at end of file + } + + +def extract_macro(node) -> dict | None: + """Extract a public object-like integer ``#define`` macro. + + The MEOS public headers expose a handful of integer constants as + ``#define`` object-like macros rather than ``enum`` values — the WKB / WKT + output-variant flags (``WKB_NDR``, ``WKB_EXTENDED`` …), the ``MEOS_FLAG_*`` + bit masks, and similar. They are part of the public API surface (a binding's + FFI must carry them: ``meos-rs`` uses ``WKB_EXTENDED`` / ``WKB_NDR`` / + ``WKB_XDR`` to select a WKB variant), but ``enum`` extraction never sees them + because they are preprocessor definitions. + + Only object-like macros whose body is a single integer literal are + extracted (matching the constants a binding can project as a typed + constant); function-like macros and expression / string macros are skipped. + """ + tokens = [t.spelling for t in node.get_tokens()] + # tokens[0] is the macro name; a function-like macro has "(" immediately after. + if len(tokens) < 2 or tokens[1] == "(": + return None + body = "".join(tokens[1:]) + try: + value = int(body, 0) + except ValueError: + return None + return { + "name": node.spelling, + "file": Path(node.location.file.name).name, + "family": _family_of(node.location.file.name), + "value": value, + } diff --git a/parser/parser.py b/parser/parser.py index bd6a198..30f657f 100644 --- a/parser/parser.py +++ b/parser/parser.py @@ -6,7 +6,12 @@ import os from pathlib import Path -from parser.extractors import extract_function, extract_struct, extract_enum +from parser.extractors import ( + extract_function, + extract_struct, + extract_enum, + extract_macro, +) from parser.type_resolver import resolve_idl_types @@ -105,7 +110,11 @@ def parse_meos(entry: Path, include_dir: Path) -> dict: # it, and an undefined ``UNUSED`` makes clang error on the declarator and # silently drop the remaining parameters of that prototype. "-DUNUSED=__attribute__((unused))", - ] + _clang_extra_args()) + ] + _clang_extra_args(), + # Record ``#define`` macro definitions as cursors so the public + # object-like integer macros (WKB / WKT variant flags, ``MEOS_FLAG_*``) + # can be extracted into the catalog — an ``enum`` walk never sees them. + options=clang.cindex.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD) # Collect all .h files belonging to the project own_files = {str(p.resolve()) for p in include_dir.glob("**/*.h")} @@ -122,13 +131,19 @@ def parse_meos(entry: Path, include_dir: Path) -> dict: if m: typedef_map[m.group(1)] = node.spelling - functions, structs, enums = [], [], [] + functions, structs, enums, macros = [], [], [], [] for node in tu.cursor.walk_preorder(): loc = node.location.file if not loc or str(Path(loc.name).resolve()) not in own_files: continue # skip stdlib, system headers, etc. + if node.kind == clang.cindex.CursorKind.MACRO_DEFINITION: + macro = extract_macro(node) + if macro: + macros.append(macro) + continue + if node.kind == clang.cindex.CursorKind.FUNCTION_DECL: functions.append(extract_function(node)) @@ -161,8 +176,10 @@ def _dedup(items: list) -> list: functions = _dedup(functions) structs = _dedup(structs) enums = _dedup(enums) + macros = _dedup(macros) - idl = {"functions": functions, "structs": structs, "enums": enums} + idl = {"functions": functions, "structs": structs, "enums": enums, + "macros": macros} # Resolve types if the mappings file exists mappings_path = Path("./meta/type-mappings.json") diff --git a/run.py b/run.py index 22dd76c..de069f7 100644 --- a/run.py +++ b/run.py @@ -166,6 +166,7 @@ def main(): f"({exposable} stateless-exposable), " f"{len(idl['structs'])} structs, " f"{len(idl['enums'])} enums, " + f"{len(idl.get('macros', []))} macros, " f"{pa} portable bare-name aliases, " f"{cov} temporal covering types", file=sys.stderr) if om: diff --git a/tests/test_macros.py b/tests/test_macros.py new file mode 100644 index 0000000..e0c32ac --- /dev/null +++ b/tests/test_macros.py @@ -0,0 +1,53 @@ +"""Regression tests for public ``#define`` macro extraction into the IDL. + +A handful of MEOS public constants are object-like ``#define`` macros, not +``enum`` values — the WKB / WKT output-variant flags and the ``MEOS_FLAG_*`` +bit masks. An ``enum`` walk never sees them, so the parser records the +preprocessor definitions separately (``options`` enables the detailed +preprocessing record). A binding's FFI needs them: ``meos-rs`` selects a WKB +variant with ``WKB_EXTENDED`` / ``WKB_NDR`` / ``WKB_XDR``. + +The IDL is generated, not committed; run ``python run.py`` first. +""" +import json +import unittest +from pathlib import Path + +IDL = Path(__file__).resolve().parents[1] / "output" / "meos-idl.json" + + +class MacroTests(unittest.TestCase): + def setUp(self): + if not IDL.exists(): + self.skipTest(f"{IDL} not generated; run `python run.py` first") + idl = json.loads(IDL.read_text()) + self.macros = idl.get("macros", []) + self.by_name = {m["name"]: m for m in self.macros} + + def test_macros_present(self): + self.assertTrue(self.macros, "no macros extracted") + + def test_wkb_variant_flags(self): + # The variant flags meos-rs uses to select a WKB encoding. + for name, value in (("WKB_EXTENDED", 4), ("WKB_NDR", 8), ("WKB_XDR", 16)): + self.assertIn(name, self.by_name, f"{name} not extracted") + self.assertEqual(self.by_name[name]["value"], value) + + def test_values_are_integers(self): + for m in self.macros: + self.assertIsInstance(m["value"], int, f"{m['name']} value not int") + + def test_function_like_macros_excluded(self): + # Object-like integer macros only; a function-like macro (e.g. a + # ``#define FOO(x) ...``) must never leak in as a constant. + for m in self.macros: + self.assertNotIn("(", m["name"]) + + def test_each_macro_has_family_and_file(self): + for m in self.macros: + self.assertTrue(m.get("family")) + self.assertTrue(m.get("file", "").endswith(".h")) + + +if __name__ == "__main__": + unittest.main()