From 099dd1af42700137c65f9e104f25483639578c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esteban=20Zim=C3=A1nyi?= Date: Tue, 1 Sep 2026 00:09:10 +0200 Subject: [PATCH] Read a declaration's family from the guard it sits under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A family's declarations do not all live in the family's own header. A shared header carries some under `#if `, which MobilityDB compiles out when the family is off: `meos.h` holds `rtree_create_tpcbox`, `sptree_create_tpcbox` and `meos_initialize_pointcloud` that way, and `meos_catalog.h` four more. The classifier read only the path, so those came out `CORE` — the label that means "always emitted". A binding gating on the field emits them into a build whose headers never declared them and whose library never defined them, so the wrapper fails to compile, or compiles and fails to link. Nothing reports it from the catalog side, because `CORE` is exactly what an always-present function looks like. The guard is read first and outranks the path; the subdirectory and the `meos_.h` name answer as before. `#else` ends the family's region rather than continuing it, since the alternative branch is the one taken when the family is OFF. The tokens come from the published `families` list, so a family added to MobilityDB's `ALL` list is read here with no edit. Against MobilityDB 5d58fbc05f the catalog changes by exactly seven functions, all of them POINTCLOUD-guarded — `ensure_tpointcloud_temptype`, `meos_initialize_pointcloud`, `pointcloud_basetype`, `pointcloudset_type`, `rtree_create_tpcbox`, `sptree_create_tpcbox`, `tpointcloud_temptype` — with no function, struct, enum or macro added or removed and no other field altered anywhere. That MEOS.js carries five of the seven in a hand-maintained exclusion list, and is missing the other two, is the shape of the defect: the list is a copy of this classification kept by hand, and it went stale when `sptree_create_tpcbox` joined its `rtree_` sibling. The test asserts both directions — the seven land in POINTCLOUD, and the unguarded `sptree_create_stbox`/`sptree_create_tbox` beside them stay CORE, so it fails if the guard is read too widely as well as if it is not read at all. --- parser/extractors.py | 87 +++++++++++++++++++++++++++++++++++++------- tests/test_family.py | 16 ++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/parser/extractors.py b/parser/extractors.py index cd503bf..df1e36a 100644 --- a/parser/extractors.py +++ b/parser/extractors.py @@ -1,8 +1,9 @@ import re import clang.cindex +from functools import lru_cache from pathlib import Path -from parser.families import header_family, subdir_family +from parser.families import all_families, header_family, subdir_family def _canonical_spelling(ty) -> str: @@ -131,15 +132,75 @@ def _canonical_c_spelling(ty) -> str: # core, the base ``geo``/tpoint types the families build on, and the shared # top-level headers — is ``CORE`` and always emitted. # ----------------------------------------------------------------------------- -def _family_of(loc_path: str) -> str: - """Classify the declaring header into its optional family, or ``CORE``. - - The family is taken from the header's parent directory (the canonical - grouping); the top-level ``meos_.h`` public headers are mapped by - name. Both mappings are derived from MobilityDB's own ``ALL`` family list, - so a family added there is classified here with no edit. Anything unmatched - (temporal core, base geo, shared headers) is ``CORE`` and always emitted. +#: A family's declarations do not all live in the family's own header. A shared +#: header carries a few under ``#if ``, which MobilityDB compiles out when +#: the family is off — three in ``meos.h`` at the time of writing +#: (``rtree_create_tpcbox``, ``sptree_create_tpcbox``, ``meos_initialize_pointcloud``, +#: all under ``#if POINTCLOUD``). The path says ``meos.h``, so a path-only +#: classification calls them ``CORE``, and a consumer gating on the family field +#: emits them unconditionally: the declaration is gone from its build and the +#: symbol is absent from its library, so the wrapper fails to compile or fails to +#: link. Reading the guard is what makes the field answer for those too. +#: +#: `#else` ends the family's region rather than continuing it: the alternative +#: branch is the one taken when the family is OFF, so nothing in it belongs to the +#: family. +_GUARD_OPEN = re.compile( + r"^\s*#\s*if(?:def)?\s+(?:defined\s*\(\s*)?([A-Z][A-Z0-9_]*)\s*\)?\s*$") +_GUARD_ELSE = re.compile(r"^\s*#\s*el(?:se|if)\b") +_GUARD_CLOSE = re.compile(r"^\s*#\s*endif\b") + + +@lru_cache(maxsize=None) +def _guard_families(loc_path: str) -> tuple: + """Per-line family guard for a header: ``line number -> family or None``. + + Returned as a tuple indexed by 1-based line, so a lookup is a subscript. A + header that cannot be read guards nothing, which is the same answer a header + with no family guard gives. """ + families = set(all_families()) + try: + lines = Path(loc_path).read_text(errors="ignore").splitlines() + except OSError: + return () + out = [None] * (len(lines) + 1) + stack = [] + for i, line in enumerate(lines, start=1): + m = _GUARD_OPEN.match(line) + if m: + stack.append(m.group(1) if m.group(1) in families else None) + out[i] = None + continue + if _GUARD_CLOSE.match(line): + if stack: + stack.pop() + out[i] = None + continue + if _GUARD_ELSE.match(line): + if stack: + stack[-1] = None + out[i] = None + continue + out[i] = next((f for f in reversed(stack) if f is not None), None) + return tuple(out) + + +def _family_of(loc_path: str, line: int = 0) -> str: + """Classify a declaration into its optional family, or ``CORE``. + + The guard the declaration sits under is read first, because it is the one + signal that answers for a family's declarations placed outside the family's + own header. Otherwise the family is taken from the header's parent directory + (the canonical grouping), then from the top-level ``meos_.h`` public + headers by name. Every mapping derives from MobilityDB's own ``ALL`` family + list, so a family added there is classified here with no edit. Anything + unmatched (temporal core, base geo, shared headers) is ``CORE`` and always + emitted. + """ + guards = _guard_families(loc_path) + if 0 < line < len(guards) and guards[line] is not None: + return guards[line] path = Path(loc_path) fam = subdir_family().get(path.parent.name) if fam is not None: @@ -180,7 +241,7 @@ def extract_function(node) -> dict: return { "name": node.spelling, "file": Path(node.location.file.name).name, - "family": _family_of(node.location.file.name), + "family": _family_of(node.location.file.name, node.location.line), "vendored": _is_vendored(node.location.file.name), "returnType": { "c": _c_spelling(node.result_type), @@ -201,7 +262,7 @@ def extract_struct(node) -> dict: return { "name": node.spelling, "file": Path(node.location.file.name).name, - "family": _family_of(node.location.file.name), + "family": _family_of(node.location.file.name, node.location.line), "vendored": _is_vendored(node.location.file.name), "fields": [ { @@ -219,7 +280,7 @@ def extract_enum(node) -> dict: return { "name": node.spelling, "file": Path(node.location.file.name).name, - "family": _family_of(node.location.file.name), + "family": _family_of(node.location.file.name, node.location.line), "vendored": _is_vendored(node.location.file.name), "values": [ { @@ -259,7 +320,7 @@ def extract_macro(node) -> dict | None: return { "name": node.spelling, "file": Path(node.location.file.name).name, - "family": _family_of(node.location.file.name), + "family": _family_of(node.location.file.name, node.location.line), "vendored": _is_vendored(node.location.file.name), "value": value, } diff --git a/tests/test_family.py b/tests/test_family.py index 1329f0f..bb2b0df 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -73,6 +73,22 @@ def test_each_optional_family_is_populated(self): for family in self.optional_families - {"RASTER"}: self.assertIn(family, present, f"{family} unpopulated — classifier regression?") + def test_family_guard_outranks_the_declaring_header(self): + # A family's declarations do not all live in the family's own header: a + # shared header carries some under `#if `, which MobilityDB + # compiles out when the family is off. The path says meos.h, so a + # path-only classifier calls them CORE and a consumer gating on this + # field emits them into a build whose headers never declared them and + # whose library never defined them. These sit under `#if POINTCLOUD` in + # meos.h and meos_catalog.h; the control beside them is unguarded and + # stays CORE, so the test fails if the guard is read too widely as well + # as if it is not read at all. + for name in ("rtree_create_tpcbox", "sptree_create_tpcbox", + "meos_initialize_pointcloud", "pointcloud_basetype"): + self.assertEqual(self._family(name), "POINTCLOUD", name) + for name in ("sptree_create_stbox", "sptree_create_tbox"): + self.assertEqual(self._family(name), "CORE", name) + def test_families_are_known_labels(self): # The field's vocabulary is CORE plus the published list; anything else # means the classifier named a family the build never had.