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,686 @@
1
+ """Regex/line-oriented Fortran reader (design.md section 8).
2
+
3
+ This was nativegate's only Fortran front end; `fortran_fparser.py` is the real
4
+ parse tree that supersedes it, and `fortran.py` picks between them. It is kept
5
+ because it has no third-party dependency and because reproducing its exact
6
+ behaviour is how the fparser2 backend's parity is measured.
7
+
8
+ It does NOT scan the whole file into a tree. Each name in `expose.functions:`
9
+ drives one targeted regex search for that routine's
10
+ `function ... end function` / `subroutine ... end subroutine` block, and
11
+ everything else in the file is left untouched — so a 20,000-line template
12
+ with one exposed routine costs roughly the same as a 200-line one.
13
+
14
+ That property turns out to be a premature optimisation at the sizes nativegate
15
+ actually sees: fparser2 parses every deck in libraries/petro whole in well
16
+ under 0.15s each. It is documented here as a description of this reader, not
17
+ as a constraint on the replacement.
18
+
19
+ `expose.functions:` is still REQUIRED for Fortran (there is no
20
+ "expose everything" fallback like C++ has), under both backends.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from pathlib import Path
27
+
28
+ from ..config import ExposeConfig
29
+ from ..discovery import is_fixed_form
30
+ from ..ir import (
31
+ FunctionDef,
32
+ ModuleIR,
33
+ NativeTypeError,
34
+ Parameter,
35
+ SkippedSymbol,
36
+ map_fortran_type,
37
+ )
38
+ from ..preprocess import expand_includes
39
+ from . import fixed_form
40
+
41
+
42
+ def _strip_inline_comment(
43
+ line: str, open_quote: str | None = None
44
+ ) -> tuple[str, str | None]:
45
+ """Drop a free-form `!` comment, respecting character literals.
46
+
47
+ Returns (text, quote_still_open). `open_quote` carries a character literal
48
+ that a previous physical line left open across a `&` continuation.
49
+
50
+ `write (*,*) 'flow rate ! per day'` has no comment on it, and cutting at
51
+ the bare `!` would truncate the statement mid-string.
52
+
53
+ Doubled quotes (`'don''t'`) need no special case: the second quote closes
54
+ the literal and the third reopens it, which leaves the parity — the only
55
+ thing this needs — correct either way.
56
+ """
57
+ quote = open_quote
58
+ for index, char in enumerate(line):
59
+ if quote is not None:
60
+ if char == quote:
61
+ quote = None
62
+ elif char in "'\"":
63
+ quote = char
64
+ elif char == "!":
65
+ return line[:index], None
66
+ # A literal still open at end of line is only meaningful when the line
67
+ # continues; the caller decides, and passes it back in on the next line.
68
+ return line, quote
69
+
70
+
71
+ def normalize_free_form(source: str) -> str:
72
+ """Collapse free-form source into one logical statement per line.
73
+
74
+ Free-form Fortran continues a statement by ending the line with `&`, and
75
+ a continuation line may re-open with its own leading `&` to mark exactly
76
+ where the statement resumes. Nothing downstream of here handles that: the
77
+ routine finder captures its argument list with `(?P<params>[^)]*)`, which
78
+ matches newlines, so a wrapped argument list
79
+
80
+ function tubing_bhp(wellhead_p, q_oil, q_water, q_gas, diameter, &
81
+ roughness, tvd, md, nseg) result(pbh)
82
+
83
+ yielded a parameter literally named "&\\n roughness". That name reached
84
+ the generated FastAPI router as `diameter: float, &` — a Python
85
+ SyntaxError, so the service could not even be imported, and the only
86
+ workarounds were to reformat the Fortran onto one line or hand-edit
87
+ generated code. Both mean touching source nativegate promises not to
88
+ touch.
89
+
90
+ This is the free-form counterpart of `fixed_form.normalize_fixed_form`
91
+ and exists for the same reason: dialect structure is normalized once, up
92
+ front, so no downstream pattern has to know about it.
93
+ """
94
+ out: list[str] = []
95
+ pending = ""
96
+ # Carried across physical lines: a character literal may itself be split
97
+ # over a continuation (`'rate&` / `&! per day'`), and resetting the quote
98
+ # state per line made the `!` inside that still-open literal look like a
99
+ # comment, silently truncating the string.
100
+ open_quote: str | None = None
101
+
102
+ for raw in source.splitlines():
103
+ line, open_quote = _strip_inline_comment(raw, open_quote)
104
+
105
+ if pending:
106
+ # A whole-line comment is legal *between* continuation lines and
107
+ # does not end the statement. `_strip_inline_comment` reduces such
108
+ # a line to blank; flushing on it closed the statement early and
109
+ # truncated the argument list — so while a continuation is pending,
110
+ # a blank line is skipped rather than treated as the final segment.
111
+ if not line.strip():
112
+ continue
113
+ # A leading `&` marks where the statement resumes and is not part
114
+ # of it. Without one, the statement resumes at the first
115
+ # non-blank character.
116
+ resumed = line.lstrip()
117
+ line = resumed[1:] if resumed.startswith("&") else resumed
118
+
119
+ trimmed = line.rstrip()
120
+ if trimmed.endswith("&"):
121
+ pending += trimmed[:-1]
122
+ continue
123
+
124
+ if pending:
125
+ out.append(pending + trimmed)
126
+ pending = ""
127
+ else:
128
+ # Leading whitespace is preserved: the declaration and routine
129
+ # patterns anchor with `^\s*`, and blank lines keep the source
130
+ # roughly line-aligned for error messages.
131
+ out.append(line)
132
+
133
+ # A trailing `&` with nothing after it is malformed Fortran; keep what was
134
+ # accumulated so the caller reports "routine not found" rather than
135
+ # silently dropping the last statement.
136
+ if pending:
137
+ out.append(pending)
138
+
139
+ return "\n".join(out)
140
+
141
+
142
+ _DECL_RE = re.compile(
143
+ r"^\s*(?P<type>real|integer|logical|character|complex|double\s+precision)"
144
+ r"(?:\s*\((?P<kind>[^)]*)\))?"
145
+ r"(?P<attrs>(?:\s*,\s*\w+(?:\([^)]*\))?)*)"
146
+ r"\s*::\s*(?P<names>[^\n!]+)",
147
+ re.IGNORECASE | re.MULTILINE,
148
+ )
149
+
150
+ _INTENT_RE = re.compile(r"intent\s*\(\s*(in|out|inout)\s*\)", re.IGNORECASE)
151
+
152
+ # `optional` is an attribute in its own right and never a prefix of another
153
+ # one, so a word-boundary match against the attribute list is exact.
154
+ _OPTIONAL_RE = re.compile(r"\boptional\b", re.IGNORECASE)
155
+
156
+ # A derived-type (or polymorphic) declaration: `type(pvt_state) :: st`.
157
+ # Recognised deliberately — f2py cannot pass or return one, and the whole
158
+ # point of matching it is to say so rather than let the declaration lookup
159
+ # miss and fall through to a default. `type :: pvt_state` (a type
160
+ # *definition*) carries no parenthesis and is not matched here.
161
+ _DERIVED_DECL_RE = re.compile(
162
+ r"^\s*(?P<type>(?:type|class)\s*\(\s*[^)]*\))"
163
+ r"(?P<attrs>(?:\s*,\s*\w+(?:\([^)]*\))?)*)"
164
+ r"\s*::\s*(?P<names>[^\n!]+)",
165
+ re.IGNORECASE | re.MULTILINE,
166
+ )
167
+
168
+ # An intrinsic type word anywhere in a routine's return-type prefix
169
+ # (`real(8) function foo(...)`, `double precision function bar(...)`).
170
+ _PREFIX_TYPE_RE = re.compile(
171
+ r"\b(real|integer|logical|character|complex|double\s+precision)\b",
172
+ re.IGNORECASE,
173
+ )
174
+ _PREFIX_DERIVED_RE = re.compile(r"^\s*(?:type|class)\s*\(", re.IGNORECASE)
175
+
176
+ # The trailing name on `end module` is optional in the standard, and a module
177
+ # that omits it must still be recognised or every routine inside it is
178
+ # mis-attributed to the top level — which sends generate_init_py looking for
179
+ # the symbol where f2py has not put it.
180
+ _MODULE_BLOCK_RE = re.compile(
181
+ r"^[ \t]*module\s+(?P<name>\w+)[ \t]*$"
182
+ r".*?"
183
+ r"^[ \t]*end[ \t]+module(?:[ \t]+\w+)?[ \t]*$",
184
+ re.IGNORECASE | re.MULTILINE | re.DOTALL,
185
+ )
186
+
187
+ # A return-type prefix can be more than one token: `double precision function`,
188
+ # `real(kind=8) function`, `character*(*) function`. Allowing only a single
189
+ # `[\w()]+` token silently skipped every `DOUBLE PRECISION FUNCTION` in the
190
+ # file. `(?!function\b|subroutine\b)` keeps the optional prefix from eating
191
+ # the keyword itself when a routine has no return type; `end\b` is excluded
192
+ # for a related reason — once the argument list became optional below,
193
+ # `end subroutine reset` otherwise parsed as prefix "end" plus a fresh
194
+ # routine declaration, and every routine was discovered twice.
195
+ _TYPE_PREFIX = r"(?:(?!function\b|subroutine\b|end\b)[\w*()=]+[ \t]+)*"
196
+
197
+ # The argument list is optional: `subroutine reset` is legal and is the
198
+ # standard idiom for a routine that works purely through module variables or
199
+ # COMMON. Requiring "(" made such a routine invisible to discovery *and*
200
+ # unfindable by name, so this pattern and `_find_block` are fixed together —
201
+ # fixing only discovery converts a silent omission into a hard error.
202
+ # (fixed_form.py was already fixed for the same thing; this is the free-form
203
+ # half.)
204
+ _ROUTINE_DECL_RE = re.compile(
205
+ rf"^[ \t]*{_TYPE_PREFIX}(?:function|subroutine)\s+(?P<name>\w+)[ \t]*(?:\(|$)",
206
+ re.IGNORECASE | re.MULTILINE,
207
+ )
208
+
209
+ # `end`, `end function` and `end function foo` are all legal terminators. The
210
+ # bare form is the delicate one: `end` also closes `if`/`do`/`select`/`where`/
211
+ # `associate`/`block`/`type`/`interface`, so it is accepted only when the line
212
+ # is *exactly* `end` — which no construct terminator ever is, since they all
213
+ # carry their keyword and the one-word spellings are `endif`/`enddo`.
214
+ _END_BLOCK = r"^[ \t]*end(?:[ \t]+{keyword}(?:[ \t]+{name})?)?[ \t]*$"
215
+
216
+
217
+ class _RoutineSkipped(Exception):
218
+ """A routine that was found and understood, but cannot be bound by f2py.
219
+
220
+ Raised out of extraction and turned into a `ModuleIR.skipped` entry by
221
+ `parse_source`. Defaulting an unresolvable type to `float` instead
222
+ produces a service that builds, imports, passes its smoke test and
223
+ returns a wrong number; this is the alternative.
224
+ """
225
+
226
+ def __init__(self, reason: str) -> None:
227
+ super().__init__(reason)
228
+ self.reason = reason
229
+
230
+
231
+ def list_routine_names(path: Path) -> list[str]:
232
+ """Full-file scan for every `function`/`subroutine` name, for `quickstart`
233
+ on small/unfamiliar files where you don't yet know what to ask for by
234
+ name. NOT used by parse_source's targeted extraction — deliberately
235
+ separate so the "huge legacy template" path never accidentally pays for
236
+ a full scan (see module docstring).
237
+
238
+ Fixed-form source must be normalized first, and for the same reasons
239
+ parse_source normalizes it: a continued declaration
240
+ (`DOUBLE PRECISION FUNCTION FOO (A,\\n & B)`) spans two physical
241
+ lines, and a commented-out routine in column 1 looks real. Scanning raw
242
+ text with the free-form regex silently under-reports — against
243
+ libraries/petro it found 25 of 48 routines, missing every
244
+ `DOUBLE PRECISION FUNCTION`, so `quickstart` would generate a service
245
+ exposing half the API with no warning that anything was skipped.
246
+ """
247
+ if is_fixed_form(path):
248
+ normalized = fixed_form.normalize_fixed_form(path.read_text())
249
+ return fixed_form.list_routine_names(normalized)
250
+ # Free-form needs its own normalization for the same reason: a routine
251
+ # whose argument list wraps has its `function` keyword and its name on one
252
+ # physical line but is only a single statement, and a commented-out
253
+ # routine must not be discovered.
254
+ return [
255
+ m.group("name")
256
+ for m in _ROUTINE_DECL_RE.finditer(normalize_free_form(path.read_text()))
257
+ ]
258
+
259
+
260
+ def _find_block(source: str, keyword: str, name: str) -> re.Match | None:
261
+ """Locate one `function`/`subroutine` block by name without parsing the rest of the file."""
262
+ end = _END_BLOCK.format(keyword=keyword, name=re.escape(name))
263
+ pattern = re.compile(
264
+ rf"^[ \t]*(?P<prefix>{_TYPE_PREFIX}){keyword}\s+{re.escape(name)}[ \t]*"
265
+ # No argument list at all is legal (`subroutine reset`).
266
+ rf"(?:\((?P<params>[^)]*)\))?"
267
+ rf"(?:\s*result\s*\(\s*(?P<result>\w+)\s*\))?[ \t]*$"
268
+ rf"(?P<body>.*?)"
269
+ rf"{end}",
270
+ re.IGNORECASE | re.MULTILINE | re.DOTALL,
271
+ )
272
+ return pattern.search(source)
273
+
274
+
275
+ def _enclosing_fortran_module(source: str, match_start: int) -> str | None:
276
+ """f2py nests routines defined inside `module X ... end module X` under X.<routine>
277
+ in the compiled extension (unlike bare/free routines, which land at the top level).
278
+ The generated Python __init__.py needs to know this to re-export correctly."""
279
+ for module_match in _MODULE_BLOCK_RE.finditer(source):
280
+ if module_match.start() <= match_start <= module_match.end():
281
+ return module_match.group("name")
282
+ return None
283
+
284
+
285
+ def _parse_derived_declarations(body: str) -> dict[str, str]:
286
+ """Map variable name -> native type spelling for every derived-type declaration.
287
+
288
+ These are collected precisely so they can be refused: f2py has no way to
289
+ pass or return a `type(...)`, and a missed lookup here used to fall
290
+ through to `float`.
291
+ """
292
+ unbindable: dict[str, str] = {}
293
+ for decl in _DERIVED_DECL_RE.finditer(body):
294
+ spelling = re.sub(r"\s+", "", decl.group("type"))
295
+ for raw_name in decl.group("names").split(","):
296
+ var_name = raw_name.strip().split("(")[0].strip()
297
+ if var_name:
298
+ unbindable[var_name.lower()] = spelling
299
+ return unbindable
300
+
301
+
302
+ def _parse_declarations(body: str) -> dict[str, Parameter]:
303
+ """Map declared variable name -> Parameter, for every `type, attrs :: names` line in body."""
304
+ declared: dict[str, Parameter] = {}
305
+ for decl in _DECL_RE.finditer(body):
306
+ try:
307
+ py_type = map_fortran_type(decl.group("type"))
308
+ except NativeTypeError:
309
+ raise
310
+
311
+ attrs = decl.group("attrs") or ""
312
+ intent_match = _INTENT_RE.search(attrs)
313
+ intent = intent_match.group(1).lower() if intent_match else "in"
314
+ # Without this an `optional` argument is generated as required, and
315
+ # the caller has no way to express "absent".
316
+ is_optional = bool(_OPTIONAL_RE.search(attrs))
317
+
318
+ for raw_name in decl.group("names").split(","):
319
+ raw_name = raw_name.strip()
320
+ if not raw_name:
321
+ continue
322
+ is_array = "(" in raw_name or "dimension" in attrs.lower()
323
+ var_name = raw_name.split("(")[0].strip()
324
+ declared[var_name.lower()] = Parameter(
325
+ name=var_name,
326
+ type=py_type,
327
+ is_array=is_array,
328
+ intent=intent,
329
+ is_optional=is_optional,
330
+ )
331
+ return declared
332
+
333
+
334
+ def _resolve_parameters(
335
+ match: re.Match,
336
+ declared: dict[str, Parameter],
337
+ unbindable: dict[str, str],
338
+ implicit_map: dict[str, str],
339
+ ) -> list[Parameter]:
340
+ """Type every dummy argument, or refuse the routine.
341
+
342
+ Three sources in decreasing authority: an explicit declaration, a
343
+ recognised-but-unbindable declaration (refuse), and IMPLICIT typing.
344
+ There is deliberately no fourth "assume float" step.
345
+ """
346
+ raw_params = match.group("params") or ""
347
+ param_names = [p.strip() for p in raw_params.split(",") if p.strip()]
348
+
349
+ parameters: list[Parameter] = []
350
+ for param_name in param_names:
351
+ key = param_name.lower()
352
+ if key in declared:
353
+ parameters.append(declared[key])
354
+ continue
355
+ if key in unbindable:
356
+ raise _RoutineSkipped(
357
+ f"argument '{param_name}' is declared '{unbindable[key]}'; "
358
+ "f2py cannot pass a derived type."
359
+ )
360
+
361
+ base_type = fixed_form.implicit_type_of(param_name, implicit_map)
362
+ if base_type is None:
363
+ raise _RoutineSkipped(
364
+ f"argument '{param_name}' has no explicit declaration and the "
365
+ "source uses IMPLICIT NONE, so its type cannot be determined. "
366
+ "Declare it in the Fortran source."
367
+ )
368
+ parameters.append(Parameter(name=param_name, type=map_fortran_type(base_type)))
369
+ return parameters
370
+
371
+
372
+ def _prefix_return_type(prefix: str | None) -> str | None:
373
+ """Fortran base type from a `real(8) function ...` style prefix, if any."""
374
+ if not prefix:
375
+ return None
376
+ if _PREFIX_DERIVED_RE.match(prefix):
377
+ raise _RoutineSkipped(
378
+ f"declared as returning '{prefix.strip()}'; "
379
+ "f2py cannot return a derived type."
380
+ )
381
+ type_match = _PREFIX_TYPE_RE.search(prefix)
382
+ return re.sub(r"\s+", " ", type_match.group(1).lower()) if type_match else None
383
+
384
+
385
+ # A `contains` statement inside a routine opens its INTERNAL procedures. Their
386
+ # declarations are not the outer routine's declarations, but `_find_block`'s
387
+ # body capture runs to the routine's end and so swallows them — and
388
+ # `_parse_declarations` keeps the last match for a name, so an internal
389
+ # procedure that happens to reuse an outer dummy argument's name silently
390
+ # retypes it. A helper declaring `integer :: a` inside a function whose own
391
+ # `a` is `real(8)` produced a binding typed `int`: it compiles, it runs, and
392
+ # it hands Fortran the wrong thing. Contamination is the same whether or not
393
+ # the internal procedure closes with a bare `end`.
394
+ #
395
+ # Everything a routine declares for itself precedes `contains`, so cutting
396
+ # there loses nothing and removes the whole class of leak.
397
+ _CONTAINS_RE = re.compile(r"^[ \t]*contains[ \t]*$", re.IGNORECASE | re.MULTILINE)
398
+
399
+
400
+ def _own_declarations(body: str) -> str:
401
+ """`body` up to its first `contains`, i.e. excluding internal procedures."""
402
+ match = _CONTAINS_RE.search(body)
403
+ return body[: match.start()] if match else body
404
+
405
+
406
+ def _free_form_doc(raw_source: str, name: str) -> str | None:
407
+ """The comment header on a free-form routine, or None.
408
+
409
+ Located in the RAW file text, not the `normalize_free_form` output the
410
+ rest of this reader uses: normalisation blanks comments and joins
411
+ continuations, so by the time a routine is found the header is both gone
412
+ and at a different line number. The cleaning rules are shared with the
413
+ fparser2 backend (`fixed_form.doc_comment_at`) so both readers put
414
+ identical text in the IR — the differential harness compares whole
415
+ ModuleIRs and would fail on any disagreement.
416
+ """
417
+ span = fixed_form.find_header_lines(raw_source, name, fixed=False)
418
+ if span is None:
419
+ return None
420
+ return fixed_form.doc_comment_at(raw_source, span[0], span[1], fixed=False)
421
+
422
+
423
+ def _extract_function(
424
+ source: str, name: str, implicit_map: dict[str, str]
425
+ ) -> tuple[FunctionDef, str | None] | None:
426
+ match = _find_block(source, "function", name)
427
+ if match is None:
428
+ return None
429
+
430
+ body = _own_declarations(match.group("body"))
431
+ declared = _parse_declarations(body)
432
+ unbindable = _parse_derived_declarations(body)
433
+
434
+ parameters = _resolve_parameters(match, declared, unbindable, implicit_map)
435
+
436
+ result_name = (match.group("result") or name).lower()
437
+ result_param = declared.get(result_name)
438
+ if result_param is not None:
439
+ returns = result_param.type
440
+ elif result_name in unbindable:
441
+ raise _RoutineSkipped(
442
+ f"result variable '{result_name}' is declared "
443
+ f"'{unbindable[result_name]}'; f2py cannot return a derived type."
444
+ )
445
+ else:
446
+ base_type = _prefix_return_type(match.group("prefix")) or fixed_form.implicit_type_of(
447
+ result_name, implicit_map
448
+ )
449
+ if base_type is None:
450
+ raise _RoutineSkipped(
451
+ f"result variable '{result_name}' has no explicit declaration "
452
+ "and the source uses IMPLICIT NONE, so the return type cannot "
453
+ "be determined. Declare it in the Fortran source."
454
+ )
455
+ returns = map_fortran_type(base_type)
456
+
457
+ enclosing = _enclosing_fortran_module(source, match.start())
458
+ fn = FunctionDef(
459
+ name=name,
460
+ parameters=parameters,
461
+ returns=returns,
462
+ is_subroutine=False,
463
+ fortran_module=enclosing,
464
+ )
465
+ return fn, enclosing
466
+
467
+
468
+ def _extract_subroutine(
469
+ source: str, name: str, implicit_map: dict[str, str]
470
+ ) -> tuple[FunctionDef, str | None] | None:
471
+ match = _find_block(source, "subroutine", name)
472
+ if match is None:
473
+ return None
474
+
475
+ body = _own_declarations(match.group("body"))
476
+ declared = _parse_declarations(body)
477
+ unbindable = _parse_derived_declarations(body)
478
+
479
+ parameters = _resolve_parameters(match, declared, unbindable, implicit_map)
480
+
481
+ enclosing = _enclosing_fortran_module(source, match.start())
482
+ fn = FunctionDef(
483
+ name=name,
484
+ parameters=parameters,
485
+ returns="void",
486
+ is_subroutine=True,
487
+ fortran_module=enclosing,
488
+ )
489
+ return fn, enclosing
490
+
491
+
492
+ def _fixed_form_doc(expanded: str, name: str) -> str | None:
493
+ """The comment header on a fixed-form routine, or None.
494
+
495
+ Located by scanning the raw text rather than the normalised statements —
496
+ see `fixed_form.find_header_lines` for why the normalised line numbers
497
+ cannot be used.
498
+ """
499
+ span = fixed_form.find_header_lines(expanded, name)
500
+ if span is None:
501
+ return None
502
+ return fixed_form.doc_comment_at(expanded, span[0], span[1], fixed=True)
503
+
504
+
505
+ def _parse_fixed_form_source(
506
+ path: Path, expose: ExposeConfig, include_paths: list[Path]
507
+ ) -> ModuleIR:
508
+ """FORTRAN 77 fixed-form path.
509
+
510
+ INCLUDEs are expanded first because that's usually where the IMPLICIT
511
+ statements and COMMON blocks live — without them, parameter types fall
512
+ back to guesses and the resulting bindings return wrong numbers rather
513
+ than failing. See preprocess.expand_includes for why f2py can't be
514
+ trusted to do this itself.
515
+ """
516
+ expanded = expand_includes(path, include_paths)
517
+ normalized = fixed_form.normalize_fixed_form(expanded)
518
+ implicit_map = fixed_form.parse_implicit_map(normalized)
519
+
520
+ module = ModuleIR(name=path.stem, language="fortran", source_file=str(path))
521
+ # Fixed-form F77 predates modules; routines are always top level, so
522
+ # f2py exposes them directly with no nesting to bridge.
523
+ module.fortran_module = None
524
+
525
+ for name in expose.functions:
526
+ routine = fixed_form.find_routine(normalized, name)
527
+ if routine is None:
528
+ available = ", ".join(fixed_form.list_routine_names(normalized)[:15])
529
+ raise ValueError(
530
+ f"Could not find function or subroutine '{name}' in {path}. "
531
+ f"Routines found: {available or '(none)'}"
532
+ )
533
+
534
+ # F77 declares no intent, so it is recovered from the body: an
535
+ # argument that is assigned to is an output. Without this every
536
+ # scalar binds as intent(in) and its result is silently discarded —
537
+ # `STEP(DTIN, DTOUT, ICONV)` would return None.
538
+ intents = fixed_form.infer_intents(routine, normalized)
539
+
540
+ parameters = []
541
+ for param_name in routine["params"]:
542
+ key = param_name.lower()
543
+ base_type = routine["declared_types"].get(key) or fixed_form.implicit_type_of(
544
+ param_name, implicit_map
545
+ )
546
+ if base_type is None:
547
+ raise ValueError(
548
+ f"{path.name}: parameter '{param_name}' of '{name}' has no explicit "
549
+ "declaration and the source uses IMPLICIT NONE, so its type cannot "
550
+ "be determined. Declare it in the Fortran source."
551
+ )
552
+ py_type = map_fortran_type(base_type)
553
+ intent = intents.get(key, "in")
554
+
555
+ # Same rule, same message, same recorded spelling as the fparser2
556
+ # backend — the parity harness holds both to byte-identical IR. A
557
+ # CHARACTER output binds exactly when its length is fixed in the
558
+ # declaration; an assumed-length output BUILDS and then silently
559
+ # returns b'' (measured with numpy 2.5's f2py), so it is demoted
560
+ # with the reason.
561
+ char_spelling = (
562
+ routine.get("char_lengths", {}).get(key)
563
+ if py_type == "str"
564
+ else None
565
+ )
566
+ if py_type == "str" and intent != "in":
567
+ if char_spelling is None or not fixed_form.CHAR_FIXED_LEN_RE.match(
568
+ char_spelling
569
+ ):
570
+ module.skipped.append(
571
+ SkippedSymbol(
572
+ f"{name}({param_name})",
573
+ f"'{param_name}' is assigned in the body but is "
574
+ f"declared '{char_spelling or 'implicitly'}': f2py "
575
+ "can only return a CHARACTER whose length is fixed "
576
+ "in the declaration. An assumed-length output "
577
+ "builds and then silently returns an empty string "
578
+ "— measured, not assumed. Give it a length "
579
+ "(CHARACTER*80) and it becomes an output.",
580
+ )
581
+ )
582
+ intent = "in"
583
+
584
+ parameters.append(
585
+ Parameter(
586
+ name=param_name,
587
+ type=py_type,
588
+ is_array=key in routine["arrays"],
589
+ native_type=char_spelling,
590
+ intent=intent,
591
+ )
592
+ )
593
+
594
+ is_subroutine = routine["kind"] == "subroutine"
595
+ if is_subroutine:
596
+ returns = "void"
597
+ else:
598
+ declared = routine["declared_types"].get(name.lower())
599
+ prefix = routine["prefix"]
600
+ base = declared or (prefix.lower() if prefix else None) or fixed_form.implicit_type_of(
601
+ name, implicit_map
602
+ )
603
+ returns = map_fortran_type(base) if base else "float"
604
+
605
+ module.functions.append(
606
+ FunctionDef(
607
+ name=name,
608
+ parameters=parameters,
609
+ returns=returns,
610
+ is_subroutine=is_subroutine,
611
+ # Fixed-form F77 predates modules: always top level.
612
+ fortran_module=None,
613
+ # Read off `expanded` — the raw INCLUDE-expanded text, not
614
+ # `normalized`, whose line numbers no longer point at the
615
+ # comments because normalisation strips them.
616
+ doc=_fixed_form_doc(expanded, name),
617
+ )
618
+ )
619
+
620
+ return module
621
+
622
+
623
+ def parse_source(
624
+ path: Path, expose: ExposeConfig, include_paths: list[Path] | None = None
625
+ ) -> ModuleIR:
626
+ if not expose.functions:
627
+ raise ValueError(
628
+ "Fortran sources require explicit `expose.functions:` entries in nativegate.yaml "
629
+ "(no implicit expose-everything fallback — large legacy files are parsed "
630
+ "one requested routine at a time)."
631
+ )
632
+
633
+ if is_fixed_form(path):
634
+ return _parse_fixed_form_source(path, expose, include_paths or [])
635
+
636
+ # Continuations are joined before anything looks for a routine: every
637
+ # pattern below treats one line as one statement, and `_find_block`'s
638
+ # `[^)]*` argument capture spans newlines, so a wrapped argument list
639
+ # otherwise produces a parameter named "&".
640
+ raw_source = path.read_text()
641
+ source = normalize_free_form(raw_source)
642
+ module = ModuleIR(name=path.stem, language="fortran", source_file=str(path))
643
+
644
+ # IMPLICIT typing is dialect-independent, so the fixed-form implementation
645
+ # is reused rather than duplicated. Without it an undeclared argument in a
646
+ # `.f90` that omits `implicit none` — legal, and common in transitional
647
+ # 1990s code — silently bound as float; with it, `implicit none` instead
648
+ # makes the routine a reported skip.
649
+ implicit_map = fixed_form.parse_implicit_map(source)
650
+
651
+ fortran_module_name: str | None = "__unset__"
652
+ for name in expose.functions:
653
+ try:
654
+ found = _extract_function(source, name, implicit_map) or _extract_subroutine(
655
+ source, name, implicit_map
656
+ )
657
+ except _RoutineSkipped as skipped:
658
+ # Recognised but not bindable: reported through the channel that
659
+ # exists for exactly this, and omitted from module.functions so
660
+ # nothing downstream wraps a type f2py cannot carry.
661
+ module.skipped.append(SkippedSymbol(name, skipped.reason))
662
+ continue
663
+
664
+ if found is None:
665
+ raise ValueError(
666
+ f"Could not find function or subroutine '{name}' in {path}. "
667
+ "Check the name in nativegate.yaml matches the Fortran source exactly."
668
+ )
669
+ fn, enclosing_module = found
670
+ # Set here rather than inside the extractors: the doc is read off the
671
+ # raw file, which those work without.
672
+ fn.doc = _free_form_doc(raw_source, name)
673
+ module.functions.append(fn)
674
+
675
+ if fortran_module_name == "__unset__":
676
+ fortran_module_name = enclosing_module
677
+ elif fortran_module_name != enclosing_module:
678
+ raise ValueError(
679
+ f"'{name}' is defined in Fortran module '{enclosing_module}', but earlier "
680
+ f"exposed routines came from '{fortran_module_name}'. nativegate generates one "
681
+ "Python package per source file and can't mix routines from different "
682
+ "Fortran modules — split them into separate nativegate.yaml services."
683
+ )
684
+
685
+ module.fortran_module = None if fortran_module_name == "__unset__" else fortran_module_name
686
+ return module