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,868 @@
1
+ """FORTRAN 77 fixed-form source handling.
2
+
3
+ Fixed-form is a different dialect from the free-form .f90 the main parser
4
+ targets, and the differences are structural rather than cosmetic:
5
+
6
+ col 1 'C', 'c', '*' or '!' marks a full-line comment
7
+ cols 1-5 statement label
8
+ col 6 any non-blank/non-zero char continues the PREVIOUS line
9
+ cols 7-72 the statement
10
+ col 73+ ignored (historically the punch-card sequence number)
11
+
12
+ A regex written for free-form silently mismatches all of this — a
13
+ continued argument list looks like two unrelated lines, and a commented-out
14
+ routine looks like a real one. So fixed-form source is normalized to
15
+ one-logical-statement-per-line before any parsing happens.
16
+
17
+ Also handles IMPLICIT typing, which fixed-form legacy code relies on
18
+ completely: `DOUBLE PRECISION FUNCTION PVTBUB (RSTGT)` declares nothing
19
+ about RSTGT — its type comes from an `IMPLICIT DOUBLE PRECISION (A-H,O-Z)`
20
+ statement (often inside an INCLUDE file) plus the first letter of the name.
21
+ Getting this wrong produces bindings that run and return wrong numbers.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ _COMMENT_CHARS = frozenset("CcDd*!")
29
+
30
+ # FORTRAN's built-in default when no IMPLICIT statement says otherwise:
31
+ # I-N are INTEGER, everything else REAL.
32
+ DEFAULT_IMPLICIT = {
33
+ **{chr(c): "real" for c in range(ord("a"), ord("z") + 1)},
34
+ **{chr(c): "integer" for c in range(ord("i"), ord("n") + 1)},
35
+ }
36
+
37
+ _IMPLICIT_RE = re.compile(
38
+ r"^\s*IMPLICIT\s+(?P<rest>.+)$",
39
+ re.IGNORECASE,
40
+ )
41
+
42
+ # "DOUBLE PRECISION (A-H,O-Z)" / "INTEGER (I-N)" / "REAL*8 (A-H)"
43
+ _IMPLICIT_SPEC_RE = re.compile(
44
+ r"(?P<type>double\s+precision|real|integer|logical|character|complex)"
45
+ r"(?:\s*\*\s*\d+)?"
46
+ r"\s*\((?P<ranges>[^)]*)\)",
47
+ re.IGNORECASE,
48
+ )
49
+
50
+
51
+ def normalize_fixed_form(source: str) -> str:
52
+ """Collapse fixed-form source into one logical statement per line.
53
+
54
+ Strips comment lines, truncates the ignored column-73+ region, and joins
55
+ continuation lines onto the statement they continue.
56
+ """
57
+ logical_lines: list[str] = []
58
+
59
+ for raw in source.splitlines():
60
+ if not raw.strip():
61
+ continue
62
+
63
+ # A comment marker only counts in column 1 in fixed form.
64
+ if raw[0] in _COMMENT_CHARS:
65
+ continue
66
+
67
+ # Columns 73+ are sequence numbers, not code.
68
+ line = raw[:72]
69
+
70
+ is_continuation = len(line) > 5 and line[5] not in (" ", "0")
71
+ if is_continuation and logical_lines:
72
+ logical_lines[-1] += " " + line[6:].strip()
73
+ else:
74
+ # Drop the statement-label field so labels can't be mistaken for
75
+ # part of the statement.
76
+ logical_lines.append(line[6:].strip() if len(line) > 6 else line.strip())
77
+
78
+ return "\n".join(logical_lines)
79
+
80
+
81
+ def parse_implicit_map(source: str) -> dict[str, str]:
82
+ """Build first-letter -> Fortran base type from IMPLICIT statements.
83
+
84
+ `source` should already have INCLUDEs expanded, since that's usually
85
+ where the IMPLICIT statements live in legacy code.
86
+ """
87
+ implicit_map = dict(DEFAULT_IMPLICIT)
88
+
89
+ for line in source.splitlines():
90
+ match = _IMPLICIT_RE.match(line)
91
+ if not match:
92
+ continue
93
+
94
+ rest = match.group("rest")
95
+ if rest.strip().lower().startswith("none"):
96
+ # IMPLICIT NONE: every symbol must be declared explicitly, so an
97
+ # undeclared parameter is an error rather than a silent default.
98
+ return {}
99
+
100
+ for spec in _IMPLICIT_SPEC_RE.finditer(rest):
101
+ base_type = re.sub(r"\s+", " ", spec.group("type").strip().lower())
102
+ for part in spec.group("ranges").split(","):
103
+ part = part.strip().lower()
104
+ if not part:
105
+ continue
106
+ if "-" in part:
107
+ start, _, end = part.partition("-")
108
+ start, end = start.strip(), end.strip()
109
+ if len(start) == 1 and len(end) == 1:
110
+ for c in range(ord(start), ord(end) + 1):
111
+ implicit_map[chr(c)] = base_type
112
+ elif len(part) == 1:
113
+ implicit_map[part] = base_type
114
+
115
+ return implicit_map
116
+
117
+
118
+ def implicit_type_of(name: str, implicit_map: dict[str, str]) -> str | None:
119
+ """Fortran base type for `name` under these IMPLICIT rules, or None under IMPLICIT NONE."""
120
+ if not name:
121
+ return None
122
+ return implicit_map.get(name[0].lower())
123
+
124
+
125
+ # --- routine extraction -------------------------------------------------
126
+ #
127
+ # F77 routines end with a bare `END`, not `END FUNCTION <name>`, so the
128
+ # free-form block finder never matches them. Everything below works on
129
+ # already-normalized (comment-stripped, continuation-joined) source.
130
+
131
+ _ROUTINE_START_RE_TEMPLATE = (
132
+ r"^(?:(?P<prefix>[\w\s*]*?)\s+)?"
133
+ r"(?P<kind>SUBROUTINE|FUNCTION)\s+{name}\s*"
134
+ r"(?:\((?P<params>[^)]*)\))?\s*$"
135
+ )
136
+
137
+ _END_RE = re.compile(r"^END\s*$", re.IGNORECASE)
138
+
139
+ # Old-style declarations have no "::" — "DOUBLE PRECISION X, Y(10)".
140
+ #
141
+ # The length suffix has three legacy spellings and all three appear in real
142
+ # decks: `REAL*8 X`, `CHARACTER*8 NAME`, and `CHARACTER*(*) MSG` (assumed
143
+ # length, for a dummy argument). Matching only `*<digits>` left
144
+ # `CHARACTER*(*) MSG` unmatched, so MSG fell through to IMPLICIT typing and
145
+ # came back as an *integer* — a binding that compiles, runs, and passes a
146
+ # pointer where the callee expects a string descriptor.
147
+ # `character*32`, `character(len=32)`, `character(32)` — a DEFINITE length,
148
+ # which is what makes a CHARACTER output bindable. `character*(*)` and
149
+ # `character(len=*)` are assumed-length: f2py builds them and then silently
150
+ # returns b'' — measured. One pattern, used by BOTH Fortran backends, so the
151
+ # rule cannot drift between them.
152
+ CHAR_FIXED_LEN_RE = re.compile(r"^character(\*\d+|\(len=\d+\)|\(\d+\))$")
153
+
154
+ _LENGTH_SUFFIX = r"(?P<length>\s*\*\s*(?:\d+|\(\s*\*\s*\)))?"
155
+
156
+ _OLD_DECL_RE = re.compile(
157
+ r"^(?P<type>DOUBLE\s+PRECISION|REAL|INTEGER|LOGICAL|CHARACTER|COMPLEX)"
158
+ rf"{_LENGTH_SUFFIX}\s+"
159
+ r"(?!FUNCTION\b)(?P<names>[A-Z0-9_,()\s*]+)$",
160
+ re.IGNORECASE,
161
+ )
162
+
163
+ _DIMENSION_RE = re.compile(r"^DIMENSION\s+(?P<names>.+)$", re.IGNORECASE)
164
+
165
+
166
+ def _split_declarator_list(text: str) -> list[str]:
167
+ """Split "A(N), B, C(10,2)" on top-level commas only."""
168
+ items, depth, current = [], 0, ""
169
+ for ch in text:
170
+ if ch == "(":
171
+ depth += 1
172
+ elif ch == ")":
173
+ depth -= 1
174
+ if ch == "," and depth == 0:
175
+ items.append(current.strip())
176
+ current = ""
177
+ else:
178
+ current += ch
179
+ if current.strip():
180
+ items.append(current.strip())
181
+ return items
182
+
183
+
184
+ # --- documentation comments ---------------------------------------------
185
+ #
186
+ # The routine's comment header becomes `FunctionDef.doc`, which the generator
187
+ # turns into the wrapper's Python docstring — and that docstring is what
188
+ # FastAPI publishes as the route `description`, which FastMCP hands to a model
189
+ # as the MCP tool description. So this is not cosmetic: it is the only text a
190
+ # model gets about what a native routine actually computes.
191
+ #
192
+ # Both Fortran backends land here. The cleaning rules are the interesting part
193
+ # and they are dialect-independent, so they live in this module — shared —
194
+ # rather than being written twice and drifting. What each backend supplies is
195
+ # only the line span of the routine's opening statement: the fparser2 reader
196
+ # takes it from the parse tree, the regex reader recovers it with
197
+ # `find_header_lines`. The differential harness compares whole `ModuleIR`s, so
198
+ # any disagreement between the two is a test failure rather than a surprise in
199
+ # someone's tool description.
200
+ #
201
+ # Comments are read off the source TEXT, by line number, and not out of any
202
+ # parse structure. That is not laziness: measured on
203
+ # libraries/petro/fortran/pvtcor.f, the header sitting inside SUBROUTINE
204
+ # PVTINI is not a child of fparser2's `Specification_Part` at all — it is
205
+ # buried inside an `Implicit_Part`, because where a comment node lands depends
206
+ # on which grammar rule happened to be open when the reader reached it.
207
+ # Walking for that is a promise to re-learn fparser2's node placement every
208
+ # release, and it would give the regex reader nothing.
209
+
210
+ # A fixed-form comment is any line with C, c, *, or ! in column 1. 'D'/'d' in
211
+ # column 1 is a debug line, not a comment, and is deliberately not treated as
212
+ # documentation.
213
+ _FIXED_COMMENT_RE = re.compile(r"^[Cc*!]\s?(.*)$")
214
+ _FREE_COMMENT_RE = re.compile(r"^\s*!+\s?(.*)$")
215
+
216
+ # How far to look. A header longer than this is a change log or a table of
217
+ # COMMON block layouts, not a description, and pasting 200 lines of it into a
218
+ # tool description is actively harmful to the model reading it. Both limits
219
+ # are truncation points, not rejections: the first N lines of a long header
220
+ # are still the useful part.
221
+ _MAX_DOC_LINES = 30
222
+ _MAX_DOC_CHARS = 2000
223
+
224
+
225
+ # `preprocess.expand_includes` splices the include file in as a comment-marked
226
+ # block, and on the fixed-form path that text is what the reader (and so this
227
+ # module) sees. An INCLUDE almost always sits directly under the routine's
228
+ # header comment, so without this the include file's own banner — 8 lines
229
+ # about COMMON block ordering in PETRO.INC — is appended to the description of
230
+ # every routine in every deck that includes it. The marker line terminates the
231
+ # comment run, exactly as a line of code would.
232
+ _INCLUDE_MARKER_RE = re.compile(r"nativegate: (expanded|end) INCLUDE ")
233
+
234
+
235
+ def comment_text(line: str, fixed: bool) -> str | None:
236
+ """The prose in `line`, or None if `line` is not a comment line."""
237
+ if _INCLUDE_MARKER_RE.search(line):
238
+ return None
239
+ match = (_FIXED_COMMENT_RE if fixed else _FREE_COMMENT_RE).match(line)
240
+ if match is None:
241
+ # In fixed form a `!` can also open a comment after column 1 — most
242
+ # F77 that has been touched since 1990 has picked up bang comments.
243
+ if fixed:
244
+ stripped = line.lstrip()
245
+ if stripped.startswith("!"):
246
+ return stripped.lstrip("!").strip()
247
+ return None
248
+ return match.group(1).strip()
249
+
250
+
251
+ def is_banner(text: str) -> bool:
252
+ """True for a separator rule rather than prose.
253
+
254
+ Legacy decks are full of `C=======`, `C*******` and `C-------`. They carry
255
+ no information, and left in they dominate the tool description — the
256
+ PVTINI header in pvtcor.f is three lines of prose between two 71-character
257
+ rules. The test is "contains no letter or digit", which drops rules of any
258
+ punctuation character and any length while keeping anything with a word in
259
+ it (so `C--- 05-FEB-1996: FIXED DIVIDE BY ZERO` survives).
260
+ """
261
+ return not any(ch.isalnum() for ch in text)
262
+
263
+
264
+ def clean_doc(raw_lines: list[str], fixed: bool) -> str | None:
265
+ """Comment lines -> docstring prose, or None if nothing survives."""
266
+ kept: list[str] = []
267
+ for line in raw_lines:
268
+ text = comment_text(line, fixed)
269
+ if text is None:
270
+ continue
271
+ # Tabs are legal in fixed form and wreck alignment once the text is
272
+ # re-indented inside a generated docstring; a tab counts as a space.
273
+ text = text.replace("\t", " ").rstrip()
274
+ # Blank comment lines (`C` alone) and rules are dropped outright
275
+ # rather than kept as paragraph breaks: keeping them means a
276
+ # description that opens with an empty line more often than not.
277
+ if not text or is_banner(text):
278
+ continue
279
+ kept.append(text)
280
+ if len(kept) >= _MAX_DOC_LINES:
281
+ break
282
+
283
+ if not kept:
284
+ # No header, or a header that was pure ASCII art. Never invent text:
285
+ # the generator has its own truthful skeleton for that case.
286
+ return None
287
+
288
+ doc = "\n".join(kept)[:_MAX_DOC_CHARS].rstrip()
289
+
290
+ # Carriage returns from CRLF decks are dropped: they are invisible and make
291
+ # generated files diff badly. That is the ONLY character this function
292
+ # rewrites.
293
+ #
294
+ # In particular it does NOT neutralise a `\"\"\"` or a trailing backslash,
295
+ # even though both would break the docstring this text is interpolated
296
+ # into. Doing so here was tried and reverted: it is lossy, and it rewrites
297
+ # a routine's documentation to suit ONE consumer's quoting rules. The IR
298
+ # promises the deck's own words, and every other consumer — JSON, an
299
+ # OpenAPI description, a future doc site — inherits the damage. Escaping
300
+ # belongs to the layer that owns the quoting context, so it lives in
301
+ # generators/python_pkg_gen._docstring_literal, which emits repr() for
302
+ # anything carrying a quote, a backslash or a control character and is
303
+ # tested against hostile text in tests/test_endpoint_docstrings.py. The
304
+ # C++ parser's _sanitise_doc made the same call, for the same reason.
305
+ doc = doc.replace("\r", "")
306
+ return doc or None
307
+
308
+
309
+
310
+ def doc_comment_at(
311
+ source: str, first_line: int, last_line: int, fixed: bool
312
+ ) -> str | None:
313
+ """The comment header attached to a routine, cleaned, or None.
314
+
315
+ `first_line`/`last_line` are 1-based line numbers of the routine's opening
316
+ SUBROUTINE/FUNCTION statement in `source` (equal for a statement that is
317
+ not continued). Two positions are conventional and both are read:
318
+
319
+ * *inside* the routine, immediately after the opening statement —
320
+ overwhelmingly the F77 style, and what every deck in
321
+ libraries/petro/fortran does;
322
+ * *above* the routine — the free-form style.
323
+
324
+ The inside block wins when both are present. Above-the-routine comments
325
+ are the riskier source: the run above a routine also picks up the previous
326
+ routine's trailing notes and the file banner, because a blank line is not
327
+ required between program units and legacy decks rarely leave one. Both
328
+ runs stop at the first line that is not a comment, so neither can walk
329
+ past the nearest piece of code.
330
+
331
+ Both Fortran backends call this, on the same text, so the doc they put in
332
+ the IR is identical by construction — the differential harness in
333
+ tests/test_fortran_fparser.py compares whole `ModuleIR`s, so a doc the two
334
+ readers disagreed about would be a parity failure.
335
+ """
336
+ lines = source.splitlines()
337
+ if not (1 <= first_line <= len(lines)):
338
+ return None
339
+
340
+ # Inside: the contiguous comment run starting on the line after the
341
+ # opening statement. Anchored on `last_line`, so a continued statement
342
+ # (`SUBROUTINE FOO(A,` / ` * B)`) is skipped whole.
343
+ inside: list[str] = []
344
+ idx = last_line
345
+ while idx < len(lines) and comment_text(lines[idx], fixed) is not None:
346
+ inside.append(lines[idx])
347
+ idx += 1
348
+ doc = clean_doc(inside, fixed)
349
+ if doc is not None:
350
+ return doc
351
+
352
+ # Above: the contiguous comment run ending on the line before the opening
353
+ # statement, walked backwards and put back in source order.
354
+ above: list[str] = []
355
+ idx = first_line - 2
356
+ while idx >= 0 and comment_text(lines[idx], fixed) is not None:
357
+ above.append(lines[idx])
358
+ idx -= 1
359
+ return clean_doc(list(reversed(above)), fixed)
360
+
361
+
362
+ def _logical_lines_with_spans(source: str, fixed: bool) -> list[tuple[str, int, int]]:
363
+ """(statement, first_line, last_line) for every logical line in RAW source.
364
+
365
+ A local, span-preserving re-run of what `normalize_fixed_form` /
366
+ `fortran_regex.normalize_free_form` already do. Those two throw the line
367
+ numbers away — reasonably, since nothing else needed them — and the
368
+ comment header is found by line number, so the join is repeated here with
369
+ the mapping kept. Comment and blank lines are dropped, exactly as they are
370
+ there, which is what stops a commented-out header from being matched.
371
+
372
+ Deliberately not a full normalisation: string literals are not tracked and
373
+ inline comments are not stripped, because the only consumer is a routine
374
+ header match and neither can appear in one.
375
+ """
376
+ out: list[tuple[str, int, int]] = []
377
+ lines = source.splitlines()
378
+ for i, raw in enumerate(lines):
379
+ if not raw.strip():
380
+ continue
381
+ if fixed:
382
+ if raw[0] in _COMMENT_CHARS:
383
+ continue
384
+ # Columns 1-6 are label and continuation, 73+ the sequence number.
385
+ body = raw[:72]
386
+ continues = len(body) > 5 and body[5] not in (" ", "0")
387
+ text = (body[6:] if len(body) > 6 else "").strip()
388
+ if continues and out:
389
+ stmt, first, _ = out[-1]
390
+ out[-1] = (stmt + " " + text, first, i + 1)
391
+ continue
392
+ out.append((text, i + 1, i + 1))
393
+ else:
394
+ if raw.lstrip().startswith("!"):
395
+ continue
396
+ text = raw.strip()
397
+ # A free-form statement continues when the PREVIOUS line ended
398
+ # with `&`; a leading `&` on this one only marks where it resumes.
399
+ if out and out[-1][0].endswith("&"):
400
+ stmt, first, _ = out[-1]
401
+ resumed = text[1:] if text.startswith("&") else text
402
+ out[-1] = (stmt[:-1] + " " + resumed, first, i + 1)
403
+ continue
404
+ out.append((text, i + 1, i + 1))
405
+ return out
406
+
407
+
408
+ def find_header_lines(
409
+ source: str, name: str, fixed: bool = True
410
+ ) -> tuple[int, int] | None:
411
+ """1-based line span of `name`'s opening statement in RAW source.
412
+
413
+ The fparser2 backend gets this for free from the parse tree's line spans.
414
+ The regex backend does not: it works off normalised text — comments
415
+ stripped, continuations joined — whose line numbers no longer correspond
416
+ to the file, and the comment header is precisely what normalisation threw
417
+ away. So the raw text is scanned once more here.
418
+
419
+ The span covers the whole statement including its continuation lines, so
420
+ the "inside" comment run starts after the header however it was wrapped —
421
+ `SUBROUTINE NODAL (PRAVG, QMAX, ...` in wellib.f wraps over three lines
422
+ and its header comment is under the third.
423
+ """
424
+ if fixed:
425
+ start_re = re.compile(
426
+ _ROUTINE_START_RE_TEMPLATE.format(name=re.escape(name)), re.IGNORECASE
427
+ )
428
+ else:
429
+ # Free form: same shape, but a `result(...)` suffix may follow the
430
+ # argument list, so the statement is not required to end after it.
431
+ start_re = re.compile(
432
+ r"^[\w\s*(),=]*?\b(?:SUBROUTINE|FUNCTION)\s+"
433
+ + re.escape(name)
434
+ + r"\s*(?:\(|$)",
435
+ re.IGNORECASE,
436
+ )
437
+
438
+ for stmt, first, last in _logical_lines_with_spans(source, fixed):
439
+ if start_re.match(stmt.strip()):
440
+ return first, last
441
+ return None
442
+
443
+
444
+
445
+ def find_routine(normalized: str, name: str) -> dict | None:
446
+ """Locate one routine in normalized fixed-form source.
447
+
448
+ Returns {kind, params, body, declared_types, arrays, prefix} or None.
449
+ """
450
+ start_re = re.compile(
451
+ _ROUTINE_START_RE_TEMPLATE.format(name=re.escape(name)), re.IGNORECASE
452
+ )
453
+
454
+ lines = normalized.splitlines()
455
+ start_idx = None
456
+ match = None
457
+ for i, line in enumerate(lines):
458
+ m = start_re.match(line.strip())
459
+ if m:
460
+ start_idx, match = i, m
461
+ break
462
+
463
+ if start_idx is None or match is None:
464
+ return None
465
+
466
+ body_lines: list[str] = []
467
+ for line in lines[start_idx + 1 :]:
468
+ if _END_RE.match(line.strip()):
469
+ break
470
+ body_lines.append(line.strip())
471
+
472
+ body = "\n".join(body_lines)
473
+
474
+ declared_types: dict[str, str] = {}
475
+ # CHARACTER length spellings ("character*32", "character*(*)"), kept
476
+ # separately because the LENGTH decides whether f2py can return the value
477
+ # — see the demotion in both backends.
478
+ char_lengths: dict[str, str] = {}
479
+ arrays: set[str] = set()
480
+
481
+ for line in body_lines:
482
+ decl = _OLD_DECL_RE.match(line)
483
+ if decl:
484
+ base = re.sub(r"\s+", " ", decl.group("type").strip().lower())
485
+ length = (decl.group("length") or "").replace(" ", "")
486
+ for item in _split_declarator_list(decl.group("names")):
487
+ var = item.split("(")[0].strip()
488
+ if not var or not var[0].isalpha():
489
+ continue
490
+ declared_types[var.lower()] = base
491
+ if base == "character":
492
+ char_lengths[var.lower()] = f"character{length}"
493
+ if "(" in item:
494
+ arrays.add(var.lower())
495
+ continue
496
+
497
+ dim = _DIMENSION_RE.match(line)
498
+ if dim:
499
+ for item in _split_declarator_list(dim.group("names")):
500
+ var = item.split("(")[0].strip()
501
+ if var and var[0].isalpha() and "(" in item:
502
+ arrays.add(var.lower())
503
+
504
+ params = [p.strip() for p in (match.group("params") or "").split(",") if p.strip()]
505
+
506
+ return {
507
+ "kind": match.group("kind").lower(),
508
+ "prefix": (match.group("prefix") or "").strip(),
509
+ "params": params,
510
+ "body": body,
511
+ "declared_types": declared_types,
512
+ "char_lengths": char_lengths,
513
+ "arrays": arrays,
514
+ }
515
+
516
+
517
+ # The argument list is optional: `SUBROUTINE TRNCAL` (no parentheses at all)
518
+ # is legal F77 and common for routines that work purely through COMMON.
519
+ # find_routine has always accepted it, so requiring "(" here made discovery
520
+ # disagree with extraction — the routine was invisible to `quickstart` but
521
+ # bound fine once you named it by hand.
522
+ _ROUTINE_NAME_RE = re.compile(
523
+ r"^(?:[\w\s*()]*?\s+)?(?:SUBROUTINE|FUNCTION)\s+(?P<name>\w+)\s*(?:\(|$)",
524
+ re.IGNORECASE,
525
+ )
526
+
527
+
528
+ # --- intent inference ---------------------------------------------------
529
+ #
530
+ # F77 has no `intent` attribute: every dummy argument is passed by reference
531
+ # and any of them may be an output. f2py, given no other information, treats
532
+ # scalars as intent(in) — so a routine like
533
+ #
534
+ # SUBROUTINE STEP (DTIN, DTOUT, ICONV)
535
+ #
536
+ # binds as `step(dtin, dtout, iconv)` returning None, and both results are
537
+ # silently discarded. Nothing crashes; the caller just never sees the answer.
538
+ #
539
+ # The intent is recoverable from the body: an argument that is assigned to is
540
+ # an output, and one that is read before it is assigned is also an input.
541
+ # That is what the rules below reconstruct.
542
+
543
+ # Statement keywords that are never assignments. `IF` is handled separately
544
+ # because a logical IF can carry an assignment as its statement.
545
+ _NON_ASSIGNMENT_KEYWORDS = frozenset(
546
+ {
547
+ "IF", "THEN", "ELSE", "ELSEIF", "END", "ENDIF", "DO", "ENDDO",
548
+ "CALL", "RETURN", "GOTO", "GO", "CONTINUE", "STOP", "PAUSE",
549
+ "WRITE", "READ", "PRINT", "FORMAT", "DATA", "COMMON", "DIMENSION",
550
+ "PARAMETER", "EXTERNAL", "INTRINSIC", "SAVE", "IMPLICIT", "INCLUDE",
551
+ "EQUIVALENCE", "ENTRY", "SUBROUTINE", "FUNCTION", "PROGRAM", "BLOCK",
552
+ "REAL", "INTEGER", "LOGICAL", "CHARACTER", "COMPLEX", "DOUBLE",
553
+ }
554
+ )
555
+
556
+ # Statements that mention an argument without using its value. Treating a
557
+ # declaration as a read makes every declared output look like an input.
558
+ _DECLARATION_KEYWORDS = frozenset(
559
+ {
560
+ "REAL", "INTEGER", "LOGICAL", "CHARACTER", "COMPLEX", "DOUBLE",
561
+ "DIMENSION", "COMMON", "DATA", "SAVE", "EXTERNAL", "INTRINSIC",
562
+ "PARAMETER", "IMPLICIT", "INCLUDE", "EQUIVALENCE", "FORMAT",
563
+ }
564
+ )
565
+
566
+ _LOGICAL_IF_RE = re.compile(r"^IF\s*\(", re.IGNORECASE)
567
+
568
+ # "X = ..." or "X(I,J) = ..." but not "X == ..." or ".EQ."
569
+ _ASSIGNMENT_RE = re.compile(r"^(?P<target>\w+)\s*(?:\((?P<subscript>.*)\))?\s*=(?!=)")
570
+
571
+ _CALL_RE = re.compile(r"^CALL\s+(?P<name>\w+)\s*(?:\((?P<args>.*)\))?\s*$", re.IGNORECASE)
572
+
573
+ _READ_RE = re.compile(r"^READ\s*(?:\([^)]*\)|\s*\S+\s*,)\s*(?P<targets>.*)$", re.IGNORECASE)
574
+
575
+
576
+ def _split_logical_if(statement: str) -> tuple[str, str]:
577
+ """Split `IF (cond) stmt` into (condition, guarded statement).
578
+
579
+ Both halves matter: `IF (N .LT. 1) N = 20` reads N in the condition and
580
+ writes it in the guarded statement, which together make it inout. Looking
581
+ at either half alone gets the intent wrong.
582
+
583
+ `IF (...) THEN` opens a block, so it has no guarded statement — only a
584
+ condition.
585
+ """
586
+ depth = 0
587
+ for i, ch in enumerate(statement):
588
+ if ch == "(":
589
+ depth += 1
590
+ elif ch == ")":
591
+ depth -= 1
592
+ if depth == 0:
593
+ condition = statement[: i + 1]
594
+ tail = statement[i + 1 :].strip()
595
+ if tail.upper() == "THEN":
596
+ return condition, ""
597
+ return condition, tail
598
+ return statement, ""
599
+
600
+
601
+ def _mentions(name: str, text: str) -> bool:
602
+ return re.search(rf"\b{re.escape(name)}\b", text, re.IGNORECASE) is not None
603
+
604
+
605
+ def _callee_intents(
606
+ callee: str, normalized: str | None, seen: frozenset[str]
607
+ ) -> dict[str, str] | None:
608
+ """Intents of a CALLed routine, if it lives in the same source.
609
+
610
+ Without this, every argument handed to a CALL has to be assumed both read
611
+ and written. `CALL PVTSET (P)` would make BBDPDZ's P an output even though
612
+ PVTSET only reads it — technically safe, but it clutters the generated
613
+ signature with returns that are really inputs. Resolving the callee turns
614
+ the guess into an answer.
615
+ """
616
+ if normalized is None or callee.lower() in seen:
617
+ return None
618
+ routine = find_routine(normalized, callee)
619
+ if routine is None:
620
+ return None
621
+ return infer_intents(routine, normalized, seen | {callee.lower()})
622
+
623
+
624
+ def _actual_argument_names(args: str) -> list[str | None]:
625
+ """Positional actual arguments, or None where the argument is an
626
+ expression (`P+1`, `A(I)`, `2.0D0`) and therefore cannot be an output."""
627
+ names: list[str | None] = []
628
+ for arg in _split_declarator_list(args):
629
+ arg = arg.strip()
630
+ names.append(arg.lower() if re.fullmatch(r"\w+", arg) else None)
631
+ return names
632
+
633
+
634
+ def infer_intents(
635
+ routine: dict,
636
+ normalized: str | None = None,
637
+ _seen: frozenset[str] = frozenset(),
638
+ statement_functions: frozenset[str] = frozenset(),
639
+ ) -> dict[str, str]:
640
+ """Infer "in" / "out" / "inout" for each dummy argument of `routine`.
641
+
642
+ Rules, in order of application to each statement of the body:
643
+
644
+ * assigned to -> output
645
+ * read before assigned -> also an input, so "inout"
646
+ * passed to a CALL -> conservatively both: the callee may read it
647
+ and may write it, so an argument handed to a
648
+ CALL before being assigned is "inout"
649
+ * never assigned -> "in"
650
+
651
+ Conservative by construction: when a use is ambiguous the argument is
652
+ widened to "inout", which keeps it in the Python signature. The failure
653
+ mode of guessing "out" is a caller that cannot pass a value it needs to
654
+ pass; the failure mode of guessing "inout" is a redundant argument.
655
+ """
656
+ params = [p.lower() for p in routine["params"]]
657
+ if not params:
658
+ return {}
659
+
660
+ written: set[str] = set()
661
+ read_before_write: set[str] = set()
662
+
663
+ def note_reads(text: str) -> None:
664
+ for param in params:
665
+ if param not in written and _mentions(param, text):
666
+ read_before_write.add(param)
667
+
668
+ for raw_statement in routine["body"].splitlines():
669
+ statement = raw_statement.strip()
670
+ if not statement:
671
+ continue
672
+
673
+ keyword = re.match(r"^(\w+)", statement)
674
+ keyword_text = keyword.group(1).upper() if keyword else ""
675
+
676
+ # Declarations mention arguments without using them. Counting
677
+ # `DOUBLE PRECISION X` or `DIMENSION A(N)` as a read makes every
678
+ # declared output look like an input.
679
+ if keyword_text in _DECLARATION_KEYWORDS:
680
+ continue
681
+
682
+ # A logical IF is two things: a condition (reads) and a guarded
683
+ # statement (which may assign). `IF (N .LT. 1) N = 20` both reads and
684
+ # writes N, and dropping the condition loses the read.
685
+ if _LOGICAL_IF_RE.match(statement):
686
+ condition, guarded = _split_logical_if(statement)
687
+ note_reads(condition)
688
+ if not guarded:
689
+ continue
690
+ statement = guarded
691
+ keyword = re.match(r"^(\w+)", statement)
692
+ keyword_text = keyword.group(1).upper() if keyword else ""
693
+
694
+ if keyword_text == "READ":
695
+ read_targets = _READ_RE.match(statement)
696
+ if read_targets:
697
+ for param in params:
698
+ if _mentions(param, read_targets.group("targets")):
699
+ written.add(param)
700
+ continue
701
+
702
+ call = _CALL_RE.match(statement)
703
+ if call:
704
+ args = call.group("args") or ""
705
+ callee = _callee_intents(call.group("name"), normalized, _seen)
706
+
707
+ if callee is None:
708
+ # Callee not available: pass-by-reference means it may read
709
+ # and may write every argument, so widen conservatively.
710
+ note_reads(args)
711
+ for param in params:
712
+ if _mentions(param, args):
713
+ written.add(param)
714
+ continue
715
+
716
+ # Resolved: map actual arguments onto the callee's intents.
717
+ callee_intents = list(callee.values())
718
+ actuals = _actual_argument_names(args)
719
+ for position, actual in enumerate(actuals):
720
+ intent = (
721
+ callee_intents[position]
722
+ if position < len(callee_intents)
723
+ else "inout"
724
+ )
725
+ if actual is None or actual not in params:
726
+ continue
727
+ if intent in ("in", "inout"):
728
+ note_reads(actual)
729
+ if intent in ("out", "inout"):
730
+ written.add(actual)
731
+
732
+ # Arguments passed inside expressions are reads regardless.
733
+ for position, actual in enumerate(actuals):
734
+ if actual is None:
735
+ note_reads(_split_declarator_list(args)[position])
736
+ continue
737
+
738
+ if keyword_text not in _NON_ASSIGNMENT_KEYWORDS:
739
+ assignment = _ASSIGNMENT_RE.match(statement)
740
+ if assignment:
741
+ target = assignment.group("target").lower()
742
+ # `F(X) = X*2.0*B` where F is a STATEMENT FUNCTION is a
743
+ # declaration wearing an assignment's clothes: nothing
744
+ # executes here, so nothing is read. Counting its RHS as a
745
+ # read marks a pure-output dummy (B above) as "inout" —
746
+ # widened, so never a wrong answer, but a signature demanding
747
+ # a value the routine never consumes. The names come from the
748
+ # fparser2 tree, which recognises Stmt_Function_Stmt exactly.
749
+ if target in statement_functions:
750
+ continue
751
+ rest = statement[assignment.end() :]
752
+ if assignment.group("subscript"):
753
+ # A subscript is a read of whatever indexes it, and an
754
+ # element assignment does not define the whole array.
755
+ rest = assignment.group("subscript") + " " + rest
756
+ note_reads(rest)
757
+ if target in params:
758
+ written.add(target)
759
+ continue
760
+
761
+ note_reads(statement)
762
+
763
+ intents = {}
764
+ for param in params:
765
+ if param not in written:
766
+ intents[param] = "in"
767
+ elif param in read_before_write:
768
+ intents[param] = "inout"
769
+ else:
770
+ intents[param] = "out"
771
+ return intents
772
+
773
+
774
+ def list_routine_names(normalized: str) -> list[str]:
775
+ """Every SUBROUTINE/FUNCTION name in normalized fixed-form source."""
776
+ return [
777
+ m.group("name")
778
+ for m in (_ROUTINE_NAME_RE.match(line.strip()) for line in normalized.splitlines())
779
+ if m
780
+ ]
781
+
782
+
783
+ # --- f2py directive injection -------------------------------------------
784
+
785
+ # f2py reads `Cf2py`/`cf2py` comment lines in fixed-form source as signature
786
+ # directives. Writing the inferred intents there is what actually makes the
787
+ # outputs come back to Python; the IR alone changes nothing about the build.
788
+ #
789
+ # Mapping, and why:
790
+ # scalar out -> intent(out) dropped from the call, returned
791
+ # scalar inout -> intent(in,out) passed in, returned (value semantics)
792
+ # array either -> intent(in,out) the caller supplies the storage
793
+ #
794
+ # Arrays never get a bare intent(out): they are routinely dimensioned by a
795
+ # PARAMETER from an INCLUDE deck (`AE(MXCELL)`), and asking f2py to allocate
796
+ # one means allocating MXCELL elements per call. Requiring the caller to pass
797
+ # the array keeps the size where the caller can see it.
798
+
799
+ _DIRECTIVE_PREFIX = "Cf2py"
800
+
801
+
802
+ def _f2py_intent(intent: str, is_array: bool) -> str | None:
803
+ if intent == "in":
804
+ return None
805
+ if is_array:
806
+ return "in,out"
807
+ return "out" if intent == "out" else "in,out"
808
+
809
+
810
+ def _routine_statement_end(lines: list[str], start: int) -> int:
811
+ """Index of the last physical line of the routine's opening statement.
812
+
813
+ A continued SUBROUTINE statement spans several lines; the directives have
814
+ to go after all of them or they land in the middle of the argument list.
815
+ """
816
+ index = start
817
+ while index + 1 < len(lines):
818
+ nxt = lines[index + 1]
819
+ if nxt[:1] in _COMMENT_CHARS or not nxt.strip():
820
+ break
821
+ if len(nxt) > 5 and nxt[5] not in (" ", "0"):
822
+ index += 1
823
+ continue
824
+ break
825
+ return index
826
+
827
+
828
+ def inject_intent_directives(
829
+ source: str, routines: dict[str, dict[str, tuple[str, bool]]]
830
+ ) -> str:
831
+ """Insert `Cf2py intent(...)` lines into fixed-form `source`.
832
+
833
+ `routines` maps routine name -> {argument: (intent, is_array)}. Applied to
834
+ a generated copy, never to the original file.
835
+ """
836
+ lines = source.splitlines()
837
+ insertions: dict[int, list[str]] = {}
838
+
839
+ for name, arguments in routines.items():
840
+ directives = []
841
+ for argument, (intent, is_array) in arguments.items():
842
+ spec = _f2py_intent(intent, is_array)
843
+ if spec:
844
+ directives.append(f"{_DIRECTIVE_PREFIX} intent({spec}) {argument}")
845
+ if not directives:
846
+ continue
847
+
848
+ start_re = re.compile(
849
+ rf"^\s*(?:[\w*()]+\s+)*(?:SUBROUTINE|FUNCTION)\s+{re.escape(name)}\b",
850
+ re.IGNORECASE,
851
+ )
852
+ for index, line in enumerate(lines):
853
+ if line[:1] in _COMMENT_CHARS or not line.strip():
854
+ continue
855
+ # Skip the label field so a statement label can't shift the match.
856
+ statement = line[6:] if len(line) > 6 and line[5] in (" ", "0") else line
857
+ if start_re.match(statement):
858
+ insertions[_routine_statement_end(lines, index)] = directives
859
+ break
860
+
861
+ if not insertions:
862
+ return source
863
+
864
+ out: list[str] = []
865
+ for index, line in enumerate(lines):
866
+ out.append(line)
867
+ out.extend(insertions.get(index, []))
868
+ return "\n".join(out) + "\n"