nativegate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. nativegate/__init__.py +1 -0
  2. nativegate/__main__.py +4 -0
  3. nativegate/buildinfo.py +344 -0
  4. nativegate/cli.py +2007 -0
  5. nativegate/config.py +991 -0
  6. nativegate/declared_invariants.py +565 -0
  7. nativegate/discovery.py +167 -0
  8. nativegate/driverbuild.py +626 -0
  9. nativegate/drivers/__init__.py +5 -0
  10. nativegate/drivers/cpp.py +616 -0
  11. nativegate/drivers/fortran.py +507 -0
  12. nativegate/generators/__init__.py +0 -0
  13. nativegate/generators/cmake_gen.py +101 -0
  14. nativegate/generators/docker_gen.py +614 -0
  15. nativegate/generators/error_gen.py +104 -0
  16. nativegate/generators/f2py_gen.py +91 -0
  17. nativegate/generators/gateway_gen.py +110 -0
  18. nativegate/generators/golden_gen.py +50 -0
  19. nativegate/generators/k8s_gen.py +212 -0
  20. nativegate/generators/mcp_gen.py +281 -0
  21. nativegate/generators/middleware_gen.py +717 -0
  22. nativegate/generators/pybind_gen.py +406 -0
  23. nativegate/generators/pyproject_gen.py +61 -0
  24. nativegate/generators/python_pkg_gen.py +1164 -0
  25. nativegate/generators/test_gen.py +160 -0
  26. nativegate/golden.py +747 -0
  27. nativegate/invariants.py +532 -0
  28. nativegate/ir.py +789 -0
  29. nativegate/lattice.py +350 -0
  30. nativegate/locking.py +216 -0
  31. nativegate/oracle.py +904 -0
  32. nativegate/parsers/__init__.py +0 -0
  33. nativegate/parsers/cpp.py +105 -0
  34. nativegate/parsers/cpp_ast.py +1652 -0
  35. nativegate/parsers/cpp_regex.py +812 -0
  36. nativegate/parsers/fixed_form.py +868 -0
  37. nativegate/parsers/fortran.py +157 -0
  38. nativegate/parsers/fortran_fparser.py +1116 -0
  39. nativegate/parsers/fortran_regex.py +686 -0
  40. nativegate/preprocess.py +335 -0
  41. nativegate/structural_invariants.py +762 -0
  42. nativegate/suggest.py +208 -0
  43. nativegate/templates/__init__.py +20 -0
  44. nativegate/templates/golden_test_template.py +248 -0
  45. nativegate/wire.py +438 -0
  46. nativegate-0.1.0.dist-info/METADATA +547 -0
  47. nativegate-0.1.0.dist-info/RECORD +50 -0
  48. nativegate-0.1.0.dist-info/WHEEL +5 -0
  49. nativegate-0.1.0.dist-info/entry_points.txt +3 -0
  50. nativegate-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1652 @@
1
+ """C++ header parser built on the Clang AST (design.md section 7).
2
+
3
+ This is the parser design.md always specified; `cpp_regex.py` was the interim
4
+ token/brace reader. Both produce the same `ir.ModuleIR`, so generators and the
5
+ CLI do not care which one ran — see `cpp.py` for the selection logic.
6
+
7
+ What moving to a real AST buys, concretely — every one of these is a case the
8
+ regex reader could not see:
9
+
10
+ * **The preprocessor runs.** `#include`d types, `#define`d names, `#ifdef`ed
11
+ declarations and macro-mangled `extern "C"` bridge headers (the F77_NAME
12
+ pattern) are expanded before parsing instead of being skipped and reported.
13
+ * **Templates.** Class and function templates are recognised as templates and
14
+ reported as unbindable-without-instantiation, rather than mis-parsed into a
15
+ bogus declaration or dropped silently.
16
+ * **Typedefs and aliases.** `typedef double Pressure;`, `using Depth = double;`
17
+ and `typedef struct {...} Point;` resolve to their canonical types.
18
+ * **Real name lookup.** A type is a known record because the AST says a record
19
+ with that name is declared, not because the same header happened to spell
20
+ `class X {` somewhere. Incompleteness is read off the declaration
21
+ (`is_definition()`), not guessed from capitalisation.
22
+ * **Overloads, `= delete`, `= default`, copy/move constructors, abstract
23
+ classes, access via inheritance, nested types, enums, references vs
24
+ pointers, cv-qualification** — all are distinguished by the AST rather than
25
+ by token shape.
26
+
27
+ What it still refuses to bind, deliberately (same rules as the interim
28
+ parser, because they are pybind11 constraints and not parsing limits):
29
+
30
+ * raw pointers to non-char, non-record types (no length information),
31
+ * raw pointer *returns* of record types (ownership would be handed to Python
32
+ while C++ still owns the object),
33
+ * class/function templates, which have no single instantiation to bind,
34
+ * STL and user types with no entry in `ir.TYPE_MAP` and no definition in the
35
+ service's headers.
36
+
37
+ All of those are reported through `ModuleIR.skipped` — one unbindable
38
+ signature never costs you the rest of the header.
39
+
40
+ Requires the `libclang` Python package (``pip install "nativegate[clang]"``).
41
+ `is_available()` reports whether it loaded; `cpp.py` falls back to the regex
42
+ reader when it did not.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import os
48
+ import subprocess
49
+ import sys
50
+ from dataclasses import dataclass
51
+ from pathlib import Path
52
+
53
+ from ..config import ExposeConfig
54
+ from ..ir import (
55
+ ClassDef,
56
+ FunctionDef,
57
+ Method,
58
+ ModuleIR,
59
+ NativeTypeError,
60
+ Parameter,
61
+ SkippedSymbol,
62
+ StructDef,
63
+ size_pairings,
64
+ )
65
+
66
+ # --- libclang loading ---------------------------------------------------
67
+
68
+ # Import failure must not be fatal at import time: nativegate's Fortran half,
69
+ # and the regex C++ reader, work fine without libclang installed.
70
+ try: # pragma: no cover - exercised by whichever branch the machine has
71
+ from clang import cindex as _cindex
72
+ except Exception: # ImportError, or a broken wheel
73
+ _cindex = None
74
+
75
+ _LOAD_ERROR: str | None = None
76
+ _LOADED = False
77
+
78
+ # Where a libclang shared library usually lives when the pip wheel didn't
79
+ # bring its own. NATIVEGATE_LIBCLANG overrides all of it.
80
+ _LIBCLANG_CANDIDATES = (
81
+ "/Library/Developer/CommandLineTools/usr/lib/libclang.dylib",
82
+ "/opt/homebrew/opt/llvm/lib/libclang.dylib",
83
+ "/usr/local/opt/llvm/lib/libclang.dylib",
84
+ "/usr/lib/llvm-18/lib/libclang.so.1",
85
+ "/usr/lib/llvm-17/lib/libclang.so.1",
86
+ "/usr/lib/llvm-16/lib/libclang.so.1",
87
+ "/usr/lib/x86_64-linux-gnu/libclang-18.so.1",
88
+ "/usr/lib/x86_64-linux-gnu/libclang.so.1",
89
+ # Windows: LLVM's own installer and the two package managers that ship it.
90
+ # A DLL is loaded by name, so these are absolute paths on purpose —
91
+ # set_library_file with a bare name would search PATH and could pick up an
92
+ # unrelated libclang.dll.
93
+ r"C:\Program Files\LLVM\bin\libclang.dll",
94
+ r"C:\Program Files (x86)\LLVM\bin\libclang.dll",
95
+ str(
96
+ Path(os.environ.get("ProgramData", r"C:\ProgramData"))
97
+ / "chocolatey/lib/llvm/tools/LLVM/bin/libclang.dll"
98
+ ),
99
+ str(
100
+ Path(os.environ.get("VCPKG_ROOT", r"C:\vcpkg"))
101
+ / "installed/x64-windows/bin/libclang.dll"
102
+ ),
103
+ )
104
+
105
+
106
+ def _load() -> bool:
107
+ """Import libclang and point it at a usable shared library. Idempotent."""
108
+ global _LOADED, _LOAD_ERROR
109
+ if _LOADED:
110
+ return True
111
+ if _cindex is None:
112
+ _LOAD_ERROR = (
113
+ "the 'libclang' Python package is not installed "
114
+ '(pip install "nativegate[clang]")'
115
+ )
116
+ return False
117
+
118
+ override = os.environ.get("NATIVEGATE_LIBCLANG")
119
+ candidates = [override] if override else list(_LIBCLANG_CANDIDATES)
120
+
121
+ # The pip `libclang` wheel ships its own native library and configures
122
+ # itself on import, so try a bare Index first and only go hunting if that
123
+ # fails.
124
+ try:
125
+ _cindex.Index.create()
126
+ _LOADED = True
127
+ return True
128
+ except Exception as exc: # library not found / not configured
129
+ _LOAD_ERROR = str(exc)
130
+
131
+ for candidate in candidates:
132
+ if not candidate or not Path(candidate).exists():
133
+ continue
134
+ try:
135
+ _cindex.Config.set_library_file(candidate)
136
+ _cindex.Index.create()
137
+ _LOADED = True
138
+ return True
139
+ except Exception as exc:
140
+ _LOAD_ERROR = f"{candidate}: {exc}"
141
+
142
+ if override and not Path(override).exists():
143
+ _LOAD_ERROR = f"NATIVEGATE_LIBCLANG={override} does not exist"
144
+ return False
145
+
146
+
147
+ def is_available() -> bool:
148
+ """True if a Clang AST parse can actually run on this machine."""
149
+ return _load()
150
+
151
+
152
+ def unavailable_reason() -> str:
153
+ return _LOAD_ERROR or "unknown"
154
+
155
+
156
+ def clang_version() -> str:
157
+ """A human-readable identifier for the libclang in use.
158
+
159
+ clang_getClangVersion() returns a CXString whose conversion is not exposed
160
+ consistently across libclang builds, so report the packaged version and the
161
+ library file instead — both are what you need when a parse behaves oddly.
162
+ """
163
+ if not _load():
164
+ return "unavailable"
165
+ try:
166
+ from importlib.metadata import version
167
+
168
+ return f"libclang {version('libclang')}"
169
+ except Exception:
170
+ pass
171
+ try:
172
+ return Path(_cindex.conf.get_filename()).name
173
+ except Exception:
174
+ return "unknown version"
175
+
176
+
177
+ # --- parse configuration ------------------------------------------------
178
+
179
+
180
+ DEFAULT_STD = "c++17"
181
+
182
+ _PLATFORM_ARGS: list[str] | None = None
183
+
184
+
185
+ def _platform_args() -> list[str]:
186
+ """Flags that let libclang find the standard library and its own builtins.
187
+
188
+ The pip `libclang` wheel is a bare front end: it has no notion of where
189
+ the SDK or its own `stdarg.h` live, so `#include <string>` fails and every
190
+ type that came from it silently degrades to `int` under error recovery.
191
+ That degradation is precisely the class of bug an AST parser is supposed
192
+ to eliminate, so the search happens up front and is cached.
193
+ """
194
+ global _PLATFORM_ARGS
195
+ if _PLATFORM_ARGS is not None:
196
+ return _PLATFORM_ARGS
197
+
198
+ args: list[str] = []
199
+
200
+ if sys.platform == "darwin":
201
+ sdk = _run(["xcrun", "--show-sdk-path"])
202
+ if sdk:
203
+ args += ["-isysroot", sdk]
204
+
205
+ resource_dir = _run(["clang", "-print-resource-dir"]) or _run(
206
+ ["xcrun", "clang", "-print-resource-dir"]
207
+ )
208
+ builtin_includes = []
209
+ if resource_dir:
210
+ builtin_includes.append(Path(resource_dir) / "include")
211
+ builtin_includes += sorted(
212
+ Path("/Library/Developer/CommandLineTools/usr/lib/clang").glob("*/include"),
213
+ reverse=True,
214
+ )
215
+ for candidate in builtin_includes:
216
+ if (candidate / "stdarg.h").exists():
217
+ # -idirafter, not -isystem: clang's builtin headers must come
218
+ # *after* the C++ standard library's, or libc++'s <cstddef> finds
219
+ # the C <stddef.h> first and the whole standard library falls over
220
+ # with "your header search paths are not configured properly".
221
+ args += ["-idirafter", str(candidate)]
222
+ break
223
+
224
+ _PLATFORM_ARGS = args
225
+ return args
226
+
227
+
228
+ def _run(command: list[str]) -> str | None:
229
+ try:
230
+ out = subprocess.run(
231
+ command, capture_output=True, text=True, timeout=20, check=False
232
+ )
233
+ except (OSError, subprocess.SubprocessError):
234
+ return None
235
+ value = out.stdout.strip()
236
+ return value if out.returncode == 0 and value else None
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class ClangOptions:
241
+ """Compiler flags for the parse. Mirrors nativegate.yaml's `clang:` block."""
242
+
243
+ std: str = DEFAULT_STD
244
+ include_paths: tuple[str, ...] = ()
245
+ defines: tuple[str, ...] = ()
246
+ extra_args: tuple[str, ...] = ()
247
+ # `-x <language>`. `.h` is ambiguous: a C header using `restrict` or a K&R
248
+ # declaration is not valid C++, and forcing `-x c++` reports the failure as
249
+ # a C++ diagnostic. Defaults to C++ so existing callers — config.ClangConfig
250
+ # among them, which does not know about this field — are unchanged.
251
+ language: str = "c++"
252
+ # extern "C" functions whose bare `T*` arguments are scalars passed by
253
+ # reference. See config.ClangConfig.scalar_ref_functions for why this is
254
+ # opt-in per function rather than inferred.
255
+ scalar_ref_functions: tuple[str, ...] = ()
256
+
257
+ def command_line(self, header: Path) -> list[str]:
258
+ args = ["-x", self.language, "-fsyntax-only"]
259
+ # A C++ -std with -x c (or the reverse) is a hard clang error, so the
260
+ # standard is only passed when it belongs to the selected language.
261
+ is_cpp_language = "c++" in self.language
262
+ is_cpp_std = self.std.startswith("c++") or self.std.startswith("gnu++")
263
+ if is_cpp_language == is_cpp_std:
264
+ args.append(f"-std={self.std}")
265
+ args += _platform_args()
266
+ # A header almost always includes its siblings by bare name.
267
+ args.append(f"-I{header.parent}")
268
+ args.extend(f"-I{p}" for p in self.include_paths)
269
+ args.extend(f"-D{d}" for d in self.defines)
270
+ args.extend(self.extra_args)
271
+ return args
272
+
273
+
274
+ # --- type mapping -------------------------------------------------------
275
+
276
+
277
+ def _kinds(*names):
278
+ """TypeKind members by name — the set varies across libclang versions."""
279
+ return {
280
+ getattr(_cindex.TypeKind, n) for n in names if hasattr(_cindex.TypeKind, n)
281
+ }
282
+
283
+
284
+ def _builtin_map() -> dict:
285
+ return {
286
+ **{k: "None" for k in _kinds("VOID")},
287
+ **{k: "bool" for k in _kinds("BOOL")},
288
+ **{
289
+ k: "int"
290
+ for k in _kinds(
291
+ "CHAR_U", "USHORT", "UINT", "ULONG", "ULONGLONG", "UINT128",
292
+ "SHORT", "INT", "LONG", "LONGLONG", "INT128", "WCHAR",
293
+ "CHAR16", "CHAR32",
294
+ )
295
+ },
296
+ **{k: "float" for k in _kinds("FLOAT", "DOUBLE", "LONGDOUBLE", "FLOAT128")},
297
+ # A lone `char` is a one-character string on the Python side, matching
298
+ # ir.TYPE_MAP. `char*` is handled separately, as a C string.
299
+ **{k: "str" for k in _kinds("CHAR_S", "SCHAR", "UCHAR")},
300
+ }
301
+
302
+
303
+ _CHAR_KIND_NAMES = ("CHAR_S", "SCHAR", "UCHAR", "CHAR_U")
304
+
305
+
306
+ def _strip_refs(t):
307
+ while t.kind in _kinds("LVALUEREFERENCE", "RVALUEREFERENCE"):
308
+ t = t.get_pointee()
309
+ return t
310
+
311
+
312
+ def _record_short_name(t) -> str | None:
313
+ """Unqualified name of the record/enum a type refers to, if any."""
314
+ decl = t.get_declaration()
315
+ if decl is None or decl.kind == _cindex.CursorKind.NO_DECL_FOUND:
316
+ return None
317
+ if decl.spelling:
318
+ return decl.spelling.split("::")[-1].split("<")[0]
319
+ return None
320
+
321
+
322
+ def _qualified_decl_name(decl) -> str | None:
323
+ """`petro::Correlation` for a declaration, walking namespaces and records.
324
+
325
+ The Python type name is lossy by construction (every enum is `int`), so a
326
+ generator that must emit C++ needs the spelling as written *and* qualified
327
+ enough to be valid at namespace scope in the generated binding file.
328
+ """
329
+ if decl is None or not decl.spelling:
330
+ return None
331
+ parts = [decl.spelling.split("<")[0]]
332
+ parent = decl.semantic_parent
333
+ while parent is not None and parent.kind != _cindex.CursorKind.TRANSLATION_UNIT:
334
+ if parent.spelling and parent.kind in (
335
+ _cindex.CursorKind.NAMESPACE,
336
+ _cindex.CursorKind.STRUCT_DECL,
337
+ _cindex.CursorKind.CLASS_DECL,
338
+ _cindex.CursorKind.UNION_DECL,
339
+ _cindex.CursorKind.CLASS_TEMPLATE,
340
+ ):
341
+ parts.append(parent.spelling.split("<")[0])
342
+ parent = parent.semantic_parent
343
+ return "::".join(reversed(parts))
344
+
345
+
346
+ _NAMED_TYPE_DECL_KINDS = lambda: ( # noqa: E731 - needs _cindex loaded first
347
+ _cindex.CursorKind.STRUCT_DECL,
348
+ _cindex.CursorKind.CLASS_DECL,
349
+ _cindex.CursorKind.UNION_DECL,
350
+ _cindex.CursorKind.ENUM_DECL,
351
+ _cindex.CursorKind.TYPEDEF_DECL,
352
+ _cindex.CursorKind.TYPE_ALIAS_DECL,
353
+ )
354
+
355
+
356
+ def _native_spelling(t) -> str | None:
357
+ """The C++ type as written, qualified — `std::uint64_t`, `petro::Correlation`.
358
+
359
+ Top-level `const` and reference decoration are dropped: constness travels
360
+ separately on `ir.Parameter.is_const`, and what a generator needs here is
361
+ the type to name inside `py::init<...>`.
362
+ """
363
+ base = _strip_refs(t)
364
+ decl = base.get_declaration()
365
+ if (
366
+ decl is not None
367
+ and decl.kind != _cindex.CursorKind.NO_DECL_FOUND
368
+ and decl.kind in _NAMED_TYPE_DECL_KINDS()
369
+ ):
370
+ qualified = _qualified_decl_name(decl)
371
+ if qualified:
372
+ return qualified
373
+ spelling = base.spelling.strip()
374
+ if spelling.startswith("const "):
375
+ spelling = spelling[len("const ") :].strip()
376
+ return spelling or None
377
+
378
+
379
+ _STD_STRING_SPELLINGS = frozenset(
380
+ {"std::string", "std::__1::string", "std::__cxx11::string", "string"}
381
+ )
382
+
383
+
384
+ def _is_std_string(t) -> bool:
385
+ """True for std::string in any of its spellings.
386
+
387
+ libclang keeps the sugar: the canonical type of `std::string` is spelled
388
+ `std::string` on libc++ and `std::__cxx11::basic_string<char, ...>` on
389
+ libstdc++, and a user typedef adds another layer on top. All of them are
390
+ the same std::string that pybind11 converts to a Python str.
391
+ """
392
+ canonical = t.get_canonical()
393
+ spelling = canonical.spelling.replace("const ", "").strip()
394
+ if "basic_string<char" in spelling or spelling in _STD_STRING_SPELLINGS:
395
+ return True
396
+ declaration = canonical.get_declaration()
397
+ return bool(
398
+ declaration
399
+ and declaration.spelling == "basic_string"
400
+ and "char" in declaration.type.spelling
401
+ )
402
+
403
+
404
+ def _is_incomplete_record(t) -> bool:
405
+ decl = t.get_declaration()
406
+ if decl is None:
407
+ return False
408
+ if decl.kind not in (
409
+ _cindex.CursorKind.STRUCT_DECL,
410
+ _cindex.CursorKind.CLASS_DECL,
411
+ _cindex.CursorKind.UNION_DECL,
412
+ ):
413
+ return False
414
+ return not decl.is_definition()
415
+
416
+
417
+ # C++ operator -> the Python special method pybind11 should bind it as, keyed
418
+ # by (spelling, argument count). The count matters: a member `operator-` with
419
+ # one argument is subtraction, with none it is negation, and binding one as the
420
+ # other would compile and then compute something else entirely.
421
+ _OPERATOR_DUNDERS = {
422
+ ("operator+", 1): "__add__",
423
+ ("operator-", 1): "__sub__",
424
+ ("operator*", 1): "__mul__",
425
+ ("operator/", 1): "__truediv__",
426
+ ("operator%", 1): "__mod__",
427
+ ("operator+", 0): "__pos__",
428
+ ("operator-", 0): "__neg__",
429
+ ("operator==", 1): "__eq__",
430
+ ("operator!=", 1): "__ne__",
431
+ ("operator<", 1): "__lt__",
432
+ ("operator<=", 1): "__le__",
433
+ ("operator>", 1): "__gt__",
434
+ ("operator>=", 1): "__ge__",
435
+ ("operator[]", 1): "__getitem__",
436
+ ("operator()", 1): "__call__",
437
+ ("operator!", 0): "__invert__",
438
+ }
439
+
440
+ # Why the rest are refused rather than approximated.
441
+ _OPERATOR_REFUSALS = {
442
+ "operator=": "assignment has no Python equivalent — Python rebinds names, it does not assign through them",
443
+ "operator+=": "a compound assignment must return the mutated object for Python's `__iadd__` protocol, and a C++ `T&` return needs a return-value policy nativegate cannot infer",
444
+ "operator-=": "same as operator+=",
445
+ "operator*=": "same as operator+=",
446
+ "operator/=": "same as operator+=",
447
+ "operator++": "Python has no increment operator",
448
+ "operator--": "Python has no decrement operator",
449
+ "operator->": "member access cannot be forwarded through pybind11",
450
+ "operator new": "allocation is not something Python may call",
451
+ "operator delete": "deallocation is managed by pybind11's holder, not by Python",
452
+ }
453
+
454
+
455
+ def _operator_dunder(spelling: str, arg_count: int) -> str | None:
456
+ return _OPERATOR_DUNDERS.get((spelling, arg_count))
457
+
458
+
459
+ def _operator_refusal(spelling: str, arg_count: int) -> str:
460
+ """Why this operator is not bound — never just 'unsupported'."""
461
+ base = spelling.split("(")[0].strip()
462
+ if base in _OPERATOR_REFUSALS:
463
+ return (
464
+ f"'{spelling}' is not bound: {_OPERATOR_REFUSALS[base]}. "
465
+ "Expose a named method instead, or exclude the symbol in "
466
+ "nativegate.yaml."
467
+ )
468
+ return (
469
+ f"'{spelling}' with {arg_count} argument(s) has no Python special "
470
+ "method nativegate maps it to. Expose a named method instead, or "
471
+ "exclude the symbol in nativegate.yaml."
472
+ )
473
+
474
+
475
+ class PointerLengthUnknown(NativeTypeError):
476
+ """A pointer to a scalar, whose length is not knowable from the type alone.
477
+
478
+ Still a NativeTypeError, so every existing caller keeps refusing it. What
479
+ the subclass adds is the information `_parameters` needs to reconsider:
480
+ the element type, and whether the callee may write through it. If another
481
+ argument of the same function turns out to be this pointer's length, the
482
+ pair becomes a bindable array; otherwise this propagates unchanged.
483
+ """
484
+
485
+ def __init__(self, message: str, *, element: str, is_mutable: bool) -> None:
486
+ super().__init__(message)
487
+ self.element = element
488
+ self.is_mutable = is_mutable
489
+
490
+
491
+ def _refuse_mutable_vector_ref(t, *, is_return: bool) -> None:
492
+ """Refuse `std::vector<T>&` — pybind11 discards writes through it, silently.
493
+
494
+ THIS IS THE REASON std::vector SUPPORT IS SAFE TO HAVE AT ALL.
495
+
496
+ `<pybind11/stl.h>` converts a Python list to a *temporary* std::vector for
497
+ the duration of the call. For an input parameter — by value or by
498
+ `const&` — that is exactly right. For a non-const `std::vector<T>&`, which
499
+ in C++ means "I am going to write to your vector", the callee writes into
500
+ the temporary and the temporary is destroyed on return. The caller's list
501
+ is unchanged.
502
+
503
+ Nothing raises. Nothing warns. It compiles, it runs, it returns. Measured
504
+ with a real pybind11 module:
505
+
506
+ void scale_in_place(std::vector<double>& v, double k);
507
+
508
+ >>> data = [1.0, 2.0, 3.0]
509
+ >>> probe.scale_in_place(data, 10.0)
510
+ >>> data
511
+ [1.0, 2.0, 3.0] # writes discarded
512
+
513
+ A service that binds that builds, imports, passes a smoke test, and returns
514
+ unmodified data to whoever asked. That is the exact failure class this
515
+ project treats as the worst kind, so the binding is refused instead.
516
+
517
+ Returning `std::vector<T>&` is fine to bind — pybind11 copies it out, and a
518
+ copy of a returned value is what Python wants anyway.
519
+ """
520
+ if is_return or t.kind not in _kinds("LVALUEREFERENCE"):
521
+ return
522
+ pointee = t.get_pointee()
523
+ if not _is_std_vector(pointee):
524
+ return
525
+ if pointee.is_const_qualified() or pointee.get_canonical().is_const_qualified():
526
+ return
527
+ raise NativeTypeError(
528
+ f"'{t.spelling}' is a mutable reference to a std::vector. pybind11 "
529
+ "converts the caller's list into a temporary vector, so anything the "
530
+ "function writes through this parameter is discarded when the call "
531
+ "returns — silently, with no error. Take 'const std::vector<T>&' if "
532
+ "the data is input, or RETURN the modified vector, or exclude the "
533
+ "symbol in nativegate.yaml."
534
+ )
535
+
536
+
537
+ def _is_std_vector(t) -> bool:
538
+ """True for std::vector<T> in any spelling, through typedefs."""
539
+ canonical = t.get_canonical()
540
+ spelling = canonical.spelling.replace("const ", "").strip()
541
+ return spelling.startswith("std::vector<") or spelling.startswith(
542
+ "std::__1::vector<"
543
+ )
544
+
545
+
546
+ def _vector_element(t):
547
+ """The T of a std::vector<T>, or None if it cannot be read off the AST."""
548
+ canonical = t.get_canonical()
549
+ try:
550
+ if canonical.get_num_template_arguments() > 0:
551
+ return canonical.get_template_argument_type(0)
552
+ except (AttributeError, TypeError): # older libclang on some platforms
553
+ pass
554
+ return None
555
+
556
+
557
+ def _map_type(t, known_records: frozenset[str], *, is_return: bool) -> tuple[str, bool]:
558
+ """Map a clang Type to (python_type, is_array), or raise NativeTypeError.
559
+
560
+ The refusals mirror cpp_regex._normalize_type so that switching parsers
561
+ never changes which signatures are considered safe — only how accurately
562
+ they are recognised.
563
+ """
564
+ # Checked BEFORE _strip_refs, because for std::vector the reference and its
565
+ # constness are the whole question — see _refuse_mutable_vector_ref.
566
+ _refuse_mutable_vector_ref(t, is_return=is_return)
567
+
568
+ t = _strip_refs(t)
569
+ spelling = t.spelling
570
+
571
+ if _is_std_vector(t):
572
+ element = _vector_element(t)
573
+ if element is None:
574
+ raise NativeTypeError(
575
+ f"'{spelling}' is a std::vector whose element type could not be "
576
+ "read from the AST. Expose a function taking a concrete vector "
577
+ "(std::vector<double>, std::vector<int>, ...) instead, or "
578
+ "exclude the symbol in nativegate.yaml."
579
+ )
580
+ inner, inner_is_array = _map_type(element, known_records, is_return=is_return)
581
+ if inner_is_array:
582
+ raise NativeTypeError(
583
+ f"'{spelling}' is a nested container. pybind11 will convert it, "
584
+ "but nativegate has no IR shape for a list of lists, so the "
585
+ "generated Pydantic model would be wrong. Flatten it (pass the "
586
+ "data and its shape separately), or exclude the symbol."
587
+ )
588
+ if inner not in ("float", "int", "bool", "str"):
589
+ raise NativeTypeError(
590
+ f"'{spelling}' has element type '{inner}', which is not a scalar "
591
+ "pybind11 converts element-wise. Use a vector of a numeric type "
592
+ "or std::string, or exclude the symbol in nativegate.yaml."
593
+ )
594
+ return inner, True
595
+
596
+ # `double values[4]` / `double values[]` as a *field*; parameters decay to
597
+ # pointers and are recovered from the source tokens by the caller.
598
+ array_kinds = _kinds("CONSTANTARRAY", "INCOMPLETEARRAY", "VARIABLEARRAY", "DEPENDENTSIZEDARRAY")
599
+ if t.kind in array_kinds:
600
+ element, _ = _map_type(t.element_type, known_records, is_return=is_return)
601
+ return element, True
602
+
603
+ if t.kind in _kinds("POINTER"):
604
+ pointee = _strip_refs(t.get_pointee())
605
+ canonical_pointee = pointee.get_canonical()
606
+
607
+ if any(canonical_pointee.kind in _kinds(n) for n in _CHAR_KIND_NAMES):
608
+ # `const char*` is a C string, the one pointer with an unambiguous
609
+ # Python meaning. A *non-const* `char*` is an output buffer with no
610
+ # length convention: bound as a Python str, pybind11 materialises a
611
+ # temporary that C++ then writes through, so the write is lost or
612
+ # lands in freed memory.
613
+ if pointee.is_const_qualified() or canonical_pointee.is_const_qualified():
614
+ return "str", False
615
+ raise NativeTypeError(
616
+ f"'{spelling}' is a non-const 'char*': an output buffer with no "
617
+ "length convention. Binding it as a Python str would have C++ "
618
+ "write through a temporary. Return std::string (or take "
619
+ "'const char*' for input), or exclude the symbol in nativegate.yaml."
620
+ )
621
+
622
+ name = _record_short_name(pointee)
623
+ if name and name in known_records:
624
+ if is_return:
625
+ raise NativeTypeError(
626
+ f"'{spelling}' returns a raw pointer to '{name}'; binding it would "
627
+ "hand ownership to Python while C++ still owns the object. Return by "
628
+ "value or by reference instead."
629
+ )
630
+ return name, False
631
+
632
+ if name and _is_incomplete_record(pointee):
633
+ raise NativeTypeError(
634
+ f"'{spelling}' refers to '{name}', which is only forward-declared "
635
+ "(an incomplete type). pybind11 needs the full definition to bind it. "
636
+ f"Add the header that defines '{name}' to this service's native/ "
637
+ "directory, or exclude the symbol in nativegate.yaml."
638
+ )
639
+
640
+ # A pointer to a scalar MIGHT still be bindable — as an array, if some
641
+ # other parameter of the same function carries its length. That cannot
642
+ # be decided here, one type at a time, so the decision is deferred to
643
+ # _parameters, which can see the whole signature. Raising a distinct
644
+ # exception (carrying what was learned about the pointee) is how the
645
+ # information gets there without _map_type growing a second job.
646
+ element = _builtin_map().get(canonical_pointee.kind)
647
+ if element in ("float", "int", "bool"):
648
+ raise PointerLengthUnknown(
649
+ f"'{spelling}' is a pointer; nativegate cannot infer its length. "
650
+ "Wrap it in a type that carries its own size (std::vector, a "
651
+ "span, or a struct), pass its length in an argument named after "
652
+ "it (`n`, `n_values`, `values_len`, ...), or exclude the symbol "
653
+ "in nativegate.yaml.",
654
+ element=element,
655
+ is_mutable=not (
656
+ pointee.is_const_qualified()
657
+ or canonical_pointee.is_const_qualified()
658
+ ),
659
+ )
660
+
661
+ raise NativeTypeError(
662
+ f"'{spelling}' is a pointer; nativegate cannot infer its length. "
663
+ "Wrap it in a type that carries its own size (std::vector, a span, "
664
+ "or a struct) or exclude the symbol in nativegate.yaml."
665
+ )
666
+
667
+ if t.kind in _kinds("ENUM") or t.get_canonical().kind in _kinds("ENUM"):
668
+ # A scoped or unscoped enum crosses the boundary as its integer value.
669
+ return "int", False
670
+
671
+ if _is_std_string(t):
672
+ return "str", False
673
+
674
+ canonical = t.get_canonical()
675
+ builtin = _builtin_map().get(canonical.kind)
676
+ if builtin is not None:
677
+ return builtin, False
678
+
679
+ name = _record_short_name(t)
680
+ if name and name in known_records:
681
+ return name, False
682
+
683
+ if name and _is_incomplete_record(t):
684
+ raise NativeTypeError(
685
+ f"'{spelling}' refers to '{name}', which is only forward-declared "
686
+ "(an incomplete type). pybind11 needs the full definition to bind it. "
687
+ f"Add the header that defines '{name}' to this service's native/ "
688
+ "directory, or exclude the symbol in nativegate.yaml."
689
+ )
690
+
691
+ raise NativeTypeError(
692
+ f"Unsupported native type '{spelling}': add a mapping in ir.TYPE_MAP, "
693
+ "define the type in this service's headers, or exclude this symbol "
694
+ "from nativegate.yaml."
695
+ )
696
+
697
+
698
+ def _declared_as_array(cursor) -> bool:
699
+ """True if a parameter was *written* as an array (`double v[]`).
700
+
701
+ C decays an array parameter to a pointer before the AST sees it, so the
702
+ only surviving evidence is the source spelling. `is_array` on the IR is
703
+ what tells the generated service to accept a sequence there.
704
+ """
705
+ try:
706
+ tokens = [tok.spelling for tok in cursor.get_tokens()]
707
+ except Exception:
708
+ return False
709
+ return "[" in tokens
710
+
711
+
712
+ def _is_extern_c(cursor) -> bool:
713
+ """True when this declaration sits inside an `extern "C"` block."""
714
+ parent = cursor.lexical_parent
715
+ while parent is not None and parent.kind != _cindex.CursorKind.TRANSLATION_UNIT:
716
+ if parent.kind == _cindex.CursorKind.LINKAGE_SPEC:
717
+ return True
718
+ parent = parent.lexical_parent
719
+ return False
720
+
721
+
722
+ def _parameters(
723
+ cursor,
724
+ known_records: frozenset[str],
725
+ *,
726
+ scalar_ref_ok: bool = False,
727
+ extern_c: bool = False,
728
+ ) -> list[Parameter]:
729
+ """Map every argument, resolving bare pointers against the whole signature.
730
+
731
+ A `double*` carries no length, so it cannot be bound from its type alone.
732
+ But `void scale(double* data, int n, double k)` does say how long `data`
733
+ is — in another argument. That is only visible with the full parameter
734
+ list in hand, so `_map_type` defers the decision by raising
735
+ PointerLengthUnknown and it is settled here.
736
+
737
+ The pairing uses `ir.size_pairings`, the same inference the Fortran router
738
+ uses to validate `n == len(props)`. Deliberately shared: two heuristics
739
+ that drifted would mean the same signature read differently depending on
740
+ which language it came from. It is conservative by design — an ambiguous
741
+ case yields no pairing and the pointer stays refused, because a false
742
+ pairing silently binds the wrong argument as a length.
743
+ """
744
+ params: list[Parameter] = []
745
+ deferred: dict[str, PointerLengthUnknown] = {}
746
+
747
+ for index, arg in enumerate(cursor.get_arguments()):
748
+ name = arg.spelling or f"arg{index}"
749
+ try:
750
+ py_type, is_array = _map_type(arg.type, known_records, is_return=False)
751
+ except PointerLengthUnknown as exc:
752
+ # Provisional: typed from the pointee so the pairing below can see
753
+ # it as an array candidate. Replaced or re-raised before returning.
754
+ deferred[name] = exc
755
+ params.append(
756
+ Parameter(
757
+ name=name,
758
+ type=exc.element,
759
+ is_array=True,
760
+ native_type=_native_spelling(arg.type),
761
+ is_const=_is_const_type(arg.type),
762
+ )
763
+ )
764
+ continue
765
+ params.append(
766
+ Parameter(
767
+ name=name,
768
+ type=py_type,
769
+ is_array=is_array or _declared_as_array(arg),
770
+ native_type=_native_spelling(arg.type),
771
+ is_const=_is_const_type(arg.type),
772
+ )
773
+ )
774
+
775
+ if not deferred:
776
+ return params
777
+
778
+ by_name = {p.name: p for p in params}
779
+ paired = {array: length for length, array in size_pairings(params)}
780
+
781
+ # THE SCALAR-BY-REFERENCE FALLBACK, and why it is gated twice.
782
+ #
783
+ # In an `extern "C"` bridge to Fortran, EVERY argument is a pointer,
784
+ # because Fortran has no pass-by-value — `void pvtini(double* api, ...)`
785
+ # takes four scalars. Refusing those as "pointer with no length" refused
786
+ # the single most common C-linkage signature there is.
787
+ #
788
+ # But `T*` is also how C passes an ARRAY whose length travels some other
789
+ # way, and binding an array as one scalar hands the callee a pointer to a
790
+ # single stack double it will read past — the silent-wrong-answer class.
791
+ # Hence two gates:
792
+ # 1. extern "C" only. In C++ linkage a scalar in/out is spelled `T&`,
793
+ # which already binds; a bare `T*` there usually IS an array.
794
+ # 2. no size-word integer anywhere in the signature. `krload(double* sw,
795
+ # double* krw, double* kro, double* pcw, int* n)` has four arrays
796
+ # sharing one `n` — the pairing above is ambiguous, and the presence
797
+ # of `n` says "these are arrays", so everything stays refused rather
798
+ # than becoming five scalars.
799
+ #
800
+ # Non-const scalars are marked intent="inout": the binding returns their
801
+ # final value, so an output like PVTERR's `int* icode` cannot be silently
802
+ # dropped into a discarded temporary.
803
+ from ..ir import _SIZE_WORDS, names_a_size_of
804
+
805
+ def _signature_has_size_word() -> bool:
806
+ for candidate in params:
807
+ if candidate.type != "int":
808
+ continue
809
+ lowered = candidate.name.lower()
810
+ if lowered in _SIZE_WORDS:
811
+ return True
812
+ if any(names_a_size_of(candidate.name, other.name) for other in params):
813
+ return True
814
+ return False
815
+
816
+ scalar_ref = scalar_ref_ok and not _signature_has_size_word()
817
+
818
+ for name, exc in deferred.items():
819
+ length = paired.get(name)
820
+ if length is None:
821
+ if scalar_ref:
822
+ target = by_name[name]
823
+ target.is_array = False
824
+ target.length_param = None
825
+ target.is_scalar_ref = True
826
+ target.intent = "inout" if exc.is_mutable else "in"
827
+ continue
828
+ # Nothing in the signature names this pointer's length. Refuse it
829
+ # exactly as before — guessing here is how a service binds the
830
+ # wrong argument as an extent and reads past the end of a buffer.
831
+ if extern_c:
832
+ raise NativeTypeError(
833
+ str(exc)
834
+ + f" If every `T*` argument of '{cursor.spelling}' is a "
835
+ "SCALAR passed by reference (the Fortran-linkage "
836
+ "convention), list it under `clang.scalar_ref_functions` "
837
+ "in nativegate.yaml — but check the Fortran first: an "
838
+ "array whose extent lives in a PARAMETER or COMMON block "
839
+ "looks identical in the C prototype, and binding it as a "
840
+ "scalar corrupts memory."
841
+ ) from exc
842
+ raise exc
843
+ by_name[name].length_param = length
844
+ by_name[name].is_mutable_buffer = exc.is_mutable
845
+
846
+ return params
847
+
848
+
849
+ def _is_const_type(t) -> bool:
850
+ """True for a `const`-qualified declaration (`const double x`).
851
+
852
+ A const data member bound with def_readwrite is a compile error inside
853
+ pybind11's templates; the generator emits def_readonly instead.
854
+ """
855
+ try:
856
+ if t.is_const_qualified():
857
+ return True
858
+ stripped = _strip_refs(t)
859
+ return bool(stripped is not t and stripped.is_const_qualified())
860
+ except Exception:
861
+ return False
862
+
863
+
864
+ def _mark_overloads(entries) -> None:
865
+ """Flag every entry whose name is declared more than once.
866
+
867
+ `&Cls::viscosity` is ambiguous the moment a second `viscosity` exists, so
868
+ the binding has to go through py::overload_cast<...>; on the HTTP side two
869
+ same-named routes would silently shadow each other.
870
+ """
871
+ counts: dict[str, int] = {}
872
+ for entry in entries:
873
+ counts[entry.name] = counts.get(entry.name, 0) + 1
874
+ for entry in entries:
875
+ if counts[entry.name] > 1:
876
+ entry.is_overloaded = True
877
+
878
+
879
+ # --- declaration walking ------------------------------------------------
880
+
881
+
882
+ def _in_file(cursor, path: Path) -> bool:
883
+ """True if the cursor was written in this header rather than an include.
884
+
885
+ Everything reachable through `#include` belongs to the header that
886
+ declared it; binding it here would emit it twice when both headers are in
887
+ the same service.
888
+ """
889
+ location = cursor.location
890
+ if location is None or location.file is None:
891
+ return False
892
+ try:
893
+ return Path(location.file.name).resolve() == path
894
+ except OSError:
895
+ return str(location.file.name) == str(path)
896
+
897
+
898
+ _RECORD_KINDS = lambda: ( # noqa: E731 - needs _cindex loaded first
899
+ _cindex.CursorKind.CLASS_DECL,
900
+ _cindex.CursorKind.STRUCT_DECL,
901
+ )
902
+
903
+
904
+ def _namespace_of(cursor) -> str | None:
905
+ """`a::b` for a declaration nested in namespaces, else None.
906
+
907
+ Anonymous namespaces and `extern "C"` blocks are not name scopes we can
908
+ spell in generated code, so they are skipped in the chain.
909
+ """
910
+ parts = []
911
+ parent = cursor.semantic_parent
912
+ while parent is not None and parent.kind != _cindex.CursorKind.TRANSLATION_UNIT:
913
+ if parent.kind == _cindex.CursorKind.NAMESPACE and parent.spelling:
914
+ parts.append(parent.spelling)
915
+ parent = parent.semantic_parent
916
+ return "::".join(reversed(parts)) or None
917
+
918
+
919
+ _COMMENT_OPENERS = ("/**", "/*!", "/*")
920
+ _COMMENT_LINE_MARKERS = ("///", "//!", "//<", "//")
921
+
922
+
923
+ def _doc_of(cursor) -> str | None:
924
+ """The declaration's own documentation, cleaned into one line of prose.
925
+
926
+ Why `raw_comment` and not `brief_comment`: `brief_comment` is Clang's
927
+ *parsed* comment model, and it returns only the \\brief paragraph. For the
928
+ kind of header this tool is pointed at — legacy engineering headers whose
929
+ Doxygen blocks are one unlabelled paragraph followed by @param lines, or
930
+ just a bare `///` line — that paragraph is very often absent, so
931
+ `brief_comment` silently yields nothing where there is real documentation
932
+ sitting right above the declaration. It also depends on libclang having
933
+ built the comment AST, which is not uniform across builds. `raw_comment` is
934
+ the verbatim source text of the attached comment and is stable on every
935
+ libclang we support; the price is that the comment syntax has to be
936
+ stripped here, which is the rest of this function.
937
+
938
+ Doxygen *structure* is deliberately not parsed: @param/@return stay in the
939
+ prose. The consumer is an MCP tool description handed to a model, and a
940
+ model reads "@param api oil gravity" perfectly well; inventing IR fields to
941
+ hold it would be a schema change no generator asked for (ROADMAP 3.4).
942
+
943
+ The result is a single line by construction — see `_sanitise_doc` for why
944
+ that is a safety property and not a formatting preference.
945
+ """
946
+ raw = getattr(cursor, "raw_comment", None)
947
+ if not raw:
948
+ return None
949
+
950
+ body = raw.strip()
951
+ for opener in _COMMENT_OPENERS:
952
+ if body.startswith(opener):
953
+ body = body[len(opener):]
954
+ break
955
+ if body.endswith("*/"):
956
+ body = body[: -len("*/")]
957
+
958
+ lines = []
959
+ for line in body.splitlines():
960
+ line = line.strip()
961
+ # A block comment's continuation stars, and the marker on every line of
962
+ # a `///` run — libclang hands the whole run back as one raw comment,
963
+ # so the markers after the first are still in the text.
964
+ if line.startswith("*") and not line.startswith("*/"):
965
+ line = line[1:].strip()
966
+ else:
967
+ for marker in _COMMENT_LINE_MARKERS:
968
+ if line.startswith(marker):
969
+ line = line[len(marker):].strip()
970
+ break
971
+ lines.append(line)
972
+
973
+ return _sanitise_doc(" ".join(lines))
974
+
975
+
976
+ def _sanitise_doc(text: str) -> str | None:
977
+ """Collapse to one line of prose, and make it safe to *paste* into source.
978
+
979
+ This text does not stay data. `python_pkg_gen._docstring` interpolates it
980
+ straight into generated Python between triple double-quotes, so three
981
+ characters of a comment nobody wrote for that purpose can close the
982
+ docstring and turn the generated module into a syntax error — or, with a
983
+ little effort, into code.
984
+
985
+ Exactly ONE transformation happens here: all whitespace — newlines and
986
+ control characters included — collapses to single spaces. A legacy header
987
+ wraps its prose to 72 columns and pads with continuation stars, none of
988
+ which is content, so this loses nothing and gives the field a contract that
989
+ is narrow and checkable: `doc` is one line.
990
+
991
+ Everything else is left ALONE, and that is a deliberate reversal of the
992
+ obvious design. It is tempting to also neutralise a `\"\"\"` here (it would
993
+ close the docstring this text is interpolated into) or to drop a trailing
994
+ backslash (it would escape the closing quote). Both work, and both are
995
+ LOSSY: they silently rewrite a routine's documentation to suit one
996
+ consumer's quoting rules. The IR promises the native source's own words,
997
+ and a parser that quietly edits them breaks that promise for every other
998
+ consumer too — JSON, an OpenAPI description, a future doc site.
999
+
1000
+ So escaping lives entirely in the layer that owns the quoting context:
1001
+ `python_pkg_gen._docstring_literal` emits `repr(doc)` for anything carrying
1002
+ a quote, a backslash or a control character, which Python itself guarantees
1003
+ is correctly encoded, and a readable triple-quoted block otherwise. That is
1004
+ lossless, it is tested against hostile text in
1005
+ tests/test_endpoint_docstrings.py, and it means this function does not need
1006
+ to guess what its output will be pasted into.
1007
+ """
1008
+ return " ".join(text.split()) or None
1009
+
1010
+
1011
+ def _is_public(cursor) -> bool:
1012
+ return cursor.access_specifier == _cindex.AccessSpecifier.PUBLIC
1013
+
1014
+
1015
+ def _is_deleted(cursor) -> bool:
1016
+ # is_deleted_method() is missing on some libclang builds; fall back to the
1017
+ # source tokens, where `= delete` is unmistakable.
1018
+ getter = getattr(cursor, "is_deleted_method", None)
1019
+ if callable(getter):
1020
+ try:
1021
+ return bool(getter())
1022
+ except Exception:
1023
+ pass
1024
+ try:
1025
+ tokens = [t.spelling for t in cursor.get_tokens()]
1026
+ except Exception:
1027
+ return False
1028
+ return "delete" in tokens and "=" in tokens
1029
+
1030
+
1031
+ def _bool_method(cursor, name: str, default: bool = False) -> bool:
1032
+ getter = getattr(cursor, name, None)
1033
+ if not callable(getter):
1034
+ return default
1035
+ try:
1036
+ return bool(getter())
1037
+ except Exception:
1038
+ return default
1039
+
1040
+
1041
+ def _error_at(cursor, errors: dict[int, str]) -> str | None:
1042
+ """The compiler error covering this declaration, if any.
1043
+
1044
+ Clang recovers from an unknown type by pretending it was `int` — so a
1045
+ method returning an unincluded `std::vector<double>` parses cleanly as
1046
+ `int b()` and would be bound as returning an integer. Any declaration the
1047
+ compiler complained about is therefore refused rather than trusted; the
1048
+ diagnostic itself is the reason reported to the user.
1049
+ """
1050
+ if not errors:
1051
+ return None
1052
+ extent = cursor.extent
1053
+ if extent is None or extent.start.file is None:
1054
+ return None
1055
+ for line in range(extent.start.line, extent.end.line + 1):
1056
+ if line in errors:
1057
+ return errors[line]
1058
+ return None
1059
+
1060
+
1061
+ def _parse_record(
1062
+ cursor,
1063
+ module: ModuleIR,
1064
+ known_records: frozenset[str],
1065
+ errors: dict[int, str] | None = None,
1066
+ ) -> ClassDef | StructDef:
1067
+ errors = errors or {}
1068
+ name = cursor.spelling
1069
+ namespace = _namespace_of(cursor)
1070
+ is_struct = cursor.kind == _cindex.CursorKind.STRUCT_DECL
1071
+
1072
+ methods: list[Method] = []
1073
+ fields: list[Parameter] = []
1074
+ constructors: list[list[Parameter]] = []
1075
+ bases: list[str] = []
1076
+ saw_constructor = False
1077
+ saw_public_default_ctor = False
1078
+ blocks_default_ctor = False
1079
+
1080
+ for child in cursor.get_children():
1081
+ kind = child.kind
1082
+
1083
+ if kind == _cindex.CursorKind.CXX_BASE_SPECIFIER:
1084
+ base_name = _record_short_name(child.type)
1085
+ if not base_name:
1086
+ continue
1087
+ if not _is_public(child):
1088
+ # Private/protected inheritance is an implementation detail:
1089
+ # the conversion Derived* -> Base* is inaccessible, so telling
1090
+ # pybind11 about it would not compile.
1091
+ continue
1092
+ if base_name not in known_records:
1093
+ module.skipped.append(
1094
+ SkippedSymbol(
1095
+ f"{name} : {base_name}",
1096
+ f"inherits from '{base_name}', which this service does not "
1097
+ "bind. Python callers will not see anything inherited from "
1098
+ f"it — expose '{base_name}' too, or ignore this if its "
1099
+ "members are not needed.",
1100
+ )
1101
+ )
1102
+ continue
1103
+ bases.append(base_name)
1104
+ continue
1105
+
1106
+ broken = _error_at(child, errors)
1107
+
1108
+ if kind == _cindex.CursorKind.CONSTRUCTOR:
1109
+ saw_constructor = True
1110
+ if not _is_public(child) or _is_deleted(child):
1111
+ continue
1112
+ if broken:
1113
+ module.skipped.append(
1114
+ SkippedSymbol(f"{name}::{name}", _unparsed_reason(broken))
1115
+ )
1116
+ continue
1117
+ # A copy/move constructor is not a Python-facing way to build the
1118
+ # object; pybind11 provides copying separately.
1119
+ if _bool_method(child, "is_copy_constructor") or _bool_method(
1120
+ child, "is_move_constructor"
1121
+ ):
1122
+ continue
1123
+ if _bool_method(child, "is_default_constructor") or not list(
1124
+ child.get_arguments()
1125
+ ):
1126
+ saw_public_default_ctor = True
1127
+ continue
1128
+ try:
1129
+ constructors.append(_parameters(child, known_records))
1130
+ except NativeTypeError as exc:
1131
+ module.skipped.append(SkippedSymbol(f"{name}::{name}", str(exc)))
1132
+ continue
1133
+
1134
+ if kind == _cindex.CursorKind.CXX_METHOD:
1135
+ if not _is_public(child) or _is_deleted(child):
1136
+ continue
1137
+ # Operators used to be dropped here, silently and without a
1138
+ # `skipped` entry — the one thing this parser is not allowed to do.
1139
+ # pybind11 binds them perfectly well as Python special methods, so
1140
+ # the ones with an honest mapping are bound and the rest are
1141
+ # reported with the reason they cannot be.
1142
+ operator_dunder = None
1143
+ if child.spelling.startswith("operator"):
1144
+ arg_count = len(list(child.get_arguments()))
1145
+ operator_dunder = _operator_dunder(child.spelling, arg_count)
1146
+ if operator_dunder is None:
1147
+ module.skipped.append(
1148
+ SkippedSymbol(
1149
+ f"{name}::{child.spelling}",
1150
+ _operator_refusal(child.spelling, arg_count),
1151
+ )
1152
+ )
1153
+ continue
1154
+ if broken:
1155
+ module.skipped.append(
1156
+ SkippedSymbol(
1157
+ f"{name}::{child.spelling}", _unparsed_reason(broken)
1158
+ )
1159
+ )
1160
+ continue
1161
+ try:
1162
+ returns, returns_array = _map_type(
1163
+ child.result_type, known_records, is_return=True
1164
+ )
1165
+ parameters = _parameters(child, known_records)
1166
+ except NativeTypeError as exc:
1167
+ module.skipped.append(
1168
+ SkippedSymbol(f"{name}::{child.spelling}", str(exc))
1169
+ )
1170
+ continue
1171
+ methods.append(
1172
+ Method(
1173
+ name=operator_dunder or child.spelling,
1174
+ parameters=parameters,
1175
+ returns=returns,
1176
+ is_static=child.is_static_method(),
1177
+ is_const=_bool_method(child, "is_const_method"),
1178
+ returns_array=returns_array,
1179
+ # Only set for operators: the published name is the dunder,
1180
+ # but the address to take is still `Cls::operator+`.
1181
+ cpp_name=child.spelling if operator_dunder else None,
1182
+ )
1183
+ )
1184
+ continue
1185
+
1186
+ if kind == _cindex.CursorKind.FIELD_DECL:
1187
+ field_is_const = _is_const_type(child.type)
1188
+ if field_is_const or child.type.kind in _kinds(
1189
+ "LVALUEREFERENCE", "RVALUEREFERENCE"
1190
+ ):
1191
+ # A const or reference member — public or not — suppresses the
1192
+ # implicit default constructor, so py::init<>() for this record
1193
+ # would not compile.
1194
+ blocks_default_ctor = True
1195
+ if not _is_public(child):
1196
+ continue
1197
+ if broken:
1198
+ module.skipped.append(
1199
+ SkippedSymbol(f"{name}.{child.spelling}", _unparsed_reason(broken))
1200
+ )
1201
+ continue
1202
+ try:
1203
+ py_type, is_array = _map_type(
1204
+ child.type, known_records, is_return=False
1205
+ )
1206
+ except NativeTypeError as exc:
1207
+ module.skipped.append(
1208
+ SkippedSymbol(f"{name}.{child.spelling}", str(exc))
1209
+ )
1210
+ continue
1211
+ fields.append(
1212
+ Parameter(
1213
+ name=child.spelling,
1214
+ type=py_type,
1215
+ is_array=is_array,
1216
+ native_type=_native_spelling(child.type),
1217
+ is_const=field_is_const,
1218
+ )
1219
+ )
1220
+ continue
1221
+
1222
+ if kind in (
1223
+ _cindex.CursorKind.FUNCTION_TEMPLATE,
1224
+ _cindex.CursorKind.CLASS_TEMPLATE,
1225
+ ):
1226
+ module.skipped.append(
1227
+ SkippedSymbol(
1228
+ f"{name}::{child.spelling}",
1229
+ "is a template; pybind11 binds instantiations, not templates. "
1230
+ "Add a non-template wrapper for the instantiation you need, "
1231
+ "or exclude the symbol in nativegate.yaml.",
1232
+ )
1233
+ )
1234
+ continue
1235
+
1236
+ # An abstract class cannot be instantiated from Python at all — emitting
1237
+ # py::init<> for one is a compile error inside pybind11's templates.
1238
+ abstract = _bool_method(cursor, "is_abstract_record")
1239
+ if abstract:
1240
+ constructors = []
1241
+
1242
+ _mark_overloads(methods)
1243
+
1244
+ # libclang exposes no reliable "has a default constructor" query on the
1245
+ # record cursor across builds, so it is derived: a user-declared non-default
1246
+ # constructor, or a const/reference member, removes the implicit one.
1247
+ has_default = (
1248
+ not abstract
1249
+ and (saw_public_default_ctor or not saw_constructor)
1250
+ and (saw_public_default_ctor or not blocks_default_ctor)
1251
+ )
1252
+
1253
+ if is_struct and not methods and not bases and fields:
1254
+ return StructDef(
1255
+ name=name,
1256
+ namespace=namespace,
1257
+ fields=fields,
1258
+ has_default_constructor=has_default,
1259
+ )
1260
+
1261
+ return ClassDef(
1262
+ name=name,
1263
+ namespace=namespace,
1264
+ methods=methods,
1265
+ has_default_constructor=has_default,
1266
+ constructors=constructors,
1267
+ fields=fields,
1268
+ bases=bases,
1269
+ )
1270
+
1271
+
1272
+ def _unparsed_reason(diagnostic: str) -> str:
1273
+ return (
1274
+ f"the compiler rejected this declaration ({diagnostic}); it cannot be bound "
1275
+ "until it compiles. Usual causes: a header the parser could not find (add "
1276
+ "its directory to `clang.include_paths` in nativegate.yaml), or a name that "
1277
+ "comes from a macro whose definition is not reachable from this header "
1278
+ "(add the header that defines the macro, or -D it via `clang.defines`)."
1279
+ )
1280
+
1281
+
1282
+ def _parse_function(
1283
+ cursor,
1284
+ module: ModuleIR,
1285
+ known_records: frozenset[str],
1286
+ errors: dict[int, str] | None = None,
1287
+ scalar_ref_functions: frozenset[str] = frozenset(),
1288
+ ) -> None:
1289
+ broken = _error_at(cursor, errors or {})
1290
+ if broken:
1291
+ module.skipped.append(
1292
+ SkippedSymbol(cursor.spelling, _unparsed_reason(broken))
1293
+ )
1294
+ return
1295
+ try:
1296
+ returns, returns_array = _map_type(
1297
+ cursor.result_type, known_records, is_return=True
1298
+ )
1299
+ parameters = _parameters(
1300
+ cursor,
1301
+ known_records,
1302
+ # Both conditions, and the second is the load-bearing one: the
1303
+ # extern "C" check narrows WHERE the convention can apply, and the
1304
+ # per-function opt-in is the user asserting it DOES apply — see
1305
+ # config.ClangConfig.scalar_ref_functions and the FLASH2 case.
1306
+ scalar_ref_ok=_is_extern_c(cursor)
1307
+ and (
1308
+ cursor.spelling in scalar_ref_functions
1309
+ or "*" in scalar_ref_functions
1310
+ ),
1311
+ extern_c=_is_extern_c(cursor),
1312
+ )
1313
+ except NativeTypeError as exc:
1314
+ module.skipped.append(SkippedSymbol(cursor.spelling, str(exc)))
1315
+ return
1316
+ module.functions.append(
1317
+ FunctionDef(
1318
+ name=cursor.spelling,
1319
+ parameters=parameters,
1320
+ returns=returns,
1321
+ # Without this a function in `namespace petro` is emitted as
1322
+ # `&bubble_point` — "use of undeclared identifier".
1323
+ namespace=_namespace_of(cursor),
1324
+ returns_array=returns_array,
1325
+ # The routine's own documentation, when it has any, becomes the
1326
+ # generated endpoint's docstring and from there the MCP tool
1327
+ # description a model reads before deciding how to call it
1328
+ # (ROADMAP 3.4). None when the declaration carries no comment: a
1329
+ # skeleton sentence is the generator's business, and synthesising
1330
+ # prose here would put text in the IR that appears nowhere in the
1331
+ # native source.
1332
+ doc=_doc_of(cursor),
1333
+ )
1334
+ )
1335
+
1336
+
1337
+ def _walk(cursor, path: Path, handler, *, depth: int = 0) -> None:
1338
+ """Visit declarations in this file, descending through namespaces and
1339
+ `extern "C"` blocks — those group declarations without changing what is
1340
+ declared."""
1341
+ for child in cursor.get_children():
1342
+ if child.kind in (
1343
+ _cindex.CursorKind.NAMESPACE,
1344
+ _cindex.CursorKind.LINKAGE_SPEC,
1345
+ _cindex.CursorKind.UNEXPOSED_DECL,
1346
+ ):
1347
+ _walk(child, path, handler, depth=depth + 1)
1348
+ continue
1349
+ if not _in_file(child, path):
1350
+ continue
1351
+ handler(child)
1352
+
1353
+
1354
+ # --- translation units --------------------------------------------------
1355
+
1356
+
1357
+ _TU_CACHE: dict[tuple, object] = {}
1358
+
1359
+
1360
+ def _translation_unit(path: Path, options: ClangOptions):
1361
+ if not _load():
1362
+ raise NativeTypeError(
1363
+ f"the Clang parser is unavailable: {unavailable_reason()}"
1364
+ )
1365
+
1366
+ resolved = path.resolve()
1367
+ try:
1368
+ stamp = resolved.stat().st_mtime_ns
1369
+ except OSError:
1370
+ stamp = 0
1371
+ key = (str(resolved), stamp, options)
1372
+ cached = _TU_CACHE.get(key)
1373
+ if cached is not None:
1374
+ return cached
1375
+
1376
+ index = _cindex.Index.create()
1377
+ flags = (
1378
+ _cindex.TranslationUnit.PARSE_SKIP_FUNCTION_BODIES
1379
+ | _cindex.TranslationUnit.PARSE_INCOMPLETE
1380
+ )
1381
+ tu = index.parse(str(resolved), args=options.command_line(resolved), options=flags)
1382
+ _TU_CACHE[key] = tu
1383
+ return tu
1384
+
1385
+
1386
+ def _diagnostics(tu) -> list[str]:
1387
+ """Errors worth telling the user about.
1388
+
1389
+ A header that doesn't compile still yields a partial AST, and nativegate
1390
+ binds whatever parsed — but silently binding half a header because an
1391
+ `#include` was unreachable is exactly the failure the regex parser was
1392
+ criticised for. Surface it.
1393
+ """
1394
+ messages = []
1395
+ for diagnostic in tu.diagnostics:
1396
+ if diagnostic.severity < _cindex.Diagnostic.Error:
1397
+ continue
1398
+ location = diagnostic.location
1399
+ where = (
1400
+ f"{Path(location.file.name).name}:{location.line}"
1401
+ if location and location.file
1402
+ else "?"
1403
+ )
1404
+ messages.append(f"{where}: {diagnostic.spelling}")
1405
+ return messages
1406
+
1407
+
1408
+ def _fatal_diagnostics(tu) -> list[str]:
1409
+ """Errors that make the rest of the parse untrustworthy (a missing include)."""
1410
+ return [
1411
+ d.spelling
1412
+ for d in tu.diagnostics
1413
+ if d.severity >= _cindex.Diagnostic.Fatal
1414
+ ]
1415
+
1416
+
1417
+ def _error_lines(tu, path: Path) -> dict[int, str]:
1418
+ """Line -> message for compiler errors raised *in this header*."""
1419
+ resolved = path.resolve()
1420
+ lines: dict[int, str] = {}
1421
+ for diagnostic in tu.diagnostics:
1422
+ if diagnostic.severity < _cindex.Diagnostic.Error:
1423
+ continue
1424
+ location = diagnostic.location
1425
+ if location is None or location.file is None:
1426
+ continue
1427
+ try:
1428
+ same_file = Path(location.file.name).resolve() == resolved
1429
+ except OSError:
1430
+ same_file = str(location.file.name) == str(resolved)
1431
+ if same_file:
1432
+ lines.setdefault(location.line, diagnostic.spelling)
1433
+ return lines
1434
+
1435
+
1436
+ def _user_roots(path: Path, options: ClangOptions) -> tuple[Path, ...]:
1437
+ """Directories whose headers belong to this project rather than the system.
1438
+
1439
+ Records defined under these count as bindable types; records defined under
1440
+ the SDK or the standard library do not — `std::vector` is "defined" in the
1441
+ translation unit too, and treating it as a known record would emit a
1442
+ py::class_ for it.
1443
+ """
1444
+ roots = [path.parent]
1445
+ for include in options.include_paths:
1446
+ try:
1447
+ roots.append(Path(include).resolve())
1448
+ except OSError:
1449
+ continue
1450
+ return tuple(roots)
1451
+
1452
+
1453
+ def _under(candidate: Path, roots: tuple[Path, ...]) -> bool:
1454
+ for root in roots:
1455
+ try:
1456
+ candidate.relative_to(root)
1457
+ return True
1458
+ except ValueError:
1459
+ continue
1460
+ return False
1461
+
1462
+
1463
+ def _defined_records(tu, path: Path, options: ClangOptions) -> set[str]:
1464
+ """Names of records defined in this header or in a project header it includes."""
1465
+ roots = _user_roots(path, options)
1466
+ names: set[str] = set()
1467
+
1468
+ def visit(cursor):
1469
+ for child in cursor.get_children():
1470
+ if (
1471
+ child.kind in _RECORD_KINDS()
1472
+ and child.is_definition()
1473
+ and child.spelling
1474
+ ):
1475
+ location = child.location
1476
+ if location is not None and location.file is not None:
1477
+ try:
1478
+ origin = Path(location.file.name).resolve()
1479
+ except OSError:
1480
+ origin = Path(location.file.name)
1481
+ if origin == path or _under(origin, roots):
1482
+ names.add(child.spelling)
1483
+ visit(child)
1484
+
1485
+ visit(tu.cursor)
1486
+ return names
1487
+
1488
+
1489
+ def defined_record_names(
1490
+ path: Path, options: ClangOptions | None = None
1491
+ ) -> frozenset[str]:
1492
+ """Records *defined* (not merely forward-declared) in this header.
1493
+
1494
+ Unlike the regex reader's textual scan, a class only counts here if the
1495
+ AST says its definition is in this file — a forward declaration or a
1496
+ mention inside a comment or a disabled `#ifdef` branch does not.
1497
+ """
1498
+ options = options or ClangOptions()
1499
+ if not is_available():
1500
+ return frozenset()
1501
+ tu = _translation_unit(path, options)
1502
+ resolved = path.resolve()
1503
+
1504
+ names: set[str] = set()
1505
+
1506
+ def visit(cursor):
1507
+ if cursor.kind in _RECORD_KINDS() and cursor.is_definition() and cursor.spelling:
1508
+ names.add(cursor.spelling)
1509
+
1510
+ _walk(tu.cursor, resolved, visit)
1511
+ return frozenset(names)
1512
+
1513
+
1514
+ def parse_header(
1515
+ path: Path,
1516
+ expose: ExposeConfig,
1517
+ extra_known_records: frozenset[str] = frozenset(),
1518
+ options: ClangOptions | None = None,
1519
+ ) -> ModuleIR:
1520
+ """Parse a C++ header into a language-neutral ModuleIR via the Clang AST.
1521
+
1522
+ Signature-compatible with `cpp_regex.parse_header`, plus `options` for the
1523
+ compiler flags the AST parse needs (standard, include paths, defines).
1524
+
1525
+ A symbol is included if it carries `[[nativegate::expose]]` OR is named in
1526
+ nativegate.yaml's `expose:` block. `extra_known_records` names types defined
1527
+ in sibling headers of the same service — all of a service's headers compile
1528
+ into one extension, so such a type is complete by the time pybind11 sees it
1529
+ even though it is incomplete here.
1530
+ """
1531
+ options = options or ClangOptions()
1532
+ resolved = path.resolve()
1533
+ tu = _translation_unit(path, options)
1534
+
1535
+ module = ModuleIR(name=path.stem, language="cpp", source_file=str(path))
1536
+ module.diagnostics = _diagnostics(tu)
1537
+
1538
+ fatal = _fatal_diagnostics(tu)
1539
+ if fatal:
1540
+ # A missing #include stops clang from reporting anything further, and
1541
+ # every unresolved name silently becomes `int` under error recovery.
1542
+ # The AST past that point is fiction — binding it would generate a
1543
+ # service whose signatures disagree with the C++ it calls — so the
1544
+ # whole header is refused, loudly.
1545
+ module.skipped.append(
1546
+ SkippedSymbol(path.name, _unparsed_reason("; ".join(fatal)))
1547
+ )
1548
+ return module
1549
+
1550
+ # Pass 1: every record the service could bind — those defined in this file
1551
+ # (so a method returning a type declared later still resolves) plus those
1552
+ # defined in headers this one #includes from the service's own directories.
1553
+ # An included type is complete and belongs to whichever header declared it;
1554
+ # refusing it here would drop every method that uses one.
1555
+ defined_here = _defined_records(tu, resolved, options)
1556
+ errors = _error_lines(tu, path)
1557
+ known_records = frozenset(defined_here) | extra_known_records
1558
+
1559
+ # Pass 2: build the IR.
1560
+ scalar_refs = frozenset(getattr(options, "scalar_ref_functions", ()) or ())
1561
+
1562
+ def build(cursor):
1563
+ kind = cursor.kind
1564
+
1565
+ if kind in _RECORD_KINDS():
1566
+ if not cursor.is_definition() or not cursor.spelling:
1567
+ return # forward declaration, or an anonymous record
1568
+ if not expose.is_exposed(cursor.spelling):
1569
+ return
1570
+ parsed = _parse_record(cursor, module, known_records, errors)
1571
+ if isinstance(parsed, StructDef):
1572
+ module.structs.append(parsed)
1573
+ else:
1574
+ module.classes.append(parsed)
1575
+ return
1576
+
1577
+ if kind == _cindex.CursorKind.CLASS_TEMPLATE:
1578
+ if expose.is_exposed(cursor.spelling):
1579
+ module.skipped.append(
1580
+ SkippedSymbol(
1581
+ cursor.spelling,
1582
+ "is a class template; pybind11 binds instantiations, "
1583
+ "not templates. A typedef does NOT help — measured: "
1584
+ "libclang reports Buffer<double> with no members, both "
1585
+ "with and without an explicit instantiation, so there "
1586
+ "is nothing to bind. Write a non-template class or free "
1587
+ "functions over the instantiation you need and expose "
1588
+ "those, or exclude it in nativegate.yaml.",
1589
+ )
1590
+ )
1591
+ return
1592
+
1593
+ if kind == _cindex.CursorKind.FUNCTION_TEMPLATE:
1594
+ if expose.is_exposed(cursor.spelling):
1595
+ module.skipped.append(
1596
+ SkippedSymbol(
1597
+ cursor.spelling,
1598
+ "is a function template; pybind11 binds instantiations, not "
1599
+ "templates. Add a non-template overload for the types you "
1600
+ "need, or exclude it in nativegate.yaml.",
1601
+ )
1602
+ )
1603
+ return
1604
+
1605
+ if kind == _cindex.CursorKind.FUNCTION_DECL:
1606
+ if not expose.is_exposed(cursor.spelling):
1607
+ return
1608
+ _parse_function(
1609
+ cursor,
1610
+ module,
1611
+ known_records,
1612
+ errors,
1613
+ scalar_ref_functions=scalar_refs,
1614
+ )
1615
+ return
1616
+
1617
+ if kind == _cindex.CursorKind.TYPEDEF_DECL:
1618
+ # `typedef struct { double x; } Point;` — the record itself is
1619
+ # anonymous, so bind it under the typedef name.
1620
+ underlying = cursor.underlying_typedef_type
1621
+ decl = underlying.get_declaration()
1622
+ if (
1623
+ decl is not None
1624
+ and decl.kind in _RECORD_KINDS()
1625
+ and decl.is_definition()
1626
+ and not decl.spelling
1627
+ and expose.is_exposed(cursor.spelling)
1628
+ ):
1629
+ parsed = _parse_record(decl, module, known_records, errors)
1630
+ parsed.name = cursor.spelling
1631
+ if isinstance(parsed, StructDef):
1632
+ module.structs.append(parsed)
1633
+ else:
1634
+ module.classes.append(parsed)
1635
+
1636
+ _walk(tu.cursor, resolved, build)
1637
+
1638
+ # Free functions overload just as members do: the same &name ambiguity
1639
+ # applies, and two same-named HTTP routes shadow each other.
1640
+ _mark_overloads(module.functions)
1641
+
1642
+ # One macro spelled 28 times is one problem, not 28.
1643
+ seen: set[tuple[str, str]] = set()
1644
+ deduped = []
1645
+ for entry in module.skipped:
1646
+ key = (entry.name, entry.reason)
1647
+ if key not in seen:
1648
+ seen.add(key)
1649
+ deduped.append(entry)
1650
+ module.skipped = deduped
1651
+
1652
+ return module