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,812 @@
1
+ """C++ header parser — regex/brace fallback reader.
2
+
3
+ The Clang AST parser in `cpp_ast.py` is the primary front end (design.md
4
+ section 7) and is used whenever libclang is importable. This module is the
5
+ fallback for machines without it: it works on the token and brace structure
6
+ of the header rather than on a full AST, which covers the declaration subset
7
+ nativegate binds (classes, structs, free functions, `extern "C"` blocks) as
8
+ long as the header is self-contained.
9
+
10
+ What it structurally cannot do, and `cpp_ast.py` does:
11
+
12
+ * run the preprocessor — `#include`d types, `#define`d names and `#ifdef`
13
+ branches are invisible here, and macro-mangled declarations are reported as
14
+ unreadable rather than expanded;
15
+ * templates, `= delete`/`= default`, abstract classes, copy/move constructor
16
+ detection, typedef and `using`-alias resolution, nested namespaces.
17
+
18
+ `ngate inspect --parser regex` selects it explicitly; otherwise see
19
+ `cpp.py` for how a backend is chosen.
20
+
21
+ What it is careful about, because the previous line-regex version was not:
22
+
23
+ * **Comments.** Prose and commented-out code were parsed as declarations.
24
+ * **Access specifiers.** `public:` was matched as a *return type*, so any
25
+ class whose access label sat on its own line failed the whole header.
26
+ Worse, when it didn't fail, private methods were bound as public.
27
+ * **Constructors.** They have no return type and never matched, so
28
+ `has_default_constructor` was a guess and constructors taking arguments
29
+ were unreachable from Python.
30
+ * **cv-qualifiers and pointers.** `const char*`, `const double&` and
31
+ `double*` all raised and took the rest of the header down with them.
32
+
33
+ Unbindable symbols are now *skipped and reported* (see ModuleIR.skipped)
34
+ rather than aborting the file. One method taking a `std::vector` should not
35
+ cost you the other thirty in the same class.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import re
41
+ from dataclasses import dataclass
42
+ from pathlib import Path
43
+
44
+ from ..config import ExposeConfig
45
+ from ..ir import (
46
+ ClassDef,
47
+ FunctionDef,
48
+ Method,
49
+ ModuleIR,
50
+ NativeTypeError,
51
+ Parameter,
52
+ SkippedSymbol,
53
+ StructDef,
54
+ map_type,
55
+ size_pairings,
56
+ )
57
+
58
+ _EXPOSE_ATTR = "[[nativegate::expose]]"
59
+
60
+ _NAMESPACE_RE = re.compile(r"\bnamespace\s+(\w+)\s*\{")
61
+
62
+ # `class Foo final : public Bar {` / `struct Foo {`
63
+ _RECORD_RE = re.compile(
64
+ r"\b(?P<kind>class|struct)\s+(?P<name>\w+)\s*"
65
+ r"(?:final\s*)?(?::(?P<bases>[^{;]*))?\{",
66
+ )
67
+
68
+ # One entry of a base-clause: `public Bar`, `virtual public ns::Bar`.
69
+ _BASE_RE = re.compile(
70
+ r"^(?:virtual\s+)?(?:(?P<access>public|private|protected)\s+)?"
71
+ r"(?:virtual\s+)?(?P<name>[\w:]+)$"
72
+ )
73
+
74
+ _ACCESS_RE = re.compile(r"^\s*(public|private|protected)\s*:", re.IGNORECASE)
75
+
76
+ # A declarator: optional specifier tokens, a name, a parenthesised argument
77
+ # list, optional trailing qualifiers.
78
+ _DECLARATOR_RE = re.compile(
79
+ r"^(?P<spec>[\w\s:<>,&*~\[\]]*?)"
80
+ r"(?P<name>~?\w+)\s*"
81
+ r"\((?P<params>[^()]*)\)"
82
+ r"(?P<trailing>[\w\s:=&*]*)$",
83
+ re.DOTALL,
84
+ )
85
+
86
+ # Specifier keywords that carry no type information.
87
+ _DROPPED_SPECIFIERS = frozenset(
88
+ {"static", "virtual", "inline", "explicit", "friend", "constexpr", "extern"}
89
+ )
90
+
91
+
92
+ # --- lexical cleanup ----------------------------------------------------
93
+
94
+
95
+ def _strip_comments(source: str) -> str:
96
+ """Remove // and /* */ comments, preserving newlines so brace matching and
97
+ any future line reporting stay aligned with the original file."""
98
+ out = []
99
+ i, n = 0, len(source)
100
+ while i < n:
101
+ if source.startswith("//", i):
102
+ while i < n and source[i] != "\n":
103
+ i += 1
104
+ elif source.startswith("/*", i):
105
+ end = source.find("*/", i + 2)
106
+ chunk = source[i : end + 2 if end != -1 else n]
107
+ out.append("\n" * chunk.count("\n"))
108
+ i = end + 2 if end != -1 else n
109
+ elif source[i] in "\"'":
110
+ quote = source[i]
111
+ out.append(source[i])
112
+ i += 1
113
+ while i < n and source[i] != quote:
114
+ if source[i] == "\\":
115
+ out.append(source[i])
116
+ i += 1
117
+ if i < n:
118
+ out.append(source[i])
119
+ i += 1
120
+ if i < n:
121
+ out.append(source[i])
122
+ i += 1
123
+ else:
124
+ out.append(source[i])
125
+ i += 1
126
+ return "".join(out)
127
+
128
+
129
+ def _strip_preprocessor(source: str) -> str:
130
+ """Drop #include/#define/#if lines.
131
+
132
+ nativegate does not run a preprocessor, so a declaration whose name comes
133
+ from a macro (the F77_NAME mangling pattern used in Fortran bridge
134
+ headers) cannot be resolved here. Those declarations survive this step
135
+ and are reported as skipped by the declarator parser rather than
136
+ silently dropped.
137
+ """
138
+ lines = []
139
+ continued = False
140
+ for line in source.splitlines():
141
+ stripped = line.lstrip()
142
+ if continued or stripped.startswith("#"):
143
+ continued = line.rstrip().endswith("\\")
144
+ lines.append("")
145
+ continue
146
+ lines.append(line)
147
+ return "\n".join(lines)
148
+
149
+
150
+ def _matching_brace(source: str, open_index: int) -> int:
151
+ """Index of the '}' matching the '{' at open_index, or -1."""
152
+ depth = 0
153
+ for i in range(open_index, len(source)):
154
+ if source[i] == "{":
155
+ depth += 1
156
+ elif source[i] == "}":
157
+ depth -= 1
158
+ if depth == 0:
159
+ return i
160
+ return -1
161
+
162
+
163
+ def _split_top_level(text: str, separator: str = ",") -> list[str]:
164
+ """Split on `separator` at paren/angle/bracket depth zero."""
165
+ parts, depth, current = [], 0, ""
166
+ for ch in text:
167
+ if ch in "(<[":
168
+ depth += 1
169
+ elif ch in ")>]":
170
+ depth -= 1
171
+ if ch == separator and depth == 0:
172
+ parts.append(current.strip())
173
+ current = ""
174
+ else:
175
+ current += ch
176
+ if current.strip():
177
+ parts.append(current.strip())
178
+ return parts
179
+
180
+
181
+ def _strip_inline_bodies(text: str) -> str:
182
+ """Replace `{ ... }` blocks with ';' so an inline method body can't be
183
+ mistaken for a declaration, and its internal ';' don't split statements.
184
+
185
+ The replacement has to be a ';' rather than nothing: `double a() { ... }`
186
+ carries no trailing semicolon of its own, so deleting the body outright
187
+ would run `double a()` into whatever declaration follows it and lose both.
188
+ """
189
+ out, depth = [], 0
190
+ for ch in text:
191
+ if ch == "{":
192
+ depth += 1
193
+ elif ch == "}":
194
+ depth = max(0, depth - 1)
195
+ if depth == 0:
196
+ out.append(";")
197
+ elif depth == 0:
198
+ out.append(ch)
199
+ return "".join(out)
200
+
201
+
202
+ # --- type mapping -------------------------------------------------------
203
+
204
+
205
+ class PointerLengthUnknown(NativeTypeError):
206
+ """A pointer to a scalar whose length is not knowable from the type alone.
207
+
208
+ Mirrors cpp_ast.PointerLengthUnknown so both C++ readers defer the same
209
+ decision to the same place. Still a NativeTypeError, so any caller that
210
+ does not know about the deferral keeps refusing it.
211
+ """
212
+
213
+ def __init__(self, message: str, *, element: str, is_mutable: bool) -> None:
214
+ super().__init__(message)
215
+ self.element = element
216
+ self.is_mutable = is_mutable
217
+
218
+
219
+ # Pointee spellings that map to a scalar nativegate can bind as a buffer.
220
+ _SCALAR_POINTER_ELEMENTS = {
221
+ "double": "float", "float": "float", "long double": "float",
222
+ "int": "int", "short": "int", "long": "int", "long long": "int",
223
+ "unsigned": "int", "unsigned int": "int", "unsigned short": "int",
224
+ "unsigned long": "int", "size_t": "int", "std::size_t": "int",
225
+ "int8_t": "int", "int16_t": "int", "int32_t": "int", "int64_t": "int",
226
+ "uint8_t": "int", "uint16_t": "int", "uint32_t": "int", "uint64_t": "int",
227
+ "bool": "bool",
228
+ }
229
+
230
+
231
+ # `operator+`, `operator[]`, `operator()`, `operator<=`, `operator bool` ...
232
+ _OPERATOR_SPELLING_RE = re.compile(r"\boperator\s*(?:\[\]|\(\)|[^\s(\w]+|\w+)")
233
+
234
+
235
+ # `std::vector<T>` in the spellings a header actually uses, tolerating the
236
+ # spaces a formatter may leave inside the angle brackets.
237
+ _VECTOR_RE = re.compile(r"\bstd::vector\s*<\s*(.+)\s*>\s*$")
238
+
239
+
240
+ def _normalize_type(
241
+ raw: str, known_records: frozenset[str] = frozenset(), *, is_return: bool = False
242
+ ) -> tuple[str, bool]:
243
+ """Map a C++ type to (python_type, is_array).
244
+
245
+ Strips cv-qualifiers and reference markers. `char*`/`const char*` map to
246
+ str. Class and struct types declared in the same header map to themselves,
247
+ so a method returning a `PvtState` is bindable instead of being dropped.
248
+
249
+ A pointer to a *known record* is fine as a parameter — pybind11 passes the
250
+ held pointer and no ownership changes hands. A pointer *return* is not:
251
+ the default return-value policy would take ownership of a pointer the C++
252
+ side still owns, and the second free is a crash in someone else's stack
253
+ frame. A pointer to anything else has no length information and is
254
+ likewise refused.
255
+ """
256
+ # Checked on the RAW spelling, before cv-qualifiers and `&` are tokenised
257
+ # away, because for std::vector the reference and its constness are the
258
+ # whole question. pybind11 converts a Python list into a TEMPORARY vector,
259
+ # so a non-const `std::vector<T>&` — which in C++ means "I will write to
260
+ # your vector" — has its writes discarded when the temporary dies. Silently:
261
+ # it compiles, runs, and hands back unmodified data. Same refusal, and the
262
+ # same wording, as cpp_ast._refuse_mutable_vector_ref.
263
+ raw_text = raw.strip()
264
+ if "vector" in raw_text and "&" in raw_text and not is_return:
265
+ before_ref = raw_text.split("&")[0]
266
+ if _VECTOR_RE.search(before_ref) and "const" not in before_ref.split():
267
+ raise NativeTypeError(
268
+ f"'{raw_text}' is a mutable reference to a std::vector. pybind11 "
269
+ "converts the caller's list into a temporary vector, so anything "
270
+ "the function writes through this parameter is discarded when the "
271
+ "call returns — silently, with no error. Take "
272
+ "'const std::vector<T>&' if the data is input, or RETURN the "
273
+ "modified vector, or exclude the symbol in nativegate.yaml."
274
+ )
275
+
276
+ text = " ".join(raw.replace("*", " * ").replace("&", " & ").split())
277
+ tokens = [t for t in text.split(" ") if t not in ("const", "volatile", "&")]
278
+
279
+ pointer_depth = tokens.count("*")
280
+ tokens = [t for t in tokens if t != "*"]
281
+ base = " ".join(tokens).strip()
282
+
283
+ if not base:
284
+ raise NativeTypeError("empty type")
285
+
286
+ if pointer_depth == 1 and base in ("char", "signed char", "unsigned char"):
287
+ # Only `const char*` is a C string. A non-const `char*` is an output
288
+ # buffer with no length convention: bound as a Python str, pybind11
289
+ # materialises a temporary that C++ then writes through, and the write
290
+ # is lost or corrupts freed memory. (Same refusal as cpp_ast.)
291
+ if "const" in text.split("*")[0].split():
292
+ return "str", False
293
+ raise NativeTypeError(
294
+ f"'{raw.strip()}' is a non-const 'char*': an output buffer with no "
295
+ "length convention. Binding it as a Python str would have C++ write "
296
+ "through a temporary. Return std::string (or take 'const char*' for "
297
+ "input), or exclude the symbol in nativegate.yaml."
298
+ )
299
+
300
+ vector_match = _VECTOR_RE.match(base)
301
+ if vector_match:
302
+ # pybind11's <pybind11/stl.h> converts std::vector<T> <-> list, by copy.
303
+ inner, inner_is_array = _normalize_type(
304
+ vector_match.group(1), known_records, is_return=is_return
305
+ )
306
+ if inner_is_array:
307
+ raise NativeTypeError(
308
+ f"'{raw.strip()}' is a nested container. pybind11 will convert "
309
+ "it, but nativegate has no IR shape for a list of lists, so the "
310
+ "generated Pydantic model would be wrong. Flatten it (pass the "
311
+ "data and its shape separately), or exclude the symbol."
312
+ )
313
+ if inner not in ("float", "int", "bool", "str"):
314
+ raise NativeTypeError(
315
+ f"'{raw.strip()}' has element type '{inner}', which is not a "
316
+ "scalar pybind11 converts element-wise. Use a vector of a "
317
+ "numeric type or std::string, or exclude the symbol."
318
+ )
319
+ return inner, True
320
+
321
+ if base in known_records:
322
+ if pointer_depth == 0:
323
+ return base, False
324
+ if pointer_depth == 1 and not is_return:
325
+ return base, False
326
+ raise NativeTypeError(
327
+ f"'{raw.strip()}' returns a raw pointer to '{base}'; binding it would "
328
+ "hand ownership to Python while C++ still owns the object. Return by "
329
+ "value or by reference instead."
330
+ )
331
+
332
+ if pointer_depth:
333
+ # A capitalised unknown base is almost always a forward-declared class
334
+ # rather than a numeric buffer; saying "cannot infer its length" there
335
+ # would send you looking for the wrong fix.
336
+ if base[:1].isupper():
337
+ raise NativeTypeError(
338
+ f"'{raw.strip()}' refers to '{base}', which is only forward-declared "
339
+ "(an incomplete type). pybind11 needs the full definition to bind it. "
340
+ f"Add the header that defines '{base}' to this service's native/ "
341
+ "directory, or exclude the symbol in nativegate.yaml."
342
+ )
343
+ # A pointer to a scalar may still be bindable as an array, if another
344
+ # parameter of the same function carries its length. That needs the
345
+ # whole signature, so the decision is deferred to _parse_params — the
346
+ # same shape as cpp_ast.PointerLengthUnknown, and for the same reason.
347
+ if base in _SCALAR_POINTER_ELEMENTS:
348
+ raise PointerLengthUnknown(
349
+ f"'{raw.strip()}' is a pointer; nativegate cannot infer its "
350
+ "length. Wrap it in a type that carries its own size "
351
+ "(std::vector, a span, or a struct), pass its length in an "
352
+ "argument named after it (`n`, `n_values`, `values_len`, ...), "
353
+ "or exclude the symbol in nativegate.yaml.",
354
+ element=_SCALAR_POINTER_ELEMENTS[base],
355
+ is_mutable="const" not in raw.split("*")[0].split(),
356
+ )
357
+ raise NativeTypeError(
358
+ f"'{raw.strip()}' is a pointer; nativegate cannot infer its length. "
359
+ "Wrap it in a type that carries its own size (std::vector, a span, "
360
+ "or a struct) or exclude the symbol in nativegate.yaml."
361
+ )
362
+
363
+ return map_type(base), False
364
+
365
+
366
+ def _parse_params(raw: str, known_records: frozenset[str] = frozenset()) -> list[Parameter]:
367
+ raw = raw.strip()
368
+ if not raw or raw == "void":
369
+ return []
370
+
371
+ params: list[Parameter] = []
372
+ deferred: dict[str, PointerLengthUnknown] = {}
373
+ for index, part in enumerate(_split_top_level(raw)):
374
+ # Drop a default argument: `int n = 4` -> `int n`.
375
+ part = part.split("=", 1)[0].strip()
376
+ if not part:
377
+ continue
378
+
379
+ # Array parameter spelled `double values[]`.
380
+ is_array = bool(re.search(r"\[\s*\d*\s*\]$", part))
381
+ part = re.sub(r"\[\s*\d*\s*\]$", "", part).strip()
382
+
383
+ match = re.match(r"^(?P<type>.*?)(?P<name>\w+)$", part, re.DOTALL)
384
+ if match and match.group("type").strip():
385
+ type_text, name = match.group("type"), match.group("name")
386
+ else:
387
+ # Unnamed parameter: `double` / `const char*`.
388
+ type_text, name = part, f"arg{index}"
389
+
390
+ try:
391
+ py_type, pointer_array = _normalize_type(type_text, known_records)
392
+ except PointerLengthUnknown as exc:
393
+ deferred[name] = exc
394
+ params.append(Parameter(name=name, type=exc.element, is_array=True))
395
+ continue
396
+ params.append(
397
+ Parameter(name=name, type=py_type, is_array=is_array or pointer_array)
398
+ )
399
+
400
+ if deferred:
401
+ by_name = {p.name: p for p in params}
402
+ paired = {array: length for length, array in size_pairings(params)}
403
+ for name, exc in deferred.items():
404
+ length = paired.get(name)
405
+ if length is None:
406
+ # Nothing names this pointer's length; refuse it as before.
407
+ raise exc
408
+ by_name[name].length_param = length
409
+ by_name[name].is_mutable_buffer = exc.is_mutable
410
+
411
+ return params
412
+
413
+
414
+ # --- declaration scanning -----------------------------------------------
415
+
416
+
417
+ def _statements(body: str):
418
+ """Yield (statement_text, access_after) for each ';'-terminated statement,
419
+ tracking access specifiers as they are encountered."""
420
+ access = None
421
+ for chunk in _split_top_level(_strip_inline_bodies(body), ";"):
422
+ text = chunk
423
+ while True:
424
+ match = _ACCESS_RE.match(text)
425
+ if not match:
426
+ break
427
+ access = match.group(1).lower()
428
+ text = text[match.end() :]
429
+ text = text.strip()
430
+ if text:
431
+ yield text, access
432
+
433
+
434
+ @dataclass
435
+ class _Decl:
436
+ """One classified declaration.
437
+
438
+ kind is "method", "ctor", "field", or "operator" — the last meaning a
439
+ recognised operator this reader will not attempt to bind, so the caller
440
+ can report it rather than drop it.
441
+ """
442
+
443
+ kind: str
444
+ name: str = ""
445
+ returns: str = ""
446
+ params: str = ""
447
+ exposed: bool = False
448
+
449
+
450
+ def _parse_declarator(statement: str, record_name: str | None) -> _Decl | None:
451
+ """Classify one ';'-terminated declaration, or None if it isn't one."""
452
+ exposed = _EXPOSE_ATTR in statement
453
+ text = statement.replace(_EXPOSE_ATTR, " ").strip()
454
+
455
+ # Checked FIRST, because a symbolic operator never reaches the branches
456
+ # below: _DECLARATOR_RE's name group is `~?\w+`, which cannot match the
457
+ # `+` in `operator+`, so `Vec2 operator+(const Vec2&) const` fell through
458
+ # to the data-member branch and was reported as a field called "const".
459
+ # cpp_ast binds these as Python special methods; this reader cannot parse
460
+ # them reliably, so it names them honestly and says so.
461
+ operator = _OPERATOR_SPELLING_RE.search(text)
462
+ if operator and "(" in text:
463
+ return _Decl(kind="operator", name=operator.group(0).strip(), exposed=exposed)
464
+
465
+ match = _DECLARATOR_RE.match(text)
466
+ if not match:
467
+ # No argument list: a data member, or something we don't model.
468
+ field = re.match(r"^(?P<type>.*?)(?P<name>\w+)$", text, re.DOTALL)
469
+ if field and field.group("type").strip():
470
+ return _Decl(
471
+ kind="field",
472
+ name=field.group("name"),
473
+ returns=field.group("type"),
474
+ exposed=exposed,
475
+ )
476
+ return None
477
+
478
+ name = match.group("name")
479
+ spec_tokens = [
480
+ t for t in match.group("spec").split() if t not in _DROPPED_SPECIFIERS
481
+ ]
482
+
483
+ # A destructor is genuinely nothing to bind: pybind11 destroys the held
484
+ # object itself, and Python has no `__del__` contract worth wiring to it.
485
+ if name.startswith("~"):
486
+ return None
487
+ # Operators ARE bindable — cpp_ast binds them as Python special methods —
488
+ # but this reader's declarator pattern cannot match `operator[]`,
489
+ # `operator()` or `operator<` reliably, and half-parsing one is worse than
490
+ # not parsing it. Reported rather than silently dropped, with the reason,
491
+ # so the difference between the two backends is visible instead of being
492
+ # discovered as a missing method.
493
+ if "operator" in name:
494
+ return _Decl(kind="operator", name=name, exposed=exposed)
495
+ if record_name and name == record_name and not spec_tokens:
496
+ return _Decl(kind="ctor", name=name, params=match.group("params"), exposed=exposed)
497
+ if not spec_tokens:
498
+ # No return type and not a constructor — a macro-generated name, or a
499
+ # declaration this parser does not model.
500
+ return None
501
+
502
+ return _Decl(
503
+ kind="method",
504
+ name=name,
505
+ returns=" ".join(spec_tokens),
506
+ params=match.group("params"),
507
+ exposed=exposed,
508
+ )
509
+
510
+
511
+ def _unparsed_name(statement: str) -> str | None:
512
+ """Best-effort identifier for a declaration that failed to parse, so the
513
+ skip report names something a human can search the header for."""
514
+ match = re.search(r"(\w+)\s*\(", statement)
515
+ return match.group(1) if match else None
516
+
517
+
518
+ def _parse_bases(
519
+ clause: str | None, kind: str, known_records: frozenset[str]
520
+ ) -> list[str]:
521
+ """Public base classes bound by this service, from a `: public Bar` clause.
522
+
523
+ Only public bases are reported: with private or protected inheritance the
524
+ Derived -> Base conversion is inaccessible, so naming it in
525
+ `py::class_<Derived, Base>` would not compile. Bases the service does not
526
+ bind are dropped for the same reason — pybind11 needs the base registered.
527
+ """
528
+ if not clause:
529
+ return []
530
+ default_access = "public" if kind == "struct" else "private"
531
+
532
+ bases = []
533
+ for part in _split_top_level(clause):
534
+ match = _BASE_RE.match(part.strip())
535
+ if not match:
536
+ continue
537
+ access = (match.group("access") or default_access).lower()
538
+ base = match.group("name").split("::")[-1]
539
+ if access == "public" and base in known_records:
540
+ bases.append(base)
541
+ return bases
542
+
543
+
544
+ def _parse_record(
545
+ kind: str,
546
+ name: str,
547
+ body: str,
548
+ namespace: str | None,
549
+ module: ModuleIR,
550
+ known_records: frozenset[str] = frozenset(),
551
+ base_clause: str | None = None,
552
+ ):
553
+ """Build a ClassDef or StructDef from one class/struct body."""
554
+ default_access = "public" if kind == "struct" else "private"
555
+ bases = _parse_bases(base_clause, kind, known_records)
556
+
557
+ methods: list[Method] = []
558
+ fields: list[Parameter] = []
559
+ constructors: list[list[Parameter]] = []
560
+ saw_any_constructor = False
561
+
562
+ for statement, access in _statements(body):
563
+ effective = access or default_access
564
+ decl = _parse_declarator(statement, name)
565
+ if decl is not None and decl.kind == "operator":
566
+ # Reported, not dropped. cpp_ast binds operators as Python special
567
+ # methods; this reader cannot parse their declarations reliably, so
568
+ # the difference is made visible instead of being discovered later
569
+ # as a method that is simply missing.
570
+ module.skipped.append(
571
+ SkippedSymbol(
572
+ f"{name}::{decl.name}",
573
+ f"'{decl.name}' is an operator. The Clang AST parser binds "
574
+ "operators as Python special methods; this regex reader "
575
+ "cannot parse their declarations reliably. Install libclang "
576
+ '(pip install "nativegate[clang]") to bind it, or expose a '
577
+ "named method instead.",
578
+ )
579
+ )
580
+ continue
581
+ if decl is not None and decl.kind == "operator":
582
+ module.skipped.append(
583
+ SkippedSymbol(
584
+ f"{name}::{decl.name}",
585
+ f"'{decl.name}' is an operator. The Clang AST parser binds "
586
+ "operators as Python special methods; this regex reader "
587
+ "cannot parse their declarations reliably. Install libclang "
588
+ '(pip install "nativegate[clang]") to bind it, or expose a '
589
+ "named method instead.",
590
+ )
591
+ )
592
+ continue
593
+ if decl is None:
594
+ continue
595
+
596
+ if decl.kind == "ctor":
597
+ saw_any_constructor = True
598
+ if effective != "public":
599
+ continue
600
+ try:
601
+ constructors.append(_parse_params(decl.params, known_records))
602
+ except NativeTypeError as exc:
603
+ module.skipped.append(
604
+ SkippedSymbol(f"{name}::{name}", str(exc))
605
+ )
606
+ continue
607
+
608
+ if effective != "public":
609
+ continue
610
+
611
+ if decl.kind == "field":
612
+ try:
613
+ py_type, is_array = _normalize_type(decl.returns, known_records)
614
+ except NativeTypeError as exc:
615
+ module.skipped.append(SkippedSymbol(f"{name}.{decl.name}", str(exc)))
616
+ continue
617
+ fields.append(Parameter(name=decl.name, type=py_type, is_array=is_array))
618
+ continue
619
+
620
+ try:
621
+ returns, _ = _normalize_type(decl.returns, known_records, is_return=True)
622
+ parameters = _parse_params(decl.params, known_records)
623
+ except NativeTypeError as exc:
624
+ module.skipped.append(SkippedSymbol(f"{name}::{decl.name}", str(exc)))
625
+ continue
626
+ methods.append(
627
+ Method(
628
+ name=decl.name,
629
+ parameters=parameters,
630
+ returns=returns,
631
+ is_static="static" in statement.split("(")[0],
632
+ )
633
+ )
634
+
635
+ if kind == "struct" and not methods and not bases and fields:
636
+ return StructDef(name=name, namespace=namespace, fields=fields)
637
+
638
+ # A class with only non-default constructors cannot be `py::init<>()`.
639
+ has_default = (not saw_any_constructor) or any(not p for p in constructors)
640
+ return ClassDef(
641
+ name=name,
642
+ namespace=namespace,
643
+ methods=methods,
644
+ has_default_constructor=has_default,
645
+ constructors=[c for c in constructors if c],
646
+ fields=fields,
647
+ bases=bases,
648
+ )
649
+
650
+
651
+ def defined_record_names(path: Path) -> frozenset[str]:
652
+ """Records *defined* (not merely forward-declared) in this header.
653
+
654
+ A service's headers all compile into one extension, so a type
655
+ forward-declared in one and defined in another is complete by the time
656
+ pybind11 sees it. Callers union these across every header and feed the
657
+ result back as `extra_known_records`.
658
+ """
659
+ return frozenset(m.group("name") for m in _RECORD_RE.finditer(path.read_text()))
660
+
661
+
662
+ def parse_header(
663
+ path: Path,
664
+ expose: ExposeConfig,
665
+ extra_known_records: frozenset[str] = frozenset(),
666
+ ) -> ModuleIR:
667
+ """Parse a C++ header into a language-neutral ModuleIR.
668
+
669
+ A symbol is included if it carries `[[nativegate::expose]]` OR is named in
670
+ nativegate.yaml's `expose:` block (config takes precedence for legacy code
671
+ that can't be annotated in place — see design.md section 9).
672
+
673
+ `extra_known_records` names types defined in sibling headers of the same
674
+ service; without it, a type only forward-declared here is treated as
675
+ incomplete and any symbol using it is skipped.
676
+ """
677
+ source = _strip_preprocessor(_strip_comments(path.read_text()))
678
+
679
+ namespace_match = _NAMESPACE_RE.search(source)
680
+ namespace = namespace_match.group(1) if namespace_match else None
681
+
682
+ module = ModuleIR(name=path.stem, language="cpp", source_file=str(path))
683
+
684
+ # Pass 1: locate every record and blank its text out, so the free-function
685
+ # scan cannot see member declarations. Collecting the names before any
686
+ # body is parsed is what makes a method returning a type declared later in
687
+ # the same header (`PvtState properties_at(...)`) resolvable — a single
688
+ # forward pass would have to give up on it.
689
+ records = []
690
+ remainder = list(source)
691
+ for match in _RECORD_RE.finditer(source):
692
+ open_index = source.index("{", match.end() - 1)
693
+ close_index = _matching_brace(source, open_index)
694
+ if close_index == -1:
695
+ continue
696
+
697
+ records.append(
698
+ (
699
+ match.group("kind"),
700
+ match.group("name"),
701
+ source[open_index + 1 : close_index],
702
+ match.group("bases"),
703
+ )
704
+ )
705
+ for i in range(match.start(), close_index + 1):
706
+ if remainder[i] != "\n":
707
+ remainder[i] = " "
708
+
709
+ # A forward declaration (`class FluidModel;`) names an INCOMPLETE type.
710
+ # pybind11 needs a complete type to build a caster, so binding a method
711
+ # that takes one fails at compile time with
712
+ # "incomplete type 'FluidModel' used in type trait expression" — a wall
713
+ # of template errors pointing into pybind11 internals, not at your header.
714
+ # Treating forward declarations as bindable is what used to produce that.
715
+ #
716
+ # The type may still be completed by a sibling header in the same service
717
+ # (all of a service's headers compile into one extension), so the caller
718
+ # passes those names in via `extra_known_records`.
719
+ defined_here = frozenset(name for _kind, name, _body, _bases in records)
720
+ known_records = defined_here | extra_known_records
721
+
722
+ # Pass 2: parse the bodies.
723
+ for kind, record_name, body, base_clause in records:
724
+ if not expose.is_exposed(record_name):
725
+ continue
726
+ parsed = _parse_record(
727
+ kind, record_name, body, namespace, module, known_records, base_clause
728
+ )
729
+ if isinstance(parsed, StructDef):
730
+ module.structs.append(parsed)
731
+ else:
732
+ module.classes.append(parsed)
733
+
734
+ # Free functions, including everything inside `extern "C"` and `namespace`
735
+ # blocks — those braces group declarations, they don't scope them away.
736
+ outside = "".join(remainder)
737
+ outside = re.sub(r'\bextern\s+"C"\s*\{', " ", outside)
738
+ outside = re.sub(r"\bnamespace\s+\w*\s*\{", " ", outside)
739
+ outside = outside.replace("}", " ")
740
+
741
+ for statement, _access in _statements(outside):
742
+ decl = _parse_declarator(statement, None)
743
+ if decl is not None and decl.kind == "operator":
744
+ # Reported, not dropped. cpp_ast binds operators as Python special
745
+ # methods; this reader cannot parse their declarations reliably, so
746
+ # the difference is made visible instead of being discovered later
747
+ # as a method that is simply missing.
748
+ module.skipped.append(
749
+ SkippedSymbol(
750
+ decl.name,
751
+ f"'{decl.name}' is an operator. The Clang AST parser binds "
752
+ "operators as Python special methods; this regex reader "
753
+ "cannot parse their declarations reliably. Install libclang "
754
+ '(pip install "nativegate[clang]") to bind it, or expose a '
755
+ "named method instead.",
756
+ )
757
+ )
758
+ continue
759
+ if decl is not None and decl.kind == "operator":
760
+ module.skipped.append(
761
+ SkippedSymbol(
762
+ decl.name,
763
+ f"'{decl.name}' is an operator. The Clang AST parser binds "
764
+ "operators as Python special methods; this regex reader "
765
+ "cannot parse their declarations reliably. Install libclang "
766
+ '(pip install "nativegate[clang]") to bind it, or expose a '
767
+ "named method instead.",
768
+ )
769
+ )
770
+ continue
771
+ if decl is None or decl.kind != "method":
772
+ # A statement that clearly wants to be a function declaration but
773
+ # didn't parse is reported, not dropped. Returning an empty module
774
+ # for a header full of declarations reads as "nothing to bind here"
775
+ # when the truth is "this parser could not read it" — which is what
776
+ # macro-mangled extern "C" bridge headers look like.
777
+ if decl is None and "(" in statement and not statement.startswith("typedef"):
778
+ name = _unparsed_name(statement)
779
+ if name:
780
+ module.skipped.append(
781
+ SkippedSymbol(
782
+ name,
783
+ "could not parse this declaration; if the name comes from a "
784
+ "macro, nativegate does not run a preprocessor — declare the "
785
+ "symbol with its expanded name to bind it.",
786
+ )
787
+ )
788
+ continue
789
+ if not expose.is_exposed(decl.name):
790
+ continue
791
+ try:
792
+ returns, _ = _normalize_type(decl.returns, known_records, is_return=True)
793
+ parameters = _parse_params(decl.params, known_records)
794
+ except NativeTypeError as exc:
795
+ module.skipped.append(SkippedSymbol(decl.name, str(exc)))
796
+ continue
797
+ module.functions.append(
798
+ FunctionDef(name=decl.name, parameters=parameters, returns=returns)
799
+ )
800
+
801
+ # One macro spelled 28 times is one problem, not 28. Collapse identical
802
+ # (name, reason) pairs so the report stays readable.
803
+ seen: set[tuple[str, str]] = set()
804
+ deduped = []
805
+ for entry in module.skipped:
806
+ key = (entry.name, entry.reason)
807
+ if key not in seen:
808
+ seen.add(key)
809
+ deduped.append(entry)
810
+ module.skipped = deduped
811
+
812
+ return module