gelang 0.1.3 → 0.1.5

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.
@@ -13,31 +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 i64",
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="geListSlice(i64, {x}.items, @as(usize, @intCast({start})), @as(usize, @intCast({stop})))",
20
+ list_copy="geListCopy(i64, {x}.items)",
19
21
  borrow_list_arg=False,
20
22
  range_call="({lo}..{hi})",
21
23
  range_step_call="({lo}..{hi})",
22
- len_call="@as(i64, @intCast({x}.len))",
23
- 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))",
24
26
  print_int='std.io.getStdOut().writer().print("{{d}}\\n", .{{{v}}}) catch unreachable',
25
27
  print_float='std.io.getStdOut().writer().print("{{d}}\\n", .{{{v}}}) catch unreachable',
26
28
  print_str='std.io.getStdOut().writer().print("{{s}}\\n", .{{{v}}}) catch unreachable',
27
- print_bool='std.io.getStdOut().writer().print("{}\\n", .{{{v}}}) catch unreachable',
29
+ print_bool='std.io.getStdOut().writer().print("{{s}}\\n", .{{if ({v}) "True" else "False"}}) catch unreachable',
28
30
  print_generic='std.io.getStdOut().writer().print("{{any}}\\n", .{{{v}}}) catch unreachable',
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)',
29
34
  int_cast="@as(i64, {x})",
30
35
  float_cast="@as(f64, {x})",
31
36
  float_div="(@as(f64, {l}) / @as(f64, {r}))",
32
37
  floor_div="@divFloor({l}, {r})",
33
- sum_call="blk: {{ var s: i64 = 0; for ({it}) |v| {{ s += v; }} break :blk s; }}",
34
- abs_int="@intCast(if ({x} < 0) -{x} else {x})",
38
+ sum_call="blk: {{ var s: i64 = 0; for ({it}.items) |v| {{ s += v; }} break :blk s; }}",
39
+ abs_int="@as(i64, @intCast(if ({x} < 0) -{x} else {x}))",
35
40
  abs_float="@abs({x})",
36
- min_call="@min({it}...)",
37
- max_call="@max({it}...)",
41
+ min2_call="@min({a}, {b})",
42
+ max2_call="@max({a}, {b})",
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; }}",
38
45
  pow_call="std.math.pow(i64, {l}, {r})",
46
+ pow_int="std.math.pow(i64, {l}, {r})",
47
+ pow_float="std.math.pow(f64, {l}, {r})",
39
48
  append_call="{x}.append({v}) catch unreachable",
40
- index_call="{x}[@as(usize, @intCast({i}))]",
49
+ index_call="{x}.items[@as(usize, @intCast({i}))]",
41
50
  comment="//",
42
51
  fn_template="{sig} {{\n{body}\n}}",
43
52
  main_template="",
@@ -50,16 +59,24 @@ SPEC = Spec(
50
59
  str_slice="{x}[{start}..{end}]",
51
60
  str_slice_start="{x}[{start}..]",
52
61
  str_slice_end="{x}[..{end}]",
53
- 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; }}",
54
- 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}|",
55
69
  try_template="// try/except limited in Zig\n// {body}\n// {handler}",
56
70
  struct_template="const {name} = struct {{\n{fields}\n}};",
57
71
  struct_field_template=" {type}: {name},",
58
72
  struct_new_template="fn {name}_new({params}) {name} {{\n{body}\n}}",
59
73
  dict_type="std.StringHashMap({V})",
74
+ set_type="std.AutoHashMap(i64, void)",
60
75
  dict_get="{d}.get({k}).?",
61
76
  dict_set="{d}.put({k}, {v}) catch unreachable",
62
77
  dict_contains="{d}.contains({k})",
78
+ set_len="@as(i64, @intCast({x}.count()))",
79
+ dict_len="@as(i64, @intCast({x}.count()))",
63
80
  tuple_type="[2]{T}",
64
81
  tuple_get="{t}[{i}]",
65
82
  )
@@ -112,69 +129,49 @@ class ZigEmitter(Emitter):
112
129
  elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
113
130
  if node.func.attr == "append" and isinstance(node.func.value, ast.Name):
114
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)
115
140
  return mutated
116
141
 
117
142
  def emit(self, unit: FuncUnit) -> str:
118
- """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
+ """
119
150
  self.mutated = self._find_mutated_vars(unit.body)
120
- # Detect mutated list parameters (append, subscript assign, etc.)
121
- self.mutated_list_params: set[str] = set()
122
- for pname, ptype in unit.params:
123
- if ptype == "list" and pname in self.mutated:
124
- self.mutated_list_params.add(pname)
125
- # Zig parameters are const — make mutable copies for any scalar param that's mutated
126
- # List params that are mutated need ArrayList conversion
127
- mutated_params = []
128
- for pname, ptype in unit.params:
129
- if pname in self.mutated and ptype != "list":
130
- mutated_params.append(pname)
131
- # We'll insert mutable copies as the first lines of the body
132
- self._zig_mut_copies = mutated_params
133
- 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
+ }
134
159
  result = super().emit(unit)
135
- if mutated_params or self.mutated_list_params:
136
- # Insert mutable copies and list conversions right after the opening brace
160
+ if self.mutated_params:
137
161
  lines = result.split("\n")
138
- insert_lines = []
139
- for pname in mutated_params:
140
- insert_lines.append(f" var {pname}_ = {pname};")
141
- for pname in self.mutated_list_params:
142
- # Convert []const i64 to ArrayList for mutation
143
- insert_lines.append(f" var {pname}_list = std.ArrayList(i64).init(std.heap.page_allocator);")
144
- 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)]
145
163
  for i, line in enumerate(lines):
146
- if "{" in line and "fn" in lines[i]:
147
- for j, il in enumerate(insert_lines):
164
+ if "fn" in line and "{" in line:
165
+ for j, il in enumerate(insert):
148
166
  lines.insert(i + 1 + j, il)
149
167
  break
150
168
  result = "\n".join(lines)
151
- # Replace param references with mutable version
152
169
  import re
153
- for pname in mutated_params:
154
- old = result
155
- result = re.sub(r'\b' + pname + r'\b', pname + "_", result)
156
- result = re.sub(r'\b' + pname + r'_: ', pname + ': ', result, count=1)
157
- result = result.replace(f"var {pname}_ = {pname}_;", f"var {pname}_ = {pname};")
158
- # Replace list param references with ArrayList.items where indexing, or the list itself for append
159
- for pname in self.mutated_list_params:
160
- # Replace append calls: pname.append(v) -> pname_list.append(v)
161
- result = re.sub(
162
- r'\b' + pname + r'\.append\(',
163
- pname + '_list.append(',
164
- result
165
- )
166
- # Replace indexing: pname[i] -> pname_list.items[i]
167
- result = re.sub(
168
- r'\b' + pname + r'\[',
169
- pname + '_list.items[',
170
- result
171
- )
172
- # Replace len(pname) -> pname_list.items.len
173
- result = re.sub(
174
- r'\b' + pname + r'\.len\b',
175
- pname + '_list.items.len',
176
- result
177
- )
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};")
178
175
  return result
179
176
 
180
177
  def _is_mutated(self, var: str) -> bool:
@@ -194,12 +191,19 @@ class ZigEmitter(Emitter):
194
191
  self.lines.append(f"{ind}_ = {self.expr(node.value)};")
195
192
  return
196
193
  if isinstance(node, ast.AnnAssign) and node.value is not None:
194
+ # bare `list` gets its element type from the value
197
195
  target = node.target.id if isinstance(node.target, ast.Name) else self.expr(node.target)
198
196
  t = self._ann_type(node.annotation) if isinstance(node.target, ast.Name) else self.infer_type(node.value)
199
197
  if isinstance(node.target, ast.Name):
200
198
  self.var_types[target] = t
201
199
  self.declared.add(target)
202
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)
203
207
  kw = self._decl_keyword(target)
204
208
  self.lines.append(f"{ind}{kw} {target}: {nt} = {self.expr(node.value)};")
205
209
  return
@@ -244,8 +248,16 @@ class ZigEmitter(Emitter):
244
248
  self.lines.append(f"{ind}while ({var} < {hi}) : ({var} += 1) {{")
245
249
  else:
246
250
  iter_s = self.expr(it)
247
- self.var_types[var] = "long"
248
- self.lines.append(f"{ind}for ({iter_s}) |{var}| {{")
251
+ if self.infer_type(it) == "dict":
252
+ # Zig has no allocator here, so walk the map's key iterator
253
+ # instead of materialising a key list.
254
+ self.var_types[var] = "str"
255
+ self.lines.append(f"{ind}var __keys_{var} = {iter_s}.keyIterator();")
256
+ self.lines.append(f"{ind}while (__keys_{var}.next()) |__kp_{var}| {{")
257
+ self.lines.append(f"{ind} const {var} = __kp_{var}.*;")
258
+ else:
259
+ self.var_types[var] = "long"
260
+ self.lines.append(f"{ind}for ({iter_s}.items) |{var}| {{")
249
261
  self.indent_lvl += 1
250
262
  for s in node.body:
251
263
  self.stmt(s)
@@ -256,7 +268,7 @@ class ZigEmitter(Emitter):
256
268
  emit_name = unit.name.replace(".", "_")
257
269
  params = []
258
270
  for pname, ptype in unit.params:
259
- nt = self.param_native_type(ptype)
271
+ nt = self.param_native_type(ptype, pname)
260
272
  params.append(f"{pname}: {nt}")
261
273
  ret = self.py_to_native(unit.ret_type) if unit.ret_type != "None" else "void"
262
274
  param_str = ", ".join(params)
@@ -303,6 +315,23 @@ def _emit_structs_zig(classes: list) -> str:
303
315
  return "\n".join(out)
304
316
 
305
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
+
306
335
  def emit_zig(units: list[FuncUnit], entry: str | None,
307
336
  library_mode: bool = False,
308
337
  extern_fns: list[FuncUnit] | None = None,
@@ -315,8 +344,14 @@ def emit_zig(units: list[FuncUnit], entry: str | None,
315
344
  extern_fns: functions from other backends that this Zig code calls.
316
345
  """
317
346
  emitter = ZigEmitter(SPEC)
347
+ emitter.func_signatures = {
348
+ u.name: ([p for p, _t in u.params],
349
+ dict(getattr(u, 'param_defaults', {})))
350
+ for u in units
351
+ }
318
352
  emitter.library_mode = library_mode
319
353
  emitter.constants = constants or {}
354
+ _precompute_mutated_params(emitter, units)
320
355
  emitted: dict[str, str] = {}
321
356
  fns: list[str] = []
322
357
 
@@ -388,19 +423,33 @@ def emit_zig(units: list[FuncUnit], entry: str | None,
388
423
 
389
424
  if entry_u.name != "main":
390
425
  if entry_u.ret_type == "None":
391
- 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")
392
429
  else:
393
- 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")
394
433
  fns.append(wrapper)
434
+ elif not fns:
435
+ # the entry function was rejected during emission; the pipeline
436
+ # already has the reason, so emit a stub rather than crashing
437
+ return prelude, emitted
395
438
  else:
396
439
  # Zig main() must return void (or u8 for exit code)
397
- # 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.
398
442
  if entry_u.ret_type != "None":
399
443
  fns[-1] = fns[-1].replace(f"fn {entry_u.name}(", "fn __ge_main(")
400
- wrapper = "pub fn main() void {\n _ = __ge_main();\n}\n"
401
- fns.append(wrapper)
444
+ wrapper = ("pub fn main() void {\n"
445
+ " defer _ = ge_arena.deinit();\n"
446
+ " _ = __ge_main();\n}\n")
402
447
  else:
403
- 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)
404
453
 
405
454
  program = prelude + "\n".join(fns) + "\n"
406
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)