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.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- 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"
|