gelang 0.1.4 → 0.1.6

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 CHANGED
@@ -4,6 +4,113 @@ All notable changes to GE. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - **Hybrid `.ge` frontend** — one file may mix Python-flavoured and
12
+ TypeScript-flavoured definitions. Flavour is detected per chunk.
13
+ - **Named blocks** — `<name> ... </name>` gives a chunk a name; `@name(...)`
14
+ calls it from anywhere in the file, in either flavour, forward or backward.
15
+ - **Mixed-backend native linking** — `ge build` emits each non-entry backend
16
+ in library mode with `extern "C"` exports, compiles it to an object, and
17
+ links one executable. No build script, no hand-written FFI.
18
+ - **`ge diff`** — differential testing. Runs the same program through CPython
19
+ and every installed backend, comparing output byte-for-byte.
20
+ - **`ge golden`** — golden lowering corpus. Emitted code must match the
21
+ committed golden, and compiling it must still print the expected output.
22
+ - **`ge api-check`** — committed dumps of the CLI, diagnostics, and intrinsic
23
+ surfaces. Drift fails the check.
24
+ - **`ge doctor`** — runtime and toolchain check with per-platform install
25
+ hints. `--json` for CI.
26
+ - **`ge react`** — generates a React 19 + TypeScript + Vite project from a
27
+ `.ge.ui` file, one component per file.
28
+ - **Organized build output** — `build/{desktop,web,mobile}/{backend,obj,bin}`.
29
+ - **npm distribution** — `npx gelang` runs the compiler with bundled source,
30
+ no `pip install` required.
31
+ - **`--target` buckets** — `desktop`, `web`, `mobile`.
32
+ - **Target-keyword safety** — identifiers that collide with a target
33
+ language's reserved words are escaped (`@base` in C#, `r#match` in Rust)
34
+ or renamed consistently across the ABI (`double` → `double_`).
35
+
36
+ ### Fixed
37
+
38
+ - C# reserved words in parameter positions emitted invalid code (`base`).
39
+ - Function names colliding with target keywords broke compilation
40
+ (`double` in C++, `match` in Rust).
41
+ - `from pyeffic.backends import rust` was resolved as user code, pulling the
42
+ compiler's own sources into the program.
43
+ - `ge_build`/`build_flutter` used an undefined `preamble` variable.
44
+ - Go backend wrote binaries next to the source instead of `build/<target>/bin`.
45
+ - Module resolver matched a sibling file for dotted imports
46
+ (`app.main` resolved to `desktop/main.ge`).
47
+ - `.ge.ui` parser rejected files with a leading docstring.
48
+ - `.ge.ui` parser only accepted `Screen "title" {}`, not `Window { title: }`.
49
+
50
+ ### Changed
51
+
52
+ - Canonical extension is `.ge`. `.ge.py` and `.ge.ts` remain supported for
53
+ single-flavour files; `.ts.ge.py` is accepted as a legacy alias.
54
+ - Build root defaults to `build/` (was `ge_build/`).
55
+ - Package name on npm is `gelang`.
56
+ ## [0.1.6] — 2026-09-13
57
+
58
+ ### Zig is now a full backend
59
+
60
+ Zig modelled a GE list as a `[]const i64` slice, which cannot grow, so
61
+ `append`, `sort`, `split`, list comprehensions and `min`/`max` over a list
62
+ were either rejected or emitted Zig that did not compile. Research into
63
+ Zig's allocator conventions settled the design: a generated program runs
64
+ start to finish, so a single **arena** is the right fit — not
65
+ `page_allocator` (a syscall per allocation) and not
66
+ `GeneralPurposeAllocator` (a debugging allocator).
67
+
68
+ Lists are now `std.ArrayList(T)` over an arena that `main()` releases:
69
+
70
+ - literals become `geListFrom(elem, &[_]elem{...})`; an empty literal is
71
+ `geListNew(elem)`
72
+ - append, sort, reverse, index, len, iteration, slicing and printing all work
73
+ - a list parameter is `*std.ArrayList`, so mutation in the callee is visible
74
+ to the caller, matching Python's reference semantics. Call sites pass
75
+ `&name`; an rvalue argument is bound to a temporary first.
76
+ - passing a list to a mutating callee marks the local `var` (Zig rejects an
77
+ unmutated `var`)
78
+
79
+ Also implemented for Zig: `str.split`, `str.join`, `str.replace`,
80
+ `str.upper`, `str.lower` (all arena-allocated), sets as
81
+ `AutoHashMap(elem, void)` with a deduplicating literal, and dict literals
82
+ over the arena.
83
+
84
+ ### Fixed
85
+
86
+ - Iterating a Rust list yields `&i64`, so `for x in xs: if x == n` did not
87
+ compile. The loop now uses `.cloned()`, which yields owned values for both
88
+ `i64` and `String`. Python semantics are unchanged.
89
+ - Go rejects `int64(3.9)` for an untyped constant; `int()` now truncates
90
+ through `math.Trunc`, matching Python.
91
+ - `list.sort()` and `list.reverse()` had no implementation and fell through
92
+ to `xs.sort()`, valid only in Rust and Kotlin.
93
+ - `ge analyze` parsed hybrid `.ge` files as plain Python, reporting a syntax
94
+ error on the first block tag, and never lowered `.ge.ts` at all.
95
+ - `ge react` wrote to `web/` instead of the documented `build/web/`.
96
+ - `ge flutter` handed a `.ge.ui` file to the module resolver, which tried to
97
+ parse `Window {` as Python.
98
+ - The return type of `str.upper()` and friends was not inferred, so `print`
99
+ used the generic format — a Zig `[]const u8` rendered as a byte array.
100
+ - `scripts/e2e.py` aborted the whole run with `UnicodeEncodeError` when a
101
+ compiler diagnostic contained a character the console codec cannot encode.
102
+
103
+ ### Added
104
+
105
+ - `scripts/e2e.py` — 118 end-to-end cases that compile and run real programs
106
+ on every installed backend and compare stdout with CPython.
107
+ - `str_upper`, `str_lower`, `str_replace`, `set_len` and `dict_len` on the
108
+ shared `Spec`, so every backend expresses these the same way.
109
+
110
+ ### Known limitations
111
+
112
+ - Tuples are supported at arity 2; other arities are rejected with
113
+ `error[GE009]` rather than emitting broken code.
7
114
  ## [0.1.4] — 2026-09-13
8
115
 
9
116
  Found by `scripts/e2e.py`, a new harness that compiles and runs 118
@@ -57,55 +164,7 @@ programs on every installed backend and compares stdout with CPython.
57
164
  - Tuples are supported at arity 2; other arities are rejected with a
58
165
  diagnostic instead of emitting broken code.
59
166
 
60
- ## [Unreleased]
61
-
62
- ### Added
63
167
 
64
- - **Hybrid `.ge` frontend** — one file may mix Python-flavoured and
65
- TypeScript-flavoured definitions. Flavour is detected per chunk.
66
- - **Named blocks** — `<name> ... </name>` gives a chunk a name; `@name(...)`
67
- calls it from anywhere in the file, in either flavour, forward or backward.
68
- - **Mixed-backend native linking** — `ge build` emits each non-entry backend
69
- in library mode with `extern "C"` exports, compiles it to an object, and
70
- links one executable. No build script, no hand-written FFI.
71
- - **`ge diff`** — differential testing. Runs the same program through CPython
72
- and every installed backend, comparing output byte-for-byte.
73
- - **`ge golden`** — golden lowering corpus. Emitted code must match the
74
- committed golden, and compiling it must still print the expected output.
75
- - **`ge api-check`** — committed dumps of the CLI, diagnostics, and intrinsic
76
- surfaces. Drift fails the check.
77
- - **`ge doctor`** — runtime and toolchain check with per-platform install
78
- hints. `--json` for CI.
79
- - **`ge react`** — generates a React 19 + TypeScript + Vite project from a
80
- `.ge.ui` file, one component per file.
81
- - **Organized build output** — `build/{desktop,web,mobile}/{backend,obj,bin}`.
82
- - **npm distribution** — `npx gelang` runs the compiler with bundled source,
83
- no `pip install` required.
84
- - **`--target` buckets** — `desktop`, `web`, `mobile`.
85
- - **Target-keyword safety** — identifiers that collide with a target
86
- language's reserved words are escaped (`@base` in C#, `r#match` in Rust)
87
- or renamed consistently across the ABI (`double` → `double_`).
88
-
89
- ### Fixed
90
-
91
- - C# reserved words in parameter positions emitted invalid code (`base`).
92
- - Function names colliding with target keywords broke compilation
93
- (`double` in C++, `match` in Rust).
94
- - `from pyeffic.backends import rust` was resolved as user code, pulling the
95
- compiler's own sources into the program.
96
- - `ge_build`/`build_flutter` used an undefined `preamble` variable.
97
- - Go backend wrote binaries next to the source instead of `build/<target>/bin`.
98
- - Module resolver matched a sibling file for dotted imports
99
- (`app.main` resolved to `desktop/main.ge`).
100
- - `.ge.ui` parser rejected files with a leading docstring.
101
- - `.ge.ui` parser only accepted `Screen "title" {}`, not `Window { title: }`.
102
-
103
- ### Changed
104
-
105
- - Canonical extension is `.ge`. `.ge.py` and `.ge.ts` remain supported for
106
- single-flavour files; `.ts.ge.py` is accepted as a legacy alias.
107
- - Build root defaults to `build/` (was `ge_build/`).
108
- - Package name on npm is `gelang`.
109
168
 
110
169
  ## [0.1.0] — 2026-09-12
111
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gelang",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "One typed source language, compiled to Rust, C++, C#, Zig, Go, or Kotlin — chosen per function. Generates Flutter and React UIs from one DSL.",
5
5
  "keywords": [
6
6
  "ge",
@@ -63,6 +63,15 @@ class Spec:
63
63
  # str.split(sep) / sep.join(list). Empty means unsupported.
64
64
  str_split: str = ""
65
65
  str_join: str = ""
66
+ # str.upper() / str.lower(). Empty falls back to the per-backend
67
+ # branch in the dispatcher.
68
+ str_upper: str = ""
69
+ str_lower: str = ""
70
+ # str.replace(a, b). Empty falls back to the generic method call.
71
+ str_replace: str = ""
72
+ # in-place list.sort() / list.reverse()
73
+ list_sort: str = ""
74
+ list_reverse: str = ""
66
75
  list_slice: str = ""
67
76
  list_copy: str = ""
68
77
  # Iterating a dict in Python yields its keys. `dict_keys` is an
@@ -100,6 +109,8 @@ class Spec:
100
109
  # tuple support
101
110
  tuple_type: str = "std::tuple<{T}>"
102
111
  set_type: str = "" # native set type
112
+ set_len: str = "" # len(set)
113
+ dict_len: str = "" # len(dict)
103
114
  tuple_get: str = "std::get<{i}>({t})"
104
115
  # list length (separate from len_call which may be used for strings)
105
116
  list_len: str = "{x}.size()"
@@ -230,6 +241,10 @@ class Emitter:
230
241
  elem_t = self._native_elem_type(self.param_elem_types[pname])
231
242
  if self.spec.name == "rust":
232
243
  return f"&[{elem_t}]"
244
+ if self.spec.name == "zig":
245
+ # a list parameter is always a pointer to the caller's
246
+ # ArrayList so mutation is visible to it
247
+ return f"*std.ArrayList({elem_t})"
233
248
  return self.spec.list_type.format(T=elem_t)
234
249
  return self.spec.list_param_type
235
250
  if t == "dict":
@@ -1216,7 +1231,10 @@ class Emitter:
1216
1231
  if self.spec.name == "kotlin":
1217
1232
  return f"mutableListOf({elems})"
1218
1233
  if self.spec.name == "zig":
1219
- return f"&[_]i64{{ {elems} }}"
1234
+ # Zig lists are growable ArrayLists; build one from the literal
1235
+ if not node.elts:
1236
+ return f"geListNew({elem})"
1237
+ return f"geListFrom({elem}, &[_]{elem}{{ {elems} }})"
1220
1238
  if self.spec.name == "csharp":
1221
1239
  lt = self.spec.list_type.format(T=elem)
1222
1240
  return f"new {lt}{{{elems}}}"
@@ -1247,11 +1265,20 @@ class Emitter:
1247
1265
  for k, v in zip(node.keys, node.values))
1248
1266
  return f"hashMapOf({pairs})"
1249
1267
  if self.spec.name == "zig":
1250
- # Zig doesn't have map literals — emit a block expression
1251
- # that creates and populates the map
1252
- puts = "\n".join(f" m.put({self.expr(k)}, {self.expr(v)}) catch unreachable;"
1253
- for k, v in zip(node.keys, node.values))
1254
- return f"blk: {{\n var m = std.StringHashMap({self.spec.list_elem_type}).init(std.heap.page_allocator);\n{puts}\n break :blk m;\n }}"
1268
+ # Zig has no map literal; build one with the arena allocator.
1269
+ # An empty literal needs no block at all, and a block whose
1270
+ # local is never mutated is rejected by Zig.
1271
+ if not node.keys:
1272
+ return (f"std.StringHashMap({self.spec.list_elem_type})"
1273
+ f".init(ge_alloc)")
1274
+ puts = "\n".join(
1275
+ f" __m.put({self.expr(k)}, {self.expr(v)}) catch unreachable;"
1276
+ for k, v in zip(node.keys, node.values))
1277
+ return (f"blk: {{\n"
1278
+ f" var __m = std.StringHashMap({self.spec.list_elem_type}).init(ge_alloc);\n"
1279
+ f"{puts}\n"
1280
+ f" break :blk __m;\n"
1281
+ f"}}")
1255
1282
  return f"{{{pairs}}}"
1256
1283
  if isinstance(node, ast.Set):
1257
1284
  elems = ", ".join(self.expr(e) for e in node.elts)
@@ -1277,8 +1304,16 @@ class Emitter:
1277
1304
  if self.spec.name == "kotlin":
1278
1305
  return f"hashSetOf({elems})"
1279
1306
  if self.spec.name == "zig":
1280
- # no allocator-backed set in the Zig runtime
1281
- return self._mark_unsupported("set literal (Zig)")
1307
+ # Zig has no set type; GE uses AutoHashMap(elem, void) over
1308
+ # the arena allocator
1309
+ if not node.elts:
1310
+ return "geSetNew(i64)"
1311
+ seen: list[str] = []
1312
+ for e in node.elts:
1313
+ rendered = self.expr(e)
1314
+ if rendered not in seen:
1315
+ seen.append(rendered)
1316
+ return f"geSetFrom(i64, &[_]i64{{ {', '.join(seen)} }})"
1282
1317
  return f"std::set<int64_t>{{{elems}}}"
1283
1318
  if isinstance(node, ast.SetComp):
1284
1319
  return self._set_comp(node)
@@ -1362,6 +1397,10 @@ class Emitter:
1362
1397
  return self.spec.str_len.format(x=self.expr(arg))
1363
1398
  if t == "list":
1364
1399
  return self.spec.list_len.format(x=self.expr(arg))
1400
+ if t == "set" and self.spec.set_len:
1401
+ return self.spec.set_len.format(x=self.expr(arg))
1402
+ if t == "dict" and self.spec.dict_len:
1403
+ return self.spec.dict_len.format(x=self.expr(arg))
1365
1404
  return self.spec.len_call.format(x=self.expr(arg))
1366
1405
  if fname == "abs":
1367
1406
  t = self.infer_type(node.args[0])
@@ -1738,6 +1777,8 @@ class Emitter:
1738
1777
  if base_type == "str" or (isinstance(node.func.value, ast.Constant) and isinstance(node.func.value.value, str)):
1739
1778
  method = node.func.attr
1740
1779
  if method == "upper":
1780
+ if self.spec.str_upper:
1781
+ return self.spec.str_upper.format(x=base)
1741
1782
  if self.spec.name == "rust":
1742
1783
  return f"{base}.to_uppercase()"
1743
1784
  if self.spec.name == "cpp":
@@ -1750,6 +1791,8 @@ class Emitter:
1750
1791
  return f"{base}.uppercase()"
1751
1792
  return f"{base}.upper()"
1752
1793
  if method == "lower":
1794
+ if self.spec.str_lower:
1795
+ return self.spec.str_lower.format(x=base)
1753
1796
  if self.spec.name == "rust":
1754
1797
  return f"{base}.to_lowercase()"
1755
1798
  if self.spec.name == "cpp":
@@ -1764,10 +1807,10 @@ class Emitter:
1764
1807
  if method == "replace" and len(node.args) == 2:
1765
1808
  a = self.expr(node.args[0])
1766
1809
  b = self.expr(node.args[1])
1810
+ if self.spec.str_replace:
1811
+ return self.spec.str_replace.format(x=base, a=a, b=b)
1767
1812
  if self.spec.name == "rust":
1768
1813
  return f"{base}.replace({a}.as_str(), {b}.as_str())"
1769
- if self.spec.name == "cpp":
1770
- return f"geStrReplace({base}, {a}, {b})"
1771
1814
  if self.spec.name == "csharp":
1772
1815
  return f"{base}.Replace({a}, {b})"
1773
1816
  if self.spec.name == "go":
@@ -1784,6 +1827,18 @@ class Emitter:
1784
1827
  if self.spec.name == "kotlin":
1785
1828
  return f"{base}.trim()"
1786
1829
  return f"{base}.strip()"
1830
+ # in-place list operations: xs.sort() / xs.reverse()
1831
+ if (isinstance(node.func, ast.Attribute)
1832
+ and node.func.attr in ("sort", "reverse")
1833
+ and not node.args):
1834
+ list_base = self.expr(node.func.value)
1835
+ tpl = (self.spec.list_sort if node.func.attr == "sort"
1836
+ else self.spec.list_reverse)
1837
+ if tpl:
1838
+ return tpl.format(x=list_base)
1839
+ return self._mark_unsupported(
1840
+ f"list.{node.func.attr} (not available on the "
1841
+ f"{self.spec.name} backend)")
1787
1842
  # generic method call
1788
1843
  return f"{base}.{node.func.attr}({', '.join(args)})"
1789
1844
  # generic (user) function call: borrow list args where appropriate
@@ -1802,6 +1857,20 @@ class Emitter:
1802
1857
  else:
1803
1858
  args.append(f"&mut {self.expr(a)}")
1804
1859
  continue
1860
+ if self.spec.name == "zig" and self.infer_type(a) == "list":
1861
+ # Zig list params are *std.ArrayList, so the caller passes a
1862
+ # pointer to its own list (mutations must be visible).
1863
+ if isinstance(a, ast.Name):
1864
+ args.append(f"&{self.expr(a)}")
1865
+ else:
1866
+ # a literal or call result is an rvalue and cannot be
1867
+ # addressed; bind it to a temporary first
1868
+ tmp = f"__ge_arg{i}_{len(self.lines)}"
1869
+ self.lines.append(
1870
+ f"{self.spec.indent * self.indent_lvl}"
1871
+ f"var {tmp} = {self.expr(a)};")
1872
+ args.append(f"&{tmp}")
1873
+ continue
1805
1874
  args.append(self._arg(a))
1806
1875
  # handle keyword arguments: map to positional by reordering
1807
1876
  # (native languages don't support keyword args, so we just append them)
@@ -2153,11 +2222,17 @@ class Emitter:
2153
2222
  f"for _, {var} := range {iter_src} {{ __v = append(__v, int64({elem})) }}; "
2154
2223
  f"return __v; }}()")
2155
2224
  if self.spec.name == "zig":
2225
+ # build an ArrayList (the GE list type), not a slice
2226
+ et = self.spec.list_elem_type
2156
2227
  if lo is not None:
2157
- return (f"blk: {{ var __v = std.ArrayList({self.spec.list_elem_type}).init(std.heap.page_allocator); "
2158
- f"var {var}: i64 = {lo}; while ({var} < {hi}) : ({var} += 1) {{ __v.append({elem}) catch unreachable; }} "
2159
- f"break :blk __v.toOwnedSlice() catch unreachable; }}")
2160
- return self._mark_unsupported('for-in comprehension in Zig')
2228
+ return (f"blk: {{ var __v = geListNew({et}); "
2229
+ f"var {var}: i64 = {lo}; while ({var} < {hi}) : ({var} += 1) {{ "
2230
+ f"__v.append(@as({et}, @intCast({elem}))) catch unreachable; }} "
2231
+ f"break :blk __v; }}")
2232
+ return (f"blk: {{ var __v = geListNew({et}); "
2233
+ f"for ({iter_src}.items) |{var}| {{ "
2234
+ f"__v.append(@as({et}, @intCast({elem}))) catch unreachable; }} "
2235
+ f"break :blk __v; }}")
2161
2236
  if self.spec.name == "kotlin":
2162
2237
  if lo is not None:
2163
2238
  return (f"run {{ val __v = mutableListOf<Long>(); "
@@ -2525,6 +2600,17 @@ class Emitter:
2525
2600
  key = f"{node.func.value.id}.{node.func.attr}"
2526
2601
  if key in self.func_return_types:
2527
2602
  return self.func_return_types[key]
2603
+ # str methods that return a string. Without this, print() falls
2604
+ # back to the generic format and a Zig []const u8 renders as a
2605
+ # byte array.
2606
+ if (isinstance(node.func, ast.Attribute)
2607
+ and self.infer_type(node.func.value) == "str"
2608
+ and node.func.attr in ("upper", "lower", "strip", "replace",
2609
+ "join", "title", "capitalize")):
2610
+ return "str"
2611
+ if (isinstance(node.func, ast.Attribute)
2612
+ and node.func.attr in ("split", "splitlines")):
2613
+ return "list"
2528
2614
  if isinstance(node, ast.Set):
2529
2615
  return "set"
2530
2616
  if isinstance(node, ast.SetComp):
@@ -12,7 +12,12 @@ SPEC = Spec(
12
12
  list_type="std::vector<{T}>",
13
13
  list_param_type="std::vector<int64_t>&",
14
14
  list_elem_type="int64_t",
15
+ list_sort="std::sort({x}.begin(), {x}.end())",
16
+ list_reverse="std::reverse({x}.begin(), {x}.end())",
15
17
  str_split="geStrSplit({x}, {sep})",
18
+ str_upper="geStrUpper({x})",
19
+ str_lower="geStrLower({x})",
20
+ str_replace="geStrReplace({x}, {a}, {b})",
16
21
  str_join="geStrJoin({x}, {sep})",
17
22
  list_slice="std::vector<int64_t>({x}.begin() + ({start}), {x}.begin() + ({stop}))",
18
23
  list_copy="std::vector<int64_t>({x})",
@@ -17,7 +17,12 @@ SPEC = Spec(
17
17
  list_type="List<{T}>",
18
18
  list_param_type="List<long>",
19
19
  list_elem_type="long",
20
+ list_sort="{x}.Sort()",
21
+ list_reverse="{x}.Reverse()",
20
22
  str_split="{x}.Split({sep}).ToList()",
23
+ str_upper="{x}.ToUpper()",
24
+ str_lower="{x}.ToLower()",
25
+ str_replace="{x}.Replace({a}, {b})",
21
26
  str_join="string.Join({sep}, {x})",
22
27
  list_slice="{x}.GetRange((int)({start}), (int)(({stop}) - ({start})))",
23
28
  list_copy="new List<long>({x})",
@@ -26,6 +31,10 @@ SPEC = Spec(
26
31
  range_step_call="({lo}..{hi}).Step({step})",
27
32
  len_call="{x}.Length",
28
33
  list_len="{x}.Count",
34
+ # Dictionary and HashSet expose Count, not Length; len()
35
+ # fell back to len_call and emitted an invalid member.
36
+ dict_len="{x}.Count",
37
+ set_len="{x}.Count",
29
38
  print_int='Console.WriteLine({v})',
30
39
  print_float='Console.WriteLine({v})',
31
40
  print_str='Console.WriteLine({v})',
@@ -16,7 +16,12 @@ SPEC = Spec(
16
16
  list_type="[]{T}",
17
17
  list_param_type="[]int64",
18
18
  list_elem_type="int64",
19
+ list_sort="sort.Slice({x}, func(i, j int) bool {{ return {x}[i] < {x}[j] }})",
20
+ list_reverse="sort.Slice({x}, func(i, j int) bool {{ return i > j }})",
19
21
  str_split="strings.Split({x}, {sep})",
22
+ str_upper="strings.ToUpper({x})",
23
+ str_lower="strings.ToLower({x})",
24
+ str_replace="strings.ReplaceAll({x}, {a}, {b})",
20
25
  str_join="strings.Join({x}, {sep})",
21
26
  list_slice="append([]int64(nil), {x}[{start}:{stop}]...)",
22
27
  list_copy="append([]int64(nil), {x}...)",
@@ -31,7 +36,9 @@ SPEC = Spec(
31
36
  print_bool='fmt.Println(geBoolStr({v}))',
32
37
  print_generic='fmt.Println({v})',
33
38
  print_list='fmt.Println(geListStr({v}))',
34
- int_cast="int64({x})",
39
+ # Go rejects int64(3.9) for an untyped constant; math.Trunc works
40
+ # for both constants and variables and matches Python int().
41
+ int_cast="int64(math.Trunc({x}))",
35
42
  float_cast="float64({x})",
36
43
  float_div="(float64({l}) / float64({r}))",
37
44
  floor_div="({l} / {r})",
@@ -17,7 +17,12 @@ SPEC = Spec(
17
17
  list_type="MutableList<{T}>",
18
18
  list_param_type="MutableList<Long>",
19
19
  list_elem_type="Long",
20
+ list_sort="{x}.sort()",
21
+ list_reverse="{x}.reverse()",
20
22
  str_split="{x}.split({sep}).toMutableList()",
23
+ str_upper="{x}.uppercase()",
24
+ str_lower="{x}.lowercase()",
25
+ str_replace="{x}.replace({a}, {b})",
21
26
  str_join="{x}.joinToString({sep})",
22
27
  list_slice="{x}.subList(({start}).toInt(), ({stop}).toInt()).toMutableList()",
23
28
  list_copy="{x}.toMutableList()",
@@ -12,7 +12,12 @@ SPEC = Spec(
12
12
  list_type="Vec<{T}>",
13
13
  list_param_type="&[i64]",
14
14
  list_elem_type="i64",
15
+ list_sort="{x}.sort()",
16
+ list_reverse="{x}.reverse()",
15
17
  str_split="{x}.split({sep}.as_str()).map(|s| s.to_string()).collect::<Vec<String>>()",
18
+ str_upper="{x}.to_uppercase()",
19
+ str_lower="{x}.to_lowercase()",
20
+ str_replace="{x}.replace({a}.as_str(), {b}.as_str())",
16
21
  str_join="{x}.join({sep}.as_str())",
17
22
  list_slice="{x}[({start}) as usize..({stop}) as usize].to_vec()",
18
23
  list_copy="{x}.clone()",
@@ -57,7 +62,9 @@ SPEC = Spec(
57
62
  str_slice_end="{x}[..{end}].to_string()",
58
63
  list_concat="{{ let mut t = {l}; t.extend({r}.iter()); t }}",
59
64
  condition_parens=False,
60
- foreach_template="for {var} in {iter}.iter()",
65
+ # .cloned() so the loop variable is owned: iterating a &[i64]
66
+ # otherwise yields &i64 and `x == 5` does not compile.
67
+ foreach_template="for {var} in {iter}.iter().cloned()",
61
68
  try_template="// try/except not natively supported in Rust\n// {body}\n// {handler}",
62
69
  struct_template="#[derive(Clone)]\nstruct {name} {{\n{fields}\n}}",
63
70
  struct_field_template=" {type}: {name},",
@@ -13,40 +13,40 @@ from ..analyzer import FuncUnit
13
13
  SPEC = Spec(
14
14
  name="zig",
15
15
  types={"int": "i64", "float": "f64", "bool": "bool", "str": "[]const u8", "None": "void"},
16
- list_type="[]const {T}",
17
- list_param_type="[]const i64",
16
+ list_type="std.ArrayList({T})",
17
+ list_param_type="*std.ArrayList(i64)",
18
18
  list_elem_type="i64",
19
- list_slice="{x}[@intCast({start})..@intCast({stop})]",
20
- list_copy="{x}",
19
+ list_slice="geListSlice(i64, {x}.items, @as(usize, @intCast({start})), @as(usize, @intCast({stop})))",
20
+ list_copy="geListCopy(i64, {x}.items)",
21
21
  borrow_list_arg=False,
22
22
  range_call="({lo}..{hi})",
23
23
  range_step_call="({lo}..{hi})",
24
- len_call="@as(i64, @intCast({x}.len))",
25
- list_len="@as(i64, @intCast({x}.len))",
24
+ len_call="@as(i64, @intCast({x}.items.len))",
25
+ list_len="@as(i64, @intCast({x}.items.len))",
26
26
  print_int='std.io.getStdOut().writer().print("{{d}}\\n", .{{{v}}}) catch unreachable',
27
27
  print_float='std.io.getStdOut().writer().print("{{d}}\\n", .{{{v}}}) catch unreachable',
28
28
  print_str='std.io.getStdOut().writer().print("{{s}}\\n", .{{{v}}}) catch unreachable',
29
29
  print_bool='std.io.getStdOut().writer().print("{{s}}\\n", .{{if ({v}) "True" else "False"}}) catch unreachable',
30
30
  print_generic='std.io.getStdOut().writer().print("{{any}}\\n", .{{{v}}}) catch unreachable',
31
- print_list='gePrintList({v})',
31
+ list_sort="std.mem.sort(i64, {x}.items, {{}}, std.sort.asc(i64))",
32
+ list_reverse="std.mem.reverse(i64, {x}.items)",
33
+ print_list='gePrintList({v}.items)',
32
34
  int_cast="@as(i64, {x})",
33
35
  float_cast="@as(f64, {x})",
34
36
  float_div="(@as(f64, {l}) / @as(f64, {r}))",
35
37
  floor_div="@divFloor({l}, {r})",
36
- sum_call="blk: {{ var s: i64 = 0; for ({it}) |v| {{ s += v; }} break :blk s; }}",
38
+ sum_call="blk: {{ var s: i64 = 0; for ({it}.items) |v| {{ s += v; }} break :blk s; }}",
37
39
  abs_int="@as(i64, @intCast(if ({x} < 0) -{x} else {x}))",
38
40
  abs_float="@abs({x})",
39
41
  min2_call="@min({a}, {b})",
40
42
  max2_call="@max({a}, {b})",
41
- min_call="@min({it}...)",
42
- max_call="@max({it}...)",
43
+ min_call="blk: {{ var __m = {it}.items[0]; for ({it}.items) |v| {{ if (v < __m) __m = v; }} break :blk __m; }}",
44
+ max_call="blk: {{ var __m = {it}.items[0]; for ({it}.items) |v| {{ if (v > __m) __m = v; }} break :blk __m; }}",
43
45
  pow_call="std.math.pow(i64, {l}, {r})",
44
46
  pow_int="std.math.pow(i64, {l}, {r})",
45
47
  pow_float="std.math.pow(f64, {l}, {r})",
46
- # Zig lists are `[]const i64` slices and cannot grow; list.append
47
- # is rejected with a clear diagnostic rather than emitting invalid Zig.
48
- append_call="",
49
- index_call="{x}[@as(usize, @intCast({i}))]",
48
+ append_call="{x}.append({v}) catch unreachable",
49
+ index_call="{x}.items[@as(usize, @intCast({i}))]",
50
50
  comment="//",
51
51
  fn_template="{sig} {{\n{body}\n}}",
52
52
  main_template="",
@@ -59,16 +59,24 @@ SPEC = Spec(
59
59
  str_slice="{x}[{start}..{end}]",
60
60
  str_slice_start="{x}[{start}..]",
61
61
  str_slice_end="{x}[..{end}]",
62
- list_concat="blk: {{ var t = std.ArrayList(i64).init(std.heap.page_allocator); t.appendSlice({l}) catch unreachable; t.appendSlice({r}) catch unreachable; break :blk t.toOwnedSlice() catch unreachable; }}",
63
- foreach_template="for ({iter}) |{var}|",
62
+ list_concat="geListConcat(i64, {l}.items, {r}.items)",
63
+ str_split="geStrSplit({x}, {sep})",
64
+ str_join="geStrJoin({x}.items, {sep})",
65
+ str_upper="geStrUpper({x})",
66
+ str_lower="geStrLower({x})",
67
+ str_replace="geStrReplace({x}, {a}, {b})",
68
+ foreach_template="for ({iter}.items) |{var}|",
64
69
  try_template="// try/except limited in Zig\n// {body}\n// {handler}",
65
70
  struct_template="const {name} = struct {{\n{fields}\n}};",
66
71
  struct_field_template=" {type}: {name},",
67
72
  struct_new_template="fn {name}_new({params}) {name} {{\n{body}\n}}",
68
73
  dict_type="std.StringHashMap({V})",
74
+ set_type="std.AutoHashMap(i64, void)",
69
75
  dict_get="{d}.get({k}).?",
70
76
  dict_set="{d}.put({k}, {v}) catch unreachable",
71
77
  dict_contains="{d}.contains({k})",
78
+ set_len="@as(i64, @intCast({x}.count()))",
79
+ dict_len="@as(i64, @intCast({x}.count()))",
72
80
  tuple_type="[2]{T}",
73
81
  tuple_get="{t}[{i}]",
74
82
  )
@@ -121,69 +129,49 @@ class ZigEmitter(Emitter):
121
129
  elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
122
130
  if node.func.attr == "append" and isinstance(node.func.value, ast.Name):
123
131
  mutated.add(node.func.value.id)
132
+ elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
133
+ # passing a list to a callee that mutates it counts as a
134
+ # mutation here, so the local must be `var`
135
+ for idx in self.func_mutated_params.get(node.func.id, ()):
136
+ if idx < len(node.args):
137
+ arg = node.args[idx]
138
+ if isinstance(arg, ast.Name):
139
+ mutated.add(arg.id)
124
140
  return mutated
125
141
 
126
142
  def emit(self, unit: FuncUnit) -> str:
127
- """Override emit to track mutated variables and make mutable param copies."""
143
+ """Emit a function.
144
+
145
+ A list parameter is a `*std.ArrayList(i64)`, so no conversion is
146
+ needed: the callee mutates the caller's list directly. Only scalar
147
+ parameters that are reassigned need a mutable local, because Zig
148
+ parameters are immutable.
149
+ """
128
150
  self.mutated = self._find_mutated_vars(unit.body)
129
- # Detect mutated list parameters (append, subscript assign, etc.)
130
- self.mutated_list_params: set[str] = set()
131
- for pname, ptype in unit.params:
132
- if ptype == "list" and pname in self.mutated:
133
- self.mutated_list_params.add(pname)
134
- # Zig parameters are const — make mutable copies for any scalar param that's mutated
135
- # List params that are mutated need ArrayList conversion
136
- mutated_params = []
137
- for pname, ptype in unit.params:
138
- if pname in self.mutated and ptype != "list":
139
- mutated_params.append(pname)
140
- # We'll insert mutable copies as the first lines of the body
141
- self._zig_mut_copies = mutated_params
142
- self._zig_list_conversions = list(self.mutated_list_params)
151
+ self.mutated_list_params = {
152
+ pname for pname, ptype in unit.params
153
+ if ptype == "list" and pname in self.mutated
154
+ }
155
+ self.mutated_params = {
156
+ pname for pname, ptype in unit.params
157
+ if pname in self.mutated and ptype != "list"
158
+ }
143
159
  result = super().emit(unit)
144
- if mutated_params or self.mutated_list_params:
145
- # Insert mutable copies and list conversions right after the opening brace
160
+ if self.mutated_params:
146
161
  lines = result.split("\n")
147
- insert_lines = []
148
- for pname in mutated_params:
149
- insert_lines.append(f" var {pname}_ = {pname};")
150
- for pname in self.mutated_list_params:
151
- # Convert []const i64 to ArrayList for mutation
152
- insert_lines.append(f" var {pname}_list = std.ArrayList(i64).init(std.heap.page_allocator);")
153
- insert_lines.append(f" for ({pname}) |item| {{ {pname}_list.append(item) catch unreachable; }}")
162
+ insert = [f" var {p}_ = {p};" for p in sorted(self.mutated_params)]
154
163
  for i, line in enumerate(lines):
155
- if "{" in line and "fn" in lines[i]:
156
- for j, il in enumerate(insert_lines):
164
+ if "fn" in line and "{" in line:
165
+ for j, il in enumerate(insert):
157
166
  lines.insert(i + 1 + j, il)
158
167
  break
159
168
  result = "\n".join(lines)
160
- # Replace param references with mutable version
161
169
  import re
162
- for pname in mutated_params:
163
- old = result
164
- result = re.sub(r'\b' + pname + r'\b', pname + "_", result)
165
- result = re.sub(r'\b' + pname + r'_: ', pname + ': ', result, count=1)
166
- result = result.replace(f"var {pname}_ = {pname}_;", f"var {pname}_ = {pname};")
167
- # Replace list param references with ArrayList.items where indexing, or the list itself for append
168
- for pname in self.mutated_list_params:
169
- # Replace append calls: pname.append(v) -> pname_list.append(v)
170
- result = re.sub(
171
- r'\b' + pname + r'\.append\(',
172
- pname + '_list.append(',
173
- result
174
- )
175
- # Replace indexing: pname[i] -> pname_list.items[i]
176
- result = re.sub(
177
- r'\b' + pname + r'\[',
178
- pname + '_list.items[',
179
- result
180
- )
181
- # Replace len(pname) -> pname_list.items.len
182
- result = re.sub(
183
- r'\b' + pname + r'\.len\b',
184
- pname + '_list.items.len',
185
- result
186
- )
170
+ for pname in self.mutated_params:
171
+ result = re.sub(r"\b" + pname + r"\b", pname + "_", result)
172
+ result = re.sub(r"\b" + pname + r"_: ", pname + ": ", result, count=1)
173
+ result = result.replace(f"var {pname}_ = {pname}_;",
174
+ f"var {pname}_ = {pname};")
187
175
  return result
188
176
 
189
177
  def _is_mutated(self, var: str) -> bool:
@@ -210,6 +198,12 @@ class ZigEmitter(Emitter):
210
198
  self.var_types[target] = t
211
199
  self.declared.add(target)
212
200
  nt = self.py_to_native(t)
201
+ if t == "list":
202
+ # a bare `list` annotation carries no element type; take it
203
+ # from the value so `"a,b".split(",")` is a string list
204
+ elem = self._list_elem_of_value(node.value)
205
+ if elem and elem != self.spec.list_elem_type:
206
+ nt = self.spec.list_type.format(T=elem)
213
207
  kw = self._decl_keyword(target)
214
208
  self.lines.append(f"{ind}{kw} {target}: {nt} = {self.expr(node.value)};")
215
209
  return
@@ -263,7 +257,7 @@ class ZigEmitter(Emitter):
263
257
  self.lines.append(f"{ind} const {var} = __kp_{var}.*;")
264
258
  else:
265
259
  self.var_types[var] = "long"
266
- self.lines.append(f"{ind}for ({iter_s}) |{var}| {{")
260
+ self.lines.append(f"{ind}for ({iter_s}.items) |{var}| {{")
267
261
  self.indent_lvl += 1
268
262
  for s in node.body:
269
263
  self.stmt(s)
@@ -274,7 +268,7 @@ class ZigEmitter(Emitter):
274
268
  emit_name = unit.name.replace(".", "_")
275
269
  params = []
276
270
  for pname, ptype in unit.params:
277
- nt = self.param_native_type(ptype)
271
+ nt = self.param_native_type(ptype, pname)
278
272
  params.append(f"{pname}: {nt}")
279
273
  ret = self.py_to_native(unit.ret_type) if unit.ret_type != "None" else "void"
280
274
  param_str = ", ".join(params)
@@ -321,6 +315,23 @@ def _emit_structs_zig(classes: list) -> str:
321
315
  return "\n".join(out)
322
316
 
323
317
 
318
+ def _precompute_mutated_params(emitter, units) -> None:
319
+ """Record which parameters each function mutates.
320
+
321
+ The emitter normally learns this as it emits each unit, so a function
322
+ defined after its caller would not be known yet. Doing it up front makes
323
+ call-site handling independent of definition order.
324
+ """
325
+ for u in units:
326
+ if u.body is None:
327
+ continue
328
+ mutated = emitter._find_mutated_vars(u.body.body)
329
+ indices = {i for i, (pname, _pt) in enumerate(u.params)
330
+ if pname in mutated}
331
+ if indices:
332
+ emitter.func_mutated_params[u.name] = indices
333
+
334
+
324
335
  def emit_zig(units: list[FuncUnit], entry: str | None,
325
336
  library_mode: bool = False,
326
337
  extern_fns: list[FuncUnit] | None = None,
@@ -340,6 +351,7 @@ def emit_zig(units: list[FuncUnit], entry: str | None,
340
351
  }
341
352
  emitter.library_mode = library_mode
342
353
  emitter.constants = constants or {}
354
+ _precompute_mutated_params(emitter, units)
343
355
  emitted: dict[str, str] = {}
344
356
  fns: list[str] = []
345
357
 
@@ -411,9 +423,13 @@ def emit_zig(units: list[FuncUnit], entry: str | None,
411
423
 
412
424
  if entry_u.name != "main":
413
425
  if entry_u.ret_type == "None":
414
- wrapper = "pub fn main() void {\n " + entry_u.name + "();\n}\n"
426
+ wrapper = ("pub fn main() void {\n"
427
+ " defer _ = ge_arena.deinit();\n"
428
+ " " + entry_u.name + "();\n}\n")
415
429
  else:
416
- wrapper = f"pub fn main() void {{\n _ = {entry_u.name}();\n}}\n"
430
+ wrapper = (f"pub fn main() void {{\n"
431
+ f" defer _ = ge_arena.deinit();\n"
432
+ f" _ = {entry_u.name}();\n}}\n")
417
433
  fns.append(wrapper)
418
434
  elif not fns:
419
435
  # the entry function was rejected during emission; the pipeline
@@ -421,13 +437,19 @@ def emit_zig(units: list[FuncUnit], entry: str | None,
421
437
  return prelude, emitted
422
438
  else:
423
439
  # Zig main() must return void (or u8 for exit code)
424
- # Rename the user's main() to __ge_main() and add a wrapper
440
+ # Rename the user's main() to __ge_main() and add a wrapper that also
441
+ # releases the arena.
425
442
  if entry_u.ret_type != "None":
426
443
  fns[-1] = fns[-1].replace(f"fn {entry_u.name}(", "fn __ge_main(")
427
- wrapper = "pub fn main() void {\n _ = __ge_main();\n}\n"
428
- fns.append(wrapper)
444
+ wrapper = ("pub fn main() void {\n"
445
+ " defer _ = ge_arena.deinit();\n"
446
+ " _ = __ge_main();\n}\n")
429
447
  else:
430
- fns[-1] = fns[-1].replace(f"fn {entry_u.name}(", "pub fn main(")
448
+ fns[-1] = fns[-1].replace(f"fn {entry_u.name}(", "fn __ge_main(")
449
+ wrapper = ("pub fn main() void {\n"
450
+ " defer _ = ge_arena.deinit();\n"
451
+ " __ge_main();\n}\n")
452
+ fns.append(wrapper)
431
453
 
432
454
  program = prelude + "\n".join(fns) + "\n"
433
455
  # inject stdlib runtime
@@ -211,11 +211,27 @@ def cmd_analyze(args) -> int:
211
211
  return 1
212
212
 
213
213
  source = source_path.read_text(encoding="utf-8")
214
+ original_source = source
214
215
  entry = args.entry or "main"
215
216
 
217
+ # Lower non-Python flavours (.ge hybrid, .ge.ts) before analysis, exactly
218
+ # as build() does. Without this, `ge analyze` parses a hybrid file as
219
+ # plain Python and reports a syntax error on the first block tag.
220
+ # resolve_imports lowers internally, so it gets the original source.
221
+ from .frontends import frontend_for
222
+ from .frontends.typescript import TypeScriptSyntaxError
223
+ from .modules import _lower_source
224
+
225
+ if frontend_for(source_path) != "python":
226
+ try:
227
+ source = _lower_source(source, source_path)
228
+ except TypeScriptSyntaxError as exc:
229
+ print(f"Error: {exc}", file=sys.stderr)
230
+ return 1
231
+
216
232
  # Resolve imports first
217
233
  try:
218
- units, classes, warnings = resolve_imports(source, source_path)
234
+ units, classes, warnings = resolve_imports(original_source, source_path)
219
235
  for w in warnings:
220
236
  print(f" warning: {w}")
221
237
  except Exception as e:
@@ -411,8 +427,96 @@ def _print_flutter(report) -> None:
411
427
  print(f" ! {e}")
412
428
 
413
429
 
430
+ def _ui_node_to_tree(node) -> dict:
431
+ """Convert a .ge.ui WidgetNode into the dict dartgen.generate_main wants."""
432
+ if node is None:
433
+ return {"kind": "Column", "children": [], "align": "center"}
434
+ d: dict = {"kind": node.kind}
435
+ if node.children:
436
+ d["children"] = [_ui_node_to_tree(c) for c in node.children]
437
+ child = node.props.get("child")
438
+ if child is not None and hasattr(child, "kind"):
439
+ d["child"] = _ui_node_to_tree(child)
440
+ if node.text is not None:
441
+ d["text"] = node.text
442
+ elif "text" in node.props:
443
+ d["text"] = node.props["text"]
444
+ if node.label is not None:
445
+ d["label"] = node.label
446
+ elif "label" in node.props:
447
+ d["label"] = node.props["label"]
448
+ if node.state:
449
+ d["state"] = node.state
450
+ style = node.props.get("style") or node.style_dict.get("style")
451
+ if style:
452
+ d["style"] = style
453
+ for key in ("align", "padding", "color", "hint"):
454
+ if key in node.props:
455
+ d[key] = node.props[key]
456
+ if node.action:
457
+ d["action"] = node.action
458
+ return d
459
+
460
+
461
+ def _flutter_from_ui(args, ui_path: Path) -> int:
462
+ """`ge flutter foo.ge.ui` — generate a Flutter project from the UI DSL."""
463
+ from .ui_dsl import parse_ui_file
464
+ from .dartgen import generate_main, generate_pubspec
465
+ from .config import Config
466
+
467
+ try:
468
+ screen = parse_ui_file(ui_path)
469
+ except SyntaxError as e:
470
+ print(f"error: invalid .ge.ui — {e}", file=sys.stderr)
471
+ return 1
472
+
473
+ app_name = args.app_name or ui_path.stem
474
+ cfg = Config(out_dir=Path(args.out_dir), target="mobile")
475
+ project_dir = cfg.target_dir / app_name
476
+ lib_dir = project_dir / "lib"
477
+ lib_dir.mkdir(parents=True, exist_ok=True)
478
+
479
+ tree = _ui_node_to_tree(screen.root)
480
+ title = screen.title or app_name
481
+ main_dart = generate_main(tree, [], app_title=title)
482
+
483
+ (lib_dir / "main.dart").write_text(main_dart, encoding="utf-8")
484
+ # generate_main always imports bindings.dart; a UI-only project has no
485
+ # FFI exports, so emit the stub rather than leaving a dangling import.
486
+ from .dartgen import generate_bindings
487
+ (lib_dir / "bindings.dart").write_text(
488
+ generate_bindings([], app_name), encoding="utf-8")
489
+ (project_dir / "pubspec.yaml").write_text(
490
+ generate_pubspec(app_name), encoding="utf-8")
491
+
492
+ print(GE_BANNER)
493
+ print()
494
+ print(f"== GE flutter app: {project_dir} ==")
495
+ print(f"Title : {title}")
496
+ print(f"Dart files : {lib_dir / 'main.dart'}")
497
+ print(f"\nNext: cd {project_dir} && flutter pub get && flutter run")
498
+
499
+ dart = _find_dart()
500
+ if dart:
501
+ r = subprocess.run([dart, "analyze", str(lib_dir)],
502
+ capture_output=True, text=True, timeout=120)
503
+ out = ((r.stdout or "") + (r.stderr or "")).strip()
504
+ print(out[:1200] if out else "(analyzer: no output)")
505
+ print("Dart analysis: " + ("PASS" if r.returncode == 0 else "issues found"))
506
+ else:
507
+ print("\n(dart not found — install Flutter to validate generated Dart)")
508
+ return 0
509
+
510
+
414
511
  def cmd_flutter(args) -> int:
415
- source = _maybe_transpile_ts(Path(args.file))
512
+ src_path = Path(args.file)
513
+ # A .ge.ui file is a UI definition, not GE source. `ge flutter` used to
514
+ # hand it to the module resolver, which tried to parse `Window {` as
515
+ # Python and failed. Generate the Dart UI directly instead.
516
+ if src_path.suffix == ".ui":
517
+ return _flutter_from_ui(args, src_path)
518
+
519
+ source = _maybe_transpile_ts(src_path)
416
520
  # a Flutter app is a mobile artifact, so it always lands in build/mobile/
417
521
  cfg = Config(out_dir=Path(args.out_dir), target="mobile",
418
522
  force_backend=None if args.backend == "auto" else args.backend,
@@ -502,12 +606,14 @@ def cmd_react(args) -> int:
502
606
  return 1
503
607
 
504
608
  app_name = args.app_name or ui_path.stem.replace(".ge", "")
505
- # default: sibling web/frontend next to the ui/ directory
609
+ # Output goes under the target-aware build root so every artifact of a
610
+ # project lives in one place: build/web/frontend. An explicit -o wins.
506
611
  if args.out_dir:
507
612
  out_dir = Path(args.out_dir)
508
613
  else:
509
- base = ui_path.parent.parent if ui_path.parent.name == "ui" else ui_path.parent
510
- out_dir = base / "web" / "frontend"
614
+ from .config import Config
615
+ cfg = Config(target="web")
616
+ out_dir = cfg.target_dir / "frontend"
511
617
 
512
618
  files = generate_react_app(screen, app_name)
513
619
  written = write_react_app(files, out_dir, force=args.force)
@@ -298,17 +298,125 @@ fun ge_log(x: Double): Double = ln(x)
298
298
  fun ge_exp(x: Double): Double = exp(x)
299
299
  ''',
300
300
  "zig": '''
301
- // GE Math runtime (Zig)
302
- fn gePrintList(v: []const i64) void {
301
+ // GE runtime (Zig)
302
+ //
303
+ // GE lists are growable, so they need an allocator. A generated program
304
+ // runs start-to-finish, so a single arena is the right fit: it amortises
305
+ // the page-grain cost across many small lists and everything is released
306
+ // in one call. (std.heap.page_allocator alone would issue a syscall per
307
+ // allocation, and GeneralPurposeAllocator is a debugging allocator.)
308
+ var ge_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
309
+ const ge_alloc = ge_arena.allocator();
310
+
311
+ fn geListNew(comptime T: type) std.ArrayList(T) {
312
+ return std.ArrayList(T).init(ge_alloc);
313
+ }
314
+
315
+ fn geListFrom(comptime T: type, items: []const T) std.ArrayList(T) {
316
+ var l = std.ArrayList(T).init(ge_alloc);
317
+ l.appendSlice(items) catch unreachable;
318
+ return l;
319
+ }
320
+
321
+ fn geListCopy(comptime T: type, src: []const T) std.ArrayList(T) {
322
+ return geListFrom(T, src);
323
+ }
324
+
325
+ fn geListSlice(comptime T: type, src: []const T, lo: usize, hi: usize) std.ArrayList(T) {
326
+ var l = std.ArrayList(T).init(ge_alloc);
327
+ l.appendSlice(src[lo..hi]) catch unreachable;
328
+ return l;
329
+ }
330
+
331
+ fn geListConcat(comptime T: type, a: []const T, b: []const T) std.ArrayList(T) {
332
+ var l = std.ArrayList(T).init(ge_alloc);
333
+ l.appendSlice(a) catch unreachable;
334
+ l.appendSlice(b) catch unreachable;
335
+ return l;
336
+ }
337
+
338
+ // Python renders a sequence as [1, 2, 3] and quotes strings with '.
339
+ fn gePrintList(v: anytype) void {
303
340
  const w = std.io.getStdOut().writer();
304
341
  w.print("[", .{}) catch unreachable;
305
342
  for (v, 0..) |x, i| {
306
343
  if (i > 0) w.print(", ", .{}) catch unreachable;
307
- w.print("{d}", .{x}) catch unreachable;
344
+ switch (@typeInfo(@TypeOf(x))) {
345
+ .pointer => w.print("'{s}'", .{x}) catch unreachable,
346
+ .float => w.print("{d}", .{x}) catch unreachable,
347
+ .bool => w.print("{s}", .{if (x) "True" else "False"}) catch unreachable,
348
+ else => w.print("{d}", .{x}) catch unreachable,
349
+ }
308
350
  }
309
351
  w.print("]\\n", .{}) catch unreachable;
310
352
  }
311
353
 
354
+ fn geSetNew(comptime T: type) std.AutoHashMap(T, void) {
355
+ return std.AutoHashMap(T, void).init(ge_alloc);
356
+ }
357
+
358
+ fn geSetFrom(comptime T: type, items: []const T) std.AutoHashMap(T, void) {
359
+ var s = std.AutoHashMap(T, void).init(ge_alloc);
360
+ for (items) |x| s.put(x, {{}}) catch unreachable;
361
+ return s;
362
+ }
363
+ fn geStrSplit(s: []const u8, sep: []const u8) std.ArrayList([]const u8) {
364
+ var l = std.ArrayList([]const u8).init(ge_alloc);
365
+ if (sep.len == 0) {
366
+ l.append(s) catch unreachable;
367
+ return l;
368
+ }
369
+ var it = std.mem.splitSequence(u8, s, sep);
370
+ while (it.next()) |part| l.append(part) catch unreachable;
371
+ return l;
372
+ }
373
+
374
+ fn geStrJoin(parts: []const []const u8, sep: []const u8) []const u8 {
375
+ var total: usize = 0;
376
+ for (parts, 0..) |p, i| {
377
+ total += p.len;
378
+ if (i > 0) total += sep.len;
379
+ }
380
+ const buf = ge_alloc.alloc(u8, total) catch unreachable;
381
+ var pos: usize = 0;
382
+ for (parts, 0..) |p, i| {
383
+ if (i > 0) {
384
+ @memcpy(buf[pos..][0..sep.len], sep);
385
+ pos += sep.len;
386
+ }
387
+ @memcpy(buf[pos..][0..p.len], p);
388
+ pos += p.len;
389
+ }
390
+ return buf;
391
+ }
392
+
393
+ fn geStrReplace(s: []const u8, from: []const u8, to: []const u8) []const u8 {
394
+ if (from.len == 0) return s;
395
+ var out = std.ArrayList(u8).init(ge_alloc);
396
+ var i: usize = 0;
397
+ while (i < s.len) {
398
+ if (i + from.len <= s.len and std.mem.eql(u8, s[i..][0..from.len], from)) {
399
+ out.appendSlice(to) catch unreachable;
400
+ i += from.len;
401
+ } else {
402
+ out.append(s[i]) catch unreachable;
403
+ i += 1;
404
+ }
405
+ }
406
+ return out.items;
407
+ }
408
+
409
+ fn geStrUpper(s: []const u8) []const u8 {
410
+ const buf = ge_alloc.alloc(u8, s.len) catch unreachable;
411
+ for (s, 0..) |c, i| buf[i] = std.ascii.toUpper(c);
412
+ return buf;
413
+ }
414
+
415
+ fn geStrLower(s: []const u8) []const u8 {
416
+ const buf = ge_alloc.alloc(u8, s.len) catch unreachable;
417
+ for (s, 0..) |c, i| buf[i] = std.ascii.toLower(c);
418
+ return buf;
419
+ }
312
420
  fn ge_sqrt(x: f64) f64 { return @sqrt(x); }
313
421
  fn ge_floor(x: f64) f64 { return @floor(x); }
314
422
  fn ge_ceil(x: f64) f64 { return @ceil(x); }