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.
Files changed (96) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/README.md +535 -0
  4. package/bin/ge.js +112 -0
  5. package/package.json +62 -0
  6. package/python/pyeffic/__init__.py +9 -0
  7. package/python/pyeffic/__main__.py +6 -0
  8. package/python/pyeffic/analyzer.py +464 -0
  9. package/python/pyeffic/apisurface.py +238 -0
  10. package/python/pyeffic/autoselect.py +327 -0
  11. package/python/pyeffic/backends.py +87 -0
  12. package/python/pyeffic/bench.py +233 -0
  13. package/python/pyeffic/cli.py +184 -0
  14. package/python/pyeffic/compiler.py +421 -0
  15. package/python/pyeffic/config.py +383 -0
  16. package/python/pyeffic/dartgen.py +441 -0
  17. package/python/pyeffic/deploy.py +586 -0
  18. package/python/pyeffic/diagnostics.py +194 -0
  19. package/python/pyeffic/difftest.py +424 -0
  20. package/python/pyeffic/downloader.py +307 -0
  21. package/python/pyeffic/emitters/__init__.py +11 -0
  22. package/python/pyeffic/emitters/base.py +2359 -0
  23. package/python/pyeffic/emitters/cpp.py +266 -0
  24. package/python/pyeffic/emitters/csharp.py +342 -0
  25. package/python/pyeffic/emitters/dart.py +349 -0
  26. package/python/pyeffic/emitters/go.py +388 -0
  27. package/python/pyeffic/emitters/kotlin.py +314 -0
  28. package/python/pyeffic/emitters/rust.py +314 -0
  29. package/python/pyeffic/emitters/zig.py +411 -0
  30. package/python/pyeffic/ffi.py +49 -0
  31. package/python/pyeffic/frontends/__init__.py +94 -0
  32. package/python/pyeffic/frontends/hybrid.py +709 -0
  33. package/python/pyeffic/frontends/typescript.py +965 -0
  34. package/python/pyeffic/ge_cli.py +1148 -0
  35. package/python/pyeffic/golden.py +348 -0
  36. package/python/pyeffic/idents.py +206 -0
  37. package/python/pyeffic/modules.py +220 -0
  38. package/python/pyeffic/packer.py +222 -0
  39. package/python/pyeffic/pipeline.py +797 -0
  40. package/python/pyeffic/reactgen.py +966 -0
  41. package/python/pyeffic/researcher.py +177 -0
  42. package/python/pyeffic/scaffold.py +397 -0
  43. package/python/pyeffic/stdlib.py +246 -0
  44. package/python/pyeffic/styling.py +220 -0
  45. package/python/pyeffic/templates/desktop_gui/README.md +106 -0
  46. package/python/pyeffic/templates/desktop_gui/app/__init__.py +0 -0
  47. package/python/pyeffic/templates/desktop_gui/app/core/__init__.py +0 -0
  48. package/python/pyeffic/templates/desktop_gui/app/core/add.ge.py +13 -0
  49. package/python/pyeffic/templates/desktop_gui/app/core/factorial.ge.py +20 -0
  50. package/python/pyeffic/templates/desktop_gui/app/core/fibonacci.ge.py +25 -0
  51. package/python/pyeffic/templates/desktop_gui/app/core/gcd.ge.py +19 -0
  52. package/python/pyeffic/templates/desktop_gui/app/core/is_prime.ge.py +24 -0
  53. package/python/pyeffic/templates/desktop_gui/app/core/multiply.ge.py +13 -0
  54. package/python/pyeffic/templates/desktop_gui/app/core/power.ge.py +25 -0
  55. package/python/pyeffic/templates/desktop_gui/app/main.ge.py +49 -0
  56. package/python/pyeffic/templates/desktop_gui/app/memory/__init__.py +0 -0
  57. package/python/pyeffic/templates/desktop_gui/app/memory/buffer.ge.py +26 -0
  58. package/python/pyeffic/templates/desktop_gui/app/memory/limits.ge.py +47 -0
  59. package/python/pyeffic/templates/desktop_gui/app/memory/state.ge.py +44 -0
  60. package/python/pyeffic/templates/desktop_gui/app/ui/__init__.py +0 -0
  61. package/python/pyeffic/templates/desktop_gui/app/ui/layout.ge.py +64 -0
  62. package/python/pyeffic/templates/desktop_gui/app/ui/render.ge.py +87 -0
  63. package/python/pyeffic/templates/desktop_gui/app/ui/theme.ge.py +147 -0
  64. package/python/pyeffic/templates/desktop_gui/app/ui/widgets.ge.py +105 -0
  65. package/python/pyeffic/templates/desktop_gui/desktop/__init__.py +1 -0
  66. package/python/pyeffic/templates/desktop_gui/desktop/main.ge.py +258 -0
  67. package/python/pyeffic/templates/desktop_gui/ge.toml +16 -0
  68. package/python/pyeffic/templates/desktop_gui/tests/__init__.py +0 -0
  69. package/python/pyeffic/templates/desktop_gui/tests/ge_loader.py +76 -0
  70. package/python/pyeffic/templates/desktop_gui/tests/test_app.py +173 -0
  71. package/python/pyeffic/templates/web_react/README.md +115 -0
  72. package/python/pyeffic/templates/web_react/app/__init__.py +0 -0
  73. package/python/pyeffic/templates/web_react/app/core/__init__.py +0 -0
  74. package/python/pyeffic/templates/web_react/app/core/add.ge.py +9 -0
  75. package/python/pyeffic/templates/web_react/app/core/factorial.ge.py +16 -0
  76. package/python/pyeffic/templates/web_react/app/core/fibonacci.ge.py +21 -0
  77. package/python/pyeffic/templates/web_react/app/core/is_prime.ge.py +20 -0
  78. package/python/pyeffic/templates/web_react/app/core/multiply.ge.py +9 -0
  79. package/python/pyeffic/templates/web_react/app/main.ge.py +25 -0
  80. package/python/pyeffic/templates/web_react/app/memory/__init__.py +0 -0
  81. package/python/pyeffic/templates/web_react/app/memory/buffer.ge.py +25 -0
  82. package/python/pyeffic/templates/web_react/app/memory/limits.ge.py +51 -0
  83. package/python/pyeffic/templates/web_react/ge.toml +23 -0
  84. package/python/pyeffic/templates/web_react/tests/__init__.py +0 -0
  85. package/python/pyeffic/templates/web_react/tests/ge_loader.py +68 -0
  86. package/python/pyeffic/templates/web_react/tests/test_app.py +105 -0
  87. package/python/pyeffic/templates/web_react/ui/main.ge.ui +33 -0
  88. package/python/pyeffic/templates/web_react/web/__init__.py +0 -0
  89. package/python/pyeffic/templates/web_react/web/server.ge.py +78 -0
  90. package/python/pyeffic/ts2py.py +657 -0
  91. package/python/pyeffic/typecheck.py +232 -0
  92. package/python/pyeffic/ui.py +154 -0
  93. package/python/pyeffic/ui_dsl.py +618 -0
  94. package/python/pyeffic/widgets.py +87 -0
  95. package/scripts/README.md +42 -0
  96. 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)]