gelang 0.1.0
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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +535 -0
- package/bin/ge.js +112 -0
- package/package.json +62 -0
- package/python/pyeffic/__init__.py +9 -0
- package/python/pyeffic/__main__.py +6 -0
- package/python/pyeffic/analyzer.py +464 -0
- package/python/pyeffic/apisurface.py +238 -0
- package/python/pyeffic/autoselect.py +327 -0
- package/python/pyeffic/backends.py +87 -0
- package/python/pyeffic/bench.py +233 -0
- package/python/pyeffic/cli.py +184 -0
- package/python/pyeffic/compiler.py +421 -0
- package/python/pyeffic/config.py +383 -0
- package/python/pyeffic/dartgen.py +441 -0
- package/python/pyeffic/deploy.py +586 -0
- package/python/pyeffic/diagnostics.py +194 -0
- package/python/pyeffic/difftest.py +424 -0
- package/python/pyeffic/downloader.py +307 -0
- package/python/pyeffic/emitters/__init__.py +11 -0
- package/python/pyeffic/emitters/base.py +2359 -0
- package/python/pyeffic/emitters/cpp.py +266 -0
- package/python/pyeffic/emitters/csharp.py +342 -0
- package/python/pyeffic/emitters/dart.py +349 -0
- package/python/pyeffic/emitters/go.py +388 -0
- package/python/pyeffic/emitters/kotlin.py +314 -0
- package/python/pyeffic/emitters/rust.py +314 -0
- package/python/pyeffic/emitters/zig.py +411 -0
- package/python/pyeffic/ffi.py +49 -0
- package/python/pyeffic/frontends/__init__.py +94 -0
- package/python/pyeffic/frontends/hybrid.py +709 -0
- package/python/pyeffic/frontends/typescript.py +965 -0
- package/python/pyeffic/ge_cli.py +1148 -0
- package/python/pyeffic/golden.py +348 -0
- package/python/pyeffic/idents.py +206 -0
- package/python/pyeffic/modules.py +220 -0
- package/python/pyeffic/packer.py +222 -0
- package/python/pyeffic/pipeline.py +797 -0
- package/python/pyeffic/reactgen.py +966 -0
- package/python/pyeffic/researcher.py +177 -0
- package/python/pyeffic/scaffold.py +397 -0
- package/python/pyeffic/stdlib.py +246 -0
- package/python/pyeffic/styling.py +220 -0
- package/python/pyeffic/templates/desktop_gui/README.md +106 -0
- package/python/pyeffic/templates/desktop_gui/app/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/core/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/core/add.ge.py +13 -0
- package/python/pyeffic/templates/desktop_gui/app/core/factorial.ge.py +20 -0
- package/python/pyeffic/templates/desktop_gui/app/core/fibonacci.ge.py +25 -0
- package/python/pyeffic/templates/desktop_gui/app/core/gcd.ge.py +19 -0
- package/python/pyeffic/templates/desktop_gui/app/core/is_prime.ge.py +24 -0
- package/python/pyeffic/templates/desktop_gui/app/core/multiply.ge.py +13 -0
- package/python/pyeffic/templates/desktop_gui/app/core/power.ge.py +25 -0
- package/python/pyeffic/templates/desktop_gui/app/main.ge.py +49 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/buffer.ge.py +26 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/limits.ge.py +47 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/state.ge.py +44 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/layout.ge.py +64 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/render.ge.py +87 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/theme.ge.py +147 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/widgets.ge.py +105 -0
- package/python/pyeffic/templates/desktop_gui/desktop/__init__.py +1 -0
- package/python/pyeffic/templates/desktop_gui/desktop/main.ge.py +258 -0
- package/python/pyeffic/templates/desktop_gui/ge.toml +16 -0
- package/python/pyeffic/templates/desktop_gui/tests/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/tests/ge_loader.py +76 -0
- package/python/pyeffic/templates/desktop_gui/tests/test_app.py +173 -0
- package/python/pyeffic/templates/web_react/README.md +115 -0
- package/python/pyeffic/templates/web_react/app/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/core/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/core/add.ge.py +9 -0
- package/python/pyeffic/templates/web_react/app/core/factorial.ge.py +16 -0
- package/python/pyeffic/templates/web_react/app/core/fibonacci.ge.py +21 -0
- package/python/pyeffic/templates/web_react/app/core/is_prime.ge.py +20 -0
- package/python/pyeffic/templates/web_react/app/core/multiply.ge.py +9 -0
- package/python/pyeffic/templates/web_react/app/main.ge.py +25 -0
- package/python/pyeffic/templates/web_react/app/memory/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/memory/buffer.ge.py +25 -0
- package/python/pyeffic/templates/web_react/app/memory/limits.ge.py +51 -0
- package/python/pyeffic/templates/web_react/ge.toml +23 -0
- package/python/pyeffic/templates/web_react/tests/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/tests/ge_loader.py +68 -0
- package/python/pyeffic/templates/web_react/tests/test_app.py +105 -0
- package/python/pyeffic/templates/web_react/ui/main.ge.ui +33 -0
- package/python/pyeffic/templates/web_react/web/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/web/server.ge.py +78 -0
- package/python/pyeffic/ts2py.py +657 -0
- package/python/pyeffic/typecheck.py +232 -0
- package/python/pyeffic/ui.py +154 -0
- package/python/pyeffic/ui_dsl.py +618 -0
- package/python/pyeffic/widgets.py +87 -0
- package/scripts/README.md +42 -0
- package/scripts/check-toolchains.py +85 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""Hybrid GE frontend — one `.ge` file, both flavours.
|
|
2
|
+
|
|
3
|
+
A `.ge` file may mix Python-flavoured and TypeScript-flavoured definitions.
|
|
4
|
+
The compiler splits the file into top-level chunks, decides each chunk's
|
|
5
|
+
flavour from its syntax, lowers the TypeScript chunks, and concatenates
|
|
6
|
+
everything into one Python source for the shared IR.
|
|
7
|
+
|
|
8
|
+
main.ge
|
|
9
|
+
|
|
|
10
|
+
+-- def compute(x: int) -> int: <- python chunk, kept as-is
|
|
11
|
+
| return x * 2
|
|
12
|
+
|
|
|
13
|
+
+-- export function render(): number { <- typescript chunk
|
|
14
|
+
| return compute(3); lowered to python
|
|
15
|
+
| }
|
|
16
|
+
|
|
|
17
|
+
v
|
|
18
|
+
one python source -> shared IR -> every backend
|
|
19
|
+
|
|
20
|
+
Chunk classification (first significant token of the chunk):
|
|
21
|
+
|
|
22
|
+
python def / async def / from X import / import X / class
|
|
23
|
+
ge_preamble( / ge_inline( / ge_raw(
|
|
24
|
+
NAME: type = value
|
|
25
|
+
typescript function / export ... / let / const / var
|
|
26
|
+
interface / type / import { .. } from ".."
|
|
27
|
+
gePreamble( / geInline( / geRaw(
|
|
28
|
+
|
|
29
|
+
Explicit override, for the rare ambiguous case: put a marker comment on the
|
|
30
|
+
line above the chunk.
|
|
31
|
+
|
|
32
|
+
# ge:typescript
|
|
33
|
+
const SCALE: number = 2;
|
|
34
|
+
|
|
35
|
+
// ge:python
|
|
36
|
+
def helper(x: int) -> int:
|
|
37
|
+
return x
|
|
38
|
+
|
|
39
|
+
Anything the classifier gets wrong can be pinned this way, so auto-detection
|
|
40
|
+
is always escapable.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import ast
|
|
45
|
+
import re
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
|
|
48
|
+
from .typescript import TypeScriptSyntaxError, ts_to_python
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Line scanning (shared by the splitter)
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
#: string/comment states the scanner can be in at the end of a line
|
|
55
|
+
_NORMAL = "normal"
|
|
56
|
+
_PY_SINGLE = "py_single"
|
|
57
|
+
_PY_DOUBLE = "py_double"
|
|
58
|
+
_PY_TRIPLE_SINGLE = "py_triple_single"
|
|
59
|
+
_PY_TRIPLE_DOUBLE = "py_triple_double"
|
|
60
|
+
_TS_SINGLE = "ts_single"
|
|
61
|
+
_TS_DOUBLE = "ts_double"
|
|
62
|
+
_TS_TEMPLATE = "ts_template"
|
|
63
|
+
_BLOCK_COMMENT = "block_comment"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class LineScan:
|
|
68
|
+
"""What a single line contributes at top level."""
|
|
69
|
+
state: str
|
|
70
|
+
braces: int # { minus }
|
|
71
|
+
brackets: int # ( [ minus ) ]
|
|
72
|
+
colon_at_depth0: int # index of the last top-level ':' or -1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _scan_line(line: str, state: str) -> LineScan:
|
|
76
|
+
"""Walk one line, tracking string state and top-level bracket depth."""
|
|
77
|
+
braces = 0
|
|
78
|
+
brackets = 0
|
|
79
|
+
last_colon = -1
|
|
80
|
+
depth = 0
|
|
81
|
+
i = 0
|
|
82
|
+
n = len(line)
|
|
83
|
+
|
|
84
|
+
while i < n:
|
|
85
|
+
c = line[i]
|
|
86
|
+
nxt = line[i + 1] if i + 1 < n else ""
|
|
87
|
+
|
|
88
|
+
if state == _BLOCK_COMMENT:
|
|
89
|
+
if c == "*" and nxt == "/":
|
|
90
|
+
state = _NORMAL
|
|
91
|
+
i += 2
|
|
92
|
+
continue
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
if state == _PY_TRIPLE_DOUBLE:
|
|
97
|
+
if line.startswith('"""', i):
|
|
98
|
+
state = _NORMAL
|
|
99
|
+
i += 3
|
|
100
|
+
continue
|
|
101
|
+
i += 1
|
|
102
|
+
continue
|
|
103
|
+
if state == _PY_TRIPLE_SINGLE:
|
|
104
|
+
if line.startswith("'''", i):
|
|
105
|
+
state = _NORMAL
|
|
106
|
+
i += 3
|
|
107
|
+
continue
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if state in (_PY_SINGLE, _TS_SINGLE):
|
|
112
|
+
if c == "\\" and nxt:
|
|
113
|
+
i += 2
|
|
114
|
+
continue
|
|
115
|
+
if c == "'":
|
|
116
|
+
state = _NORMAL
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
if state in (_PY_DOUBLE, _TS_DOUBLE):
|
|
120
|
+
if c == "\\" and nxt:
|
|
121
|
+
i += 2
|
|
122
|
+
continue
|
|
123
|
+
if c == '"':
|
|
124
|
+
state = _NORMAL
|
|
125
|
+
i += 1
|
|
126
|
+
continue
|
|
127
|
+
if state == _TS_TEMPLATE:
|
|
128
|
+
if c == "\\" and nxt:
|
|
129
|
+
i += 2
|
|
130
|
+
continue
|
|
131
|
+
if c == "`":
|
|
132
|
+
state = _NORMAL
|
|
133
|
+
i += 1
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# ---- normal state ----
|
|
137
|
+
if c == "#":
|
|
138
|
+
break # python comment runs to end of line
|
|
139
|
+
if c == "/" and nxt == "/":
|
|
140
|
+
break # ts line comment
|
|
141
|
+
if c == "/" and nxt == "*":
|
|
142
|
+
state = _BLOCK_COMMENT
|
|
143
|
+
i += 2
|
|
144
|
+
continue
|
|
145
|
+
if line.startswith('"""', i):
|
|
146
|
+
state = _PY_TRIPLE_DOUBLE
|
|
147
|
+
i += 3
|
|
148
|
+
continue
|
|
149
|
+
if line.startswith("'''", i):
|
|
150
|
+
state = _PY_TRIPLE_SINGLE
|
|
151
|
+
i += 3
|
|
152
|
+
continue
|
|
153
|
+
if c == '"':
|
|
154
|
+
state = _PY_DOUBLE
|
|
155
|
+
i += 1
|
|
156
|
+
continue
|
|
157
|
+
if c == "'":
|
|
158
|
+
state = _PY_SINGLE
|
|
159
|
+
i += 1
|
|
160
|
+
continue
|
|
161
|
+
if c == "`":
|
|
162
|
+
state = _TS_TEMPLATE
|
|
163
|
+
i += 1
|
|
164
|
+
continue
|
|
165
|
+
if c == "{":
|
|
166
|
+
braces += 1
|
|
167
|
+
depth += 1
|
|
168
|
+
i += 1
|
|
169
|
+
continue
|
|
170
|
+
if c == "}":
|
|
171
|
+
braces -= 1
|
|
172
|
+
depth -= 1
|
|
173
|
+
i += 1
|
|
174
|
+
continue
|
|
175
|
+
if c in "([":
|
|
176
|
+
brackets += 1
|
|
177
|
+
depth += 1
|
|
178
|
+
i += 1
|
|
179
|
+
continue
|
|
180
|
+
if c in ")]":
|
|
181
|
+
brackets -= 1
|
|
182
|
+
depth -= 1
|
|
183
|
+
i += 1
|
|
184
|
+
continue
|
|
185
|
+
if c == ":" and depth == 0:
|
|
186
|
+
last_colon = i
|
|
187
|
+
i += 1
|
|
188
|
+
continue
|
|
189
|
+
i += 1
|
|
190
|
+
|
|
191
|
+
return LineScan(state, braces, brackets, last_colon)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Chunk model
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class Chunk:
|
|
200
|
+
flavour: str # "python" | "typescript"
|
|
201
|
+
text: str
|
|
202
|
+
start_line: int
|
|
203
|
+
explicit: bool = False
|
|
204
|
+
#: name from a `<name> ... </name>` block, or None for a bare chunk
|
|
205
|
+
block_name: str | None = None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Named blocks: <name> ... </name>
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
#
|
|
212
|
+
# A block gives a chunk a name and makes it callable from anywhere else in
|
|
213
|
+
# the file (or from another file that imports it):
|
|
214
|
+
#
|
|
215
|
+
# <add>
|
|
216
|
+
# def add(a: int, b: int) -> int:
|
|
217
|
+
# return a + b
|
|
218
|
+
# </add>
|
|
219
|
+
#
|
|
220
|
+
# <render:typescript>
|
|
221
|
+
# export function render(x: number): number {
|
|
222
|
+
# return @add(x, 1); // @name(...) calls the block
|
|
223
|
+
# }
|
|
224
|
+
# </render>
|
|
225
|
+
#
|
|
226
|
+
# The language is detected from the content, or pinned with `:python` /
|
|
227
|
+
# `:typescript` (aliases `:py` / `:ts`).
|
|
228
|
+
|
|
229
|
+
_BLOCK_OPEN_RE = re.compile(
|
|
230
|
+
r"^[ \t]*<(?P<name>[A-Za-z_][A-Za-z0-9_.-]*)(?::(?P<lang>[A-Za-z]+))?>[ \t]*$")
|
|
231
|
+
_BLOCK_CLOSE_RE = re.compile(
|
|
232
|
+
r"^[ \t]*</(?P<name>[A-Za-z_][A-Za-z0-9_.-]*)>[ \t]*$")
|
|
233
|
+
|
|
234
|
+
#: a call to a named block: `@name(...)`
|
|
235
|
+
_BLOCK_CALL_RE = re.compile(r"@(?P<name>[A-Za-z_][A-Za-z0-9_.-]*)\s*\(")
|
|
236
|
+
|
|
237
|
+
_LANG_ALIASES = {
|
|
238
|
+
"python": "python", "py": "python",
|
|
239
|
+
"typescript": "typescript", "ts": "typescript",
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _rewrite_block_calls(text: str, resolve: dict[str, str]) -> str:
|
|
244
|
+
"""Turn `@block(...)` into `target_function(...)`.
|
|
245
|
+
|
|
246
|
+
Only rewrites outside strings and comments, so a literal `@name(` inside
|
|
247
|
+
a string is left alone.
|
|
248
|
+
"""
|
|
249
|
+
out_lines: list[str] = []
|
|
250
|
+
state = _NORMAL
|
|
251
|
+
for line in text.splitlines(keepends=True):
|
|
252
|
+
# fast path: nothing to rewrite on this line
|
|
253
|
+
if "@" not in line:
|
|
254
|
+
out_lines.append(line)
|
|
255
|
+
state = _scan_line(line, state).state
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
result: list[str] = []
|
|
259
|
+
i = 0
|
|
260
|
+
n = len(line)
|
|
261
|
+
line_state = state
|
|
262
|
+
while i < n:
|
|
263
|
+
c = line[i]
|
|
264
|
+
if line_state != _NORMAL:
|
|
265
|
+
# inside a string/comment: copy verbatim and let the scanner
|
|
266
|
+
# advance the state by re-scanning this line's remainder
|
|
267
|
+
rest = line[i:]
|
|
268
|
+
scan = _scan_line(rest, line_state)
|
|
269
|
+
result.append(rest)
|
|
270
|
+
line_state = scan.state
|
|
271
|
+
i = n
|
|
272
|
+
break
|
|
273
|
+
if c == "#":
|
|
274
|
+
result.append(line[i:])
|
|
275
|
+
i = n
|
|
276
|
+
break
|
|
277
|
+
if c == "/" and i + 1 < n and line[i + 1] == "/":
|
|
278
|
+
result.append(line[i:])
|
|
279
|
+
i = n
|
|
280
|
+
break
|
|
281
|
+
if c == "@":
|
|
282
|
+
m = _BLOCK_CALL_RE.match(line, i)
|
|
283
|
+
if m:
|
|
284
|
+
target = resolve.get(m.group("name"), m.group("name"))
|
|
285
|
+
result.append(target + "(")
|
|
286
|
+
i = m.end()
|
|
287
|
+
continue
|
|
288
|
+
# advance one char, tracking entry into strings
|
|
289
|
+
result.append(c)
|
|
290
|
+
if c in "\"'`":
|
|
291
|
+
# let the scanner take over from here
|
|
292
|
+
rest = line[i + 1:]
|
|
293
|
+
scan = _scan_line(c + rest, _NORMAL)
|
|
294
|
+
line_state = scan.state
|
|
295
|
+
result.append(rest)
|
|
296
|
+
i = n
|
|
297
|
+
break
|
|
298
|
+
i += 1
|
|
299
|
+
out_lines.append("".join(result))
|
|
300
|
+
state = line_state
|
|
301
|
+
return "".join(out_lines)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
_PY_STARTS = (
|
|
305
|
+
"def ", "async def ", "class ",
|
|
306
|
+
"from ", "import ",
|
|
307
|
+
"ge_preamble(", "ge_inline(", "ge_raw(",
|
|
308
|
+
)
|
|
309
|
+
_TS_STARTS = (
|
|
310
|
+
"function ", "export ", "let ", "const ", "var ",
|
|
311
|
+
"interface ", "type ", "declare ", "abstract class ",
|
|
312
|
+
"gePreamble(", "geInline(", "geRaw(",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
_MARKER_RE = re.compile(
|
|
316
|
+
r"^\s*(?:#|//)\s*ge:\s*(python|py|typescript|ts)\s*$", re.IGNORECASE)
|
|
317
|
+
|
|
318
|
+
#: `def name(params) -> ret:` — a python-style signature
|
|
319
|
+
_PY_DEF_RE = re.compile(
|
|
320
|
+
r"^(?P<indent>[ \t]*)(?:async[ \t]+)?def[ \t]+(?P<name>[A-Za-z_][A-Za-z0-9_]*)"
|
|
321
|
+
r"[ \t]*\((?P<params>.*)\)[ \t]*(?:->[ \t]*(?P<ret>[^:\n]+?))?[ \t]*:[ \t]*$",
|
|
322
|
+
re.DOTALL)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _py_header_to_ts(header: str) -> str | None:
|
|
326
|
+
"""Rewrite `def f(a: int) -> int:` as `function f(a: int): int {`.
|
|
327
|
+
|
|
328
|
+
Lets a function use the compact Python signature with a braced body —
|
|
329
|
+
both spellings are accepted in a .ge file.
|
|
330
|
+
"""
|
|
331
|
+
stripped = header.rstrip()
|
|
332
|
+
m = _PY_DEF_RE.match(stripped)
|
|
333
|
+
if not m:
|
|
334
|
+
return None
|
|
335
|
+
ret = (m.group("ret") or "").strip()
|
|
336
|
+
ret_part = f": {ret}" if ret and ret != "None" else ""
|
|
337
|
+
return (f"{m.group('indent')}function {m.group('name')}"
|
|
338
|
+
f"({m.group('params').strip()}){ret_part} {{")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
#: statement-level TypeScript markers inside a function body
|
|
342
|
+
_TS_BODY_RE = re.compile(
|
|
343
|
+
r"^\s*(?:if|while|for|else|switch)\b[^\n]*\)\s*\{|"
|
|
344
|
+
r"^\s*(?:let|const|var)\s+[A-Za-z_]|"
|
|
345
|
+
r"^\s*\}[;,]?\s*$|"
|
|
346
|
+
r";\s*$",
|
|
347
|
+
re.MULTILINE,
|
|
348
|
+
)
|
|
349
|
+
#: statement-level Python markers inside a function body
|
|
350
|
+
_PY_BODY_RE = re.compile(
|
|
351
|
+
r"^\s*(?:if|elif|else|while|for|try|except|finally|with)\b[^\n]*:\s*$|"
|
|
352
|
+
r"^\s*(?:return|pass|break|continue|raise|yield|del|assert)\b[^\n;]*$",
|
|
353
|
+
re.MULTILINE,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _body_flavour(body: str) -> str:
|
|
358
|
+
"""Score a function body as TypeScript or Python.
|
|
359
|
+
|
|
360
|
+
A function may be written with a Python-style signature but a braced
|
|
361
|
+
body; the body's syntax is the tie-breaker.
|
|
362
|
+
"""
|
|
363
|
+
ts_hits = len(_TS_BODY_RE.findall(body))
|
|
364
|
+
py_hits = len(_PY_BODY_RE.findall(body))
|
|
365
|
+
return "typescript" if ts_hits > py_hits else "python"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _marker_flavour(line: str) -> str | None:
|
|
369
|
+
m = _MARKER_RE.match(line)
|
|
370
|
+
if not m:
|
|
371
|
+
return None
|
|
372
|
+
return "python" if m.group(1).lower() in ("python", "py") else "typescript"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _is_import_typescript(line: str) -> bool:
|
|
376
|
+
"""`import { a } from "./m"` and `import x from "m"` are TS."""
|
|
377
|
+
if not line.lstrip().startswith("import"):
|
|
378
|
+
return False
|
|
379
|
+
return bool(re.search(r"""\bfrom\s+['"]""", line))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _classify(first: str, header_text: str) -> str:
|
|
383
|
+
"""Decide a chunk's flavour from its first significant line."""
|
|
384
|
+
s = first.lstrip()
|
|
385
|
+
if s.startswith("import"):
|
|
386
|
+
return "typescript" if _is_import_typescript(s) else "python"
|
|
387
|
+
for kw in _TS_STARTS:
|
|
388
|
+
if s.startswith(kw):
|
|
389
|
+
return "typescript"
|
|
390
|
+
for kw in _PY_STARTS:
|
|
391
|
+
if s.startswith(kw):
|
|
392
|
+
return "python"
|
|
393
|
+
# `NAME: type = value` is python; `NAME: type;` is ambiguous but rare
|
|
394
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*:\s*[^=]+=\s*", s) and ";" not in s:
|
|
395
|
+
return "python"
|
|
396
|
+
# `NAME = value` is python
|
|
397
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*=", s) and ";" not in s:
|
|
398
|
+
return "python"
|
|
399
|
+
# a bare intrinsic or expression statement: fall back on the header text
|
|
400
|
+
if "gePreamble(" in header_text or "geInline(" in header_text:
|
|
401
|
+
return "typescript"
|
|
402
|
+
return "python"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
# Splitter
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def split_chunks(source: str) -> list[Chunk]:
|
|
410
|
+
"""Split a mixed .ge source into classified top-level chunks."""
|
|
411
|
+
lines = source.splitlines(keepends=True)
|
|
412
|
+
chunks: list[Chunk] = []
|
|
413
|
+
i = 0
|
|
414
|
+
n = len(lines)
|
|
415
|
+
pending_marker: str | None = None
|
|
416
|
+
pending_start = 0
|
|
417
|
+
|
|
418
|
+
def indent_of(text: str) -> int:
|
|
419
|
+
return len(text) - len(text.lstrip(" \t"))
|
|
420
|
+
|
|
421
|
+
while i < n:
|
|
422
|
+
line = lines[i]
|
|
423
|
+
stripped = line.strip()
|
|
424
|
+
|
|
425
|
+
# blank line: nothing to attach
|
|
426
|
+
if not stripped:
|
|
427
|
+
i += 1
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
# explicit flavour marker
|
|
431
|
+
marker = _marker_flavour(line)
|
|
432
|
+
if marker is not None:
|
|
433
|
+
pending_marker = marker
|
|
434
|
+
i += 1
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# named block: <name> ... </name>
|
|
438
|
+
block_open = _BLOCK_OPEN_RE.match(line)
|
|
439
|
+
if block_open:
|
|
440
|
+
block_name = block_open.group("name")
|
|
441
|
+
lang = (block_open.group("lang") or "").lower()
|
|
442
|
+
body_start = i + 1
|
|
443
|
+
k = body_start
|
|
444
|
+
close_idx = -1
|
|
445
|
+
while k < n:
|
|
446
|
+
close = _BLOCK_CLOSE_RE.match(lines[k])
|
|
447
|
+
if close:
|
|
448
|
+
if close.group("name") != block_name:
|
|
449
|
+
raise TypeScriptSyntaxError(
|
|
450
|
+
f"block <{block_name}> closed by </{close.group('name')}>",
|
|
451
|
+
k + 1)
|
|
452
|
+
close_idx = k
|
|
453
|
+
break
|
|
454
|
+
k += 1
|
|
455
|
+
if close_idx < 0:
|
|
456
|
+
raise TypeScriptSyntaxError(
|
|
457
|
+
f"block <{block_name}> is never closed", i + 1)
|
|
458
|
+
|
|
459
|
+
inner = "".join(lines[body_start:close_idx])
|
|
460
|
+
if lang in _LANG_ALIASES:
|
|
461
|
+
flavour = _LANG_ALIASES[lang]
|
|
462
|
+
else:
|
|
463
|
+
# auto-detect from the block's own content
|
|
464
|
+
inner_lines = [ln for ln in lines[body_start:close_idx]
|
|
465
|
+
if ln.strip()]
|
|
466
|
+
if inner_lines:
|
|
467
|
+
first_inner = inner_lines[0]
|
|
468
|
+
head = "".join(inner_lines[:4])
|
|
469
|
+
flavour = _classify(first_inner, head)
|
|
470
|
+
else:
|
|
471
|
+
flavour = "python"
|
|
472
|
+
|
|
473
|
+
if flavour == "python" and _body_flavour(inner) == "typescript":
|
|
474
|
+
# a python-style signature with a braced body inside a block
|
|
475
|
+
sig_lines = [ln for ln in inner.splitlines(keepends=True)
|
|
476
|
+
if ln.strip()]
|
|
477
|
+
if sig_lines and _py_header_to_ts(sig_lines[0].rstrip()):
|
|
478
|
+
ts_header = _py_header_to_ts(sig_lines[0].rstrip())
|
|
479
|
+
rest = "".join(sig_lines[1:])
|
|
480
|
+
inner = ts_header + "\n" + rest.rstrip() + "\n}\n"
|
|
481
|
+
flavour = "typescript"
|
|
482
|
+
|
|
483
|
+
chunks.append(Chunk(flavour, inner, body_start + 1, True, block_name))
|
|
484
|
+
i = close_idx + 1
|
|
485
|
+
pending_start = i
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
# comment-only lines attach to the next chunk
|
|
489
|
+
if stripped.startswith("#") or stripped.startswith("//") or stripped.startswith("/*"):
|
|
490
|
+
if not chunks or chunks[-1].text.endswith("\n"):
|
|
491
|
+
pending_start = pending_start or i
|
|
492
|
+
i += 1
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
# decorators attach to the next chunk
|
|
496
|
+
decorators: list[str] = []
|
|
497
|
+
while i < n and lines[i].lstrip().startswith("@"):
|
|
498
|
+
decorators.append(lines[i])
|
|
499
|
+
i += 1
|
|
500
|
+
if i >= n:
|
|
501
|
+
break
|
|
502
|
+
|
|
503
|
+
start = pending_start if decorators else i
|
|
504
|
+
first = lines[i]
|
|
505
|
+
|
|
506
|
+
# accumulate the header (signature) lines
|
|
507
|
+
header_parts = list(decorators)
|
|
508
|
+
header_state = _NORMAL
|
|
509
|
+
header_colon = -1
|
|
510
|
+
braces_total = 0
|
|
511
|
+
brackets_total = 0
|
|
512
|
+
j = i
|
|
513
|
+
|
|
514
|
+
# header ends at a top-level ':' (python), at '{' (typescript block),
|
|
515
|
+
# or at the end of the first complete line (imports, docstrings,
|
|
516
|
+
# assignments). Only an open bracket, an unterminated string, or a
|
|
517
|
+
# signature without its colon yet keeps the header going.
|
|
518
|
+
while j < n:
|
|
519
|
+
scan = _scan_line(lines[j], header_state)
|
|
520
|
+
header_state = scan.state
|
|
521
|
+
header_parts.append(lines[j])
|
|
522
|
+
braces_total += scan.braces
|
|
523
|
+
brackets_total += scan.brackets
|
|
524
|
+
header_colon = scan.colon_at_depth0 if scan.colon_at_depth0 >= 0 else header_colon
|
|
525
|
+
if scan.braces > 0 and brackets_total == 0:
|
|
526
|
+
break # typescript body opened
|
|
527
|
+
if header_colon >= 0 and brackets_total == 0 and scan.braces == 0:
|
|
528
|
+
break # python header complete
|
|
529
|
+
if header_colon < 0 and ";" in lines[j] and scan.braces == 0 and brackets_total == 0:
|
|
530
|
+
break # typescript statement (var decl / import)
|
|
531
|
+
if header_state != _NORMAL or brackets_total > 0:
|
|
532
|
+
j += 1
|
|
533
|
+
continue # still inside a string or an open bracket
|
|
534
|
+
break # complete single-line statement
|
|
535
|
+
|
|
536
|
+
header_text = "".join(header_parts)
|
|
537
|
+
flavour = pending_marker or _classify(first, header_text)
|
|
538
|
+
explicit = pending_marker is not None
|
|
539
|
+
pending_marker = None
|
|
540
|
+
|
|
541
|
+
header_indent = indent_of(first)
|
|
542
|
+
|
|
543
|
+
if flavour == "python":
|
|
544
|
+
# A python-style header followed by a `{` body is a braced
|
|
545
|
+
# function: rewrite the header to TS and let the TS parser
|
|
546
|
+
# handle the whole chunk.
|
|
547
|
+
k = j + 1
|
|
548
|
+
while k < n and not lines[k].strip():
|
|
549
|
+
k += 1
|
|
550
|
+
if k < n and lines[k].lstrip().startswith("{"):
|
|
551
|
+
# decorators are re-emitted separately, so convert only the
|
|
552
|
+
# signature part of the accumulated header
|
|
553
|
+
signature = "".join(header_parts[len(decorators):])
|
|
554
|
+
ts_header = _py_header_to_ts(signature)
|
|
555
|
+
if ts_header is not None:
|
|
556
|
+
prefix = "".join(decorators)
|
|
557
|
+
text = prefix + ts_header + "\n" + "".join(lines[j + 1:k])
|
|
558
|
+
depth = 0
|
|
559
|
+
m = k
|
|
560
|
+
while m < n:
|
|
561
|
+
scan = _scan_line(lines[m], _NORMAL)
|
|
562
|
+
depth += scan.braces
|
|
563
|
+
m += 1
|
|
564
|
+
if depth <= 0:
|
|
565
|
+
break
|
|
566
|
+
text += "".join(lines[k:m])
|
|
567
|
+
if m < n and lines[m].strip() == ";":
|
|
568
|
+
m += 1
|
|
569
|
+
chunks.append(Chunk("typescript", text, start + 1, True))
|
|
570
|
+
i = m
|
|
571
|
+
pending_start = i
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
# consume the indented body
|
|
575
|
+
k = j + 1
|
|
576
|
+
while k < n:
|
|
577
|
+
body_line = lines[k]
|
|
578
|
+
if not body_line.strip():
|
|
579
|
+
k += 1
|
|
580
|
+
continue
|
|
581
|
+
if indent_of(body_line) > header_indent:
|
|
582
|
+
k += 1
|
|
583
|
+
continue
|
|
584
|
+
break
|
|
585
|
+
body = "".join(lines[j + 1:k])
|
|
586
|
+
|
|
587
|
+
# A python-style signature may carry a braced (TypeScript) body.
|
|
588
|
+
# The body's syntax decides; if it is TS, rewrite the signature
|
|
589
|
+
# and wrap the body in braces so the TS parser handles it.
|
|
590
|
+
if _body_flavour(body) == "typescript":
|
|
591
|
+
signature = "".join(header_parts[len(decorators):])
|
|
592
|
+
ts_header = _py_header_to_ts(signature)
|
|
593
|
+
if ts_header is not None:
|
|
594
|
+
text = ("".join(decorators) + ts_header + "\n"
|
|
595
|
+
+ body.rstrip() + "\n}\n")
|
|
596
|
+
chunks.append(Chunk("typescript", text, start + 1, True))
|
|
597
|
+
i = k
|
|
598
|
+
pending_start = i
|
|
599
|
+
continue
|
|
600
|
+
|
|
601
|
+
text = "".join(lines[start:k])
|
|
602
|
+
chunks.append(Chunk("python", text, start + 1, explicit))
|
|
603
|
+
i = k
|
|
604
|
+
else:
|
|
605
|
+
# consume until braces balance (typescript block) or the ';' ends it
|
|
606
|
+
if braces_total > 0:
|
|
607
|
+
depth = braces_total
|
|
608
|
+
k = j + 1
|
|
609
|
+
while k < n and depth > 0:
|
|
610
|
+
scan = _scan_line(lines[k], _NORMAL)
|
|
611
|
+
depth += scan.braces
|
|
612
|
+
k += 1
|
|
613
|
+
# a trailing semicolon after the closing brace
|
|
614
|
+
if k < n and lines[k].strip() == ";":
|
|
615
|
+
k += 1
|
|
616
|
+
else:
|
|
617
|
+
k = j + 1
|
|
618
|
+
text = "".join(lines[start:k])
|
|
619
|
+
chunks.append(Chunk("typescript", text, start + 1, explicit))
|
|
620
|
+
i = k
|
|
621
|
+
|
|
622
|
+
pending_start = i
|
|
623
|
+
|
|
624
|
+
return chunks
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# ---------------------------------------------------------------------------
|
|
628
|
+
# Public API
|
|
629
|
+
# ---------------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
def _lower_chunk(chunk: Chunk) -> str:
|
|
632
|
+
"""Lower one chunk to Python source."""
|
|
633
|
+
if chunk.flavour == "python":
|
|
634
|
+
return chunk.text if chunk.text.endswith("\n") else chunk.text + "\n"
|
|
635
|
+
try:
|
|
636
|
+
return ts_to_python(chunk.text)
|
|
637
|
+
except TypeScriptSyntaxError as exc:
|
|
638
|
+
where = (f"in block <{chunk.block_name}>"
|
|
639
|
+
if chunk.block_name else "in TypeScript chunk")
|
|
640
|
+
raise TypeScriptSyntaxError(
|
|
641
|
+
f"{where} starting at line {chunk.start_line}: {exc}",
|
|
642
|
+
chunk.start_line) from exc
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _function_names(python_source: str) -> list[str]:
|
|
646
|
+
"""Top-level function names defined by a lowered chunk."""
|
|
647
|
+
try:
|
|
648
|
+
tree = ast.parse(python_source)
|
|
649
|
+
except SyntaxError:
|
|
650
|
+
return []
|
|
651
|
+
return [n.name for n in tree.body if isinstance(n, ast.FunctionDef)]
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def hybrid_to_python(source: str) -> str:
|
|
655
|
+
"""Lower a mixed .ge source into one Python source.
|
|
656
|
+
|
|
657
|
+
Two passes: lower every chunk once to learn which function each block
|
|
658
|
+
defines, then rewrite `@block(...)` in the *raw* chunk text and lower
|
|
659
|
+
again. Rewriting the raw text matters because the TypeScript parser
|
|
660
|
+
already accepts `@name(...)` as a call and would drop the marker before
|
|
661
|
+
the block map could be applied.
|
|
662
|
+
"""
|
|
663
|
+
chunks = split_chunks(source)
|
|
664
|
+
lowered = [_lower_chunk(c) for c in chunks]
|
|
665
|
+
|
|
666
|
+
# block name -> function name
|
|
667
|
+
resolve: dict[str, str] = {}
|
|
668
|
+
for chunk, py in zip(chunks, lowered):
|
|
669
|
+
if not chunk.block_name:
|
|
670
|
+
continue
|
|
671
|
+
names = _function_names(py)
|
|
672
|
+
if chunk.block_name in names:
|
|
673
|
+
resolve[chunk.block_name] = chunk.block_name
|
|
674
|
+
elif len(names) == 1:
|
|
675
|
+
resolve[chunk.block_name] = names[0]
|
|
676
|
+
elif names:
|
|
677
|
+
# several functions: the block name addresses the first one
|
|
678
|
+
resolve[chunk.block_name] = names[0]
|
|
679
|
+
|
|
680
|
+
out: list[str] = []
|
|
681
|
+
for chunk, py in zip(chunks, lowered):
|
|
682
|
+
if "@" in chunk.text:
|
|
683
|
+
raw = _rewrite_block_calls(chunk.text, resolve)
|
|
684
|
+
if raw != chunk.text:
|
|
685
|
+
py = _lower_chunk(Chunk(chunk.flavour, raw, chunk.start_line,
|
|
686
|
+
chunk.explicit, chunk.block_name))
|
|
687
|
+
out.append(py)
|
|
688
|
+
return "\n".join(out)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def parse_hybrid_source_full(source: str) -> tuple[list, list]:
|
|
692
|
+
"""Parse a mixed .ge source into the shared IR."""
|
|
693
|
+
from ..analyzer import parse_source_full
|
|
694
|
+
return parse_source_full(hybrid_to_python(source))
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def collect_hybrid_constants(source: str) -> dict:
|
|
698
|
+
from ..analyzer import collect_constants
|
|
699
|
+
return collect_constants(hybrid_to_python(source))
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def collect_hybrid_preamble(source: str) -> dict:
|
|
703
|
+
from ..analyzer import collect_preamble
|
|
704
|
+
return collect_preamble(hybrid_to_python(source))
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def chunk_flavours(source: str) -> list[tuple[int, str]]:
|
|
708
|
+
"""Return (line, flavour) for each chunk — used by `ge analyze` output."""
|
|
709
|
+
return [(c.start_line, c.flavour) for c in split_chunks(source)]
|