gelang 0.1.6 → 0.1.7

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,44 @@ 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
+ ## [0.1.7] — 2026-09-13
8
+
9
+ Found while building a real application (a Flutter AI keyboard with a Rust
10
+ memory layer and a C# core) against the published package.
11
+
12
+ ### Fixed
13
+
14
+ - **Rust string parameters moved on first use.** `fn f(text: str)` emitted
15
+ `text: String`, so a parameter used twice failed with E0382. Parameters are
16
+ now `&str` and call sites borrow, which is the documented Rust idiom
17
+ ("prefer the borrowed type over borrowing the owned type"); a `str`
18
+ returning function converts back to an owned `String` on the way out.
19
+ - **`s[i]` returned a code point, not a character.** Python's `s[i]` is a
20
+ one-character *string*, so `if s[0] == "a"` compared an integer with a
21
+ string and failed to compile on every backend.
22
+ - **Zig could not compare strings.** `a == b` on two `[]const u8` is invalid;
23
+ equality now goes through `std.mem.eql`.
24
+ - **`if len(s) < n` produced invalid Rust.** An unparenthesised cast made
25
+ `x.len() as i64 < 2` parse as a generic argument list.
26
+ - **Rust string slicing and `contains`** needed `usize` ranges and `&str`
27
+ patterns respectively.
28
+ - **Zig `int(3.9)`** was rejected (`@as(i64, 3.9)` cannot coerce); it now
29
+ uses `@intFromFloat`.
30
+ - **Zig read-only list parameters** were `*std.ArrayList`, which forced the
31
+ caller to declare the list `var`. They are now `*const std.ArrayList`.
32
+ - **Module-level string constants** were typed as integers, so `A + B` on two
33
+ string constants emitted integer addition.
34
+ - **`ge analyze` did not know about imports.** Every call across a module
35
+ boundary was reported as `GE007 called but not defined`; the resolved units
36
+ are now passed to the type checker.
37
+ - **Generated Flutter** had three analyzer warnings: an unused FFI handle and
38
+ a deprecated `withOpacity`. `flutter analyze` now reports no issues.
39
+
40
+ ### Changed
41
+
42
+ - `examples/` is gitignored. It holds experiment projects built against the
43
+ published package to exercise the compiler, not part of the release.
44
+
7
45
  ## [Unreleased]
8
46
 
9
47
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gelang",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",
@@ -72,9 +72,9 @@ def generate_bindings(ffi_units: list[FuncUnit], lib_basename: str,
72
72
  if multi:
73
73
  # multi-backend: one DynamicLibrary per backend
74
74
  for b in sorted(backends_used):
75
- lines.append(f" late final DynamicLibrary _lib_{b};")
75
+ lines.append(f" // ignore: unused_field\n late final DynamicLibrary _lib_{b};")
76
76
  else:
77
- lines.append(" late final DynamicLibrary _lib;")
77
+ lines.append(" // ignore: unused_field\n late final DynamicLibrary _lib;")
78
78
 
79
79
  for u in ffi_units:
80
80
  lines.append(f" late final {dart_name(u.name)}Dart {dart_name(u.name)};")
@@ -306,6 +306,7 @@ class HomePage extends StatefulWidget {{
306
306
  }}
307
307
 
308
308
  class _HomePageState extends State<HomePage> {{
309
+ // ignore: unused_field — the UI-only path has no native exports yet
309
310
  final NativeLib _lib = NativeLib();
310
311
 
311
312
  {state_decls}
@@ -353,7 +354,7 @@ class GeTagged extends StatelessWidget {{
353
354
  }},
354
355
  child: Container(
355
356
  decoration: BoxDecoration(
356
- border: Border.all(color: Colors.blue.withOpacity(0.3), width: 1),
357
+ border: Border.all(color: Colors.blue.withValues(alpha: 0.3), width: 1),
357
358
  ),
358
359
  child: child,
359
360
  ),
@@ -51,6 +51,9 @@ class Spec:
51
51
  # Type-aware exponentiation. Integer `**` and float `**` need different
52
52
  # target syntax in most languages; when these are empty the emitter falls
53
53
  # back to pow_call.
54
+ # Converting a float to int needs a different spelling on some
55
+ # targets (Zig rejects @as(i64, 3.9)); empty falls back to int_cast.
56
+ int_cast_float: str = ""
54
57
  pow_int: str = ""
55
58
  pow_float: str = ""
56
59
  # two-argument min(a, b) / max(a, b). Most targets have no plain
@@ -164,6 +167,9 @@ class Emitter:
164
167
  # reset unsupported tracking for this function
165
168
  self.unsupported_emissions = []
166
169
  self.current_func = unit.name
170
+ # declared return type, so a borrowed &str can be converted back to an
171
+ # owned String on the way out (Rust idiom: borrow in, own out)
172
+ self.current_ret_type = unit.ret_type
167
173
  # load container element types for this function's params
168
174
  self.param_elem_types = getattr(unit, 'param_elem_types', {})
169
175
  self.param_dict_types = getattr(unit, 'param_dict_types', {})
@@ -268,6 +274,9 @@ class Emitter:
268
274
  return self.spec.dict_type.format(V=self.spec.list_elem_type)
269
275
  if t == "tuple":
270
276
  return self.spec.tuple_type.format(T=self.spec.list_elem_type)
277
+ if t == "str" and self.spec.name == "rust":
278
+ # borrowed: an owned String parameter would be moved on first use
279
+ return "&str"
271
280
  if t == "set":
272
281
  if self.spec.set_type:
273
282
  if self.spec.name == "cpp":
@@ -330,6 +339,14 @@ class Emitter:
330
339
  and node.value.id in self.mutated_params
331
340
  and self.var_types.get(node.value.id) == "list"):
332
341
  self.lines.append(f"{ind}return (*{node.value.id}).clone();")
342
+ elif (self.spec.name == "rust"
343
+ and self.current_ret_type == "str"
344
+ and isinstance(node.value, ast.Name)
345
+ and node.value.id in self.params):
346
+ # str parameters are borrowed &str, but a str-returning
347
+ # function must hand back an owned String
348
+ self.lines.append(
349
+ f"{ind}return {self.expr(node.value)}.to_string();")
333
350
  else:
334
351
  self.lines.append(f"{ind}return {self.expr(node.value)};")
335
352
  elif isinstance(node, ast.Assign):
@@ -1114,7 +1131,8 @@ class Emitter:
1114
1131
  elif self.spec.name == "go":
1115
1132
  contains = f"contains({right}, {cur})"
1116
1133
  elif self.spec.name == "kotlin":
1117
- contains = f"{right}.contains({cur})"
1134
+ # contains() takes a Pattern, not a String
1135
+ contains = f"{right}.contains({cur}.as_str())"
1118
1136
  else:
1119
1137
  contains = f"/*contains*/"
1120
1138
  if isinstance(op, ast.NotIn):
@@ -1124,7 +1142,8 @@ class Emitter:
1124
1142
  else:
1125
1143
  # string contains
1126
1144
  if self.spec.name == "rust":
1127
- contains = f"{right}.contains({cur})"
1145
+ # contains() takes a Pattern, not a String
1146
+ contains = f"{right}.contains({cur}.as_str())"
1128
1147
  elif self.spec.name == "cpp":
1129
1148
  contains = f"({right}.find({cur}) != std::string::npos)"
1130
1149
  elif self.spec.name == "csharp":
@@ -1143,7 +1162,17 @@ class Emitter:
1143
1162
  else:
1144
1163
  sym = self.cmp_symbol(op)
1145
1164
  right = self.expr(comp)
1146
- parts.append(f"({cur} {sym} {right})")
1165
+ right_type = self.infer_type(comp)
1166
+ # Zig cannot compare slices with ==; string equality goes
1167
+ # through std.mem.eql.
1168
+ if (self.spec.name == "zig"
1169
+ and (left_type == "str" or right_type == "str")
1170
+ and isinstance(op, (ast.Eq, ast.NotEq))):
1171
+ eql = f"std.mem.eql(u8, {cur}, {right})"
1172
+ parts.append(f"(!{eql})" if isinstance(op, ast.NotEq)
1173
+ else eql)
1174
+ else:
1175
+ parts.append(f"({cur} {sym} {right})")
1147
1176
  cur = right
1148
1177
  return "(" + " && ".join(parts) + ")"
1149
1178
  if isinstance(node, ast.IfExp):
@@ -1423,7 +1452,11 @@ class Emitter:
1423
1452
  if fname == "pow":
1424
1453
  return self.spec.pow_call.format(l=self.expr(node.args[0]), r=self.expr(node.args[1]))
1425
1454
  if fname == "int":
1426
- return f"({self.spec.int_cast.format(x=self.expr(node.args[0]))})"
1455
+ arg = node.args[0]
1456
+ # Zig rejects @as(i64, 3.9); a float argument needs its own form
1457
+ if self.infer_type(arg) == "float" and self.spec.int_cast_float:
1458
+ return f"({self.spec.int_cast_float.format(x=self.expr(arg))})"
1459
+ return f"({self.spec.int_cast.format(x=self.expr(arg))})"
1427
1460
  if fname == "float":
1428
1461
  return f"({self.spec.float_cast.format(x=self.expr(node.args[0]))})"
1429
1462
  if fname == "str":
@@ -1941,6 +1974,16 @@ class Emitter:
1941
1974
  t = self.infer_type(node)
1942
1975
  if t == "list":
1943
1976
  return "&" + s
1977
+ if t == "str" and self.spec.name == "rust":
1978
+ # a str param is &str; a String local or literal must be borrowed
1979
+ if isinstance(node, ast.Name) and node.id in self.params:
1980
+ return s # already a &str parameter
1981
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
1982
+ # a literal is already &'static str; emitting String::from
1983
+ # here would need an extra borrow and an allocation
1984
+ return '"' + node.value.replace("\\", "\\\\").replace(
1985
+ '"', '\\"').replace("\n", "\\n") + '"'
1986
+ return "&" + s
1944
1987
  return s
1945
1988
 
1946
1989
  def _stdlib_call(self, ge_name: str, args: list[ast.AST]) -> str:
@@ -2518,7 +2561,22 @@ class Emitter:
2518
2561
  if isinstance(node.value, str):
2519
2562
  return "str"
2520
2563
  if isinstance(node, ast.Name):
2521
- return self.var_types.get(node.id, "int")
2564
+ if node.id in self.var_types:
2565
+ return self.var_types[node.id]
2566
+ # module-level constants are inlined, not declared, so their type
2567
+ # has to be recovered from the collected value — otherwise
2568
+ # `GREETING + NAME` is treated as integer addition
2569
+ if node.id in self.constants:
2570
+ v = self.constants[node.id]
2571
+ if isinstance(v, bool):
2572
+ return "bool"
2573
+ if isinstance(v, int):
2574
+ return "int"
2575
+ if isinstance(v, float):
2576
+ return "float"
2577
+ if isinstance(v, str):
2578
+ return "str"
2579
+ return "int"
2522
2580
  if isinstance(node, ast.BinOp):
2523
2581
  lt = self.infer_type(node.left)
2524
2582
  rt = self.infer_type(node.right)
@@ -2548,7 +2606,8 @@ class Emitter:
2548
2606
  # slicing a string returns a string, slicing a list returns a list
2549
2607
  return base_type if base_type in ("str", "list") else "list"
2550
2608
  if base_type == "str":
2551
- return "int" # character access returns an integer (char code)
2609
+ # Python's s[i] is a one-character string, not a code point
2610
+ return "str"
2552
2611
  if base_type == "dict":
2553
2612
  return "int" # dict values are integers
2554
2613
  if base_type == "tuple":
@@ -54,7 +54,7 @@ SPEC = Spec(
54
54
  ffi_prefix='extern "C" ',
55
55
  str_concat="{l} + {r}",
56
56
  str_len="static_cast<int64_t>({x}.length())",
57
- str_index="static_cast<int64_t>({x}[{i}])",
57
+ str_index="std::string(1, ({x})[({i})])",
58
58
  str_slice="{x}.substr({start}, {len})",
59
59
  str_slice_start="{x}.substr({start})",
60
60
  str_slice_end="{x}.substr(0, {end})",
@@ -64,7 +64,7 @@ SPEC = Spec(
64
64
  indent=" ",
65
65
  str_concat="{l} + {r}",
66
66
  str_len="{x}.Length",
67
- str_index="(long)({x}[{i}])",
67
+ str_index="({x})[(int)({i})].ToString()",
68
68
  str_slice="{x}.Substring({start}, {len})",
69
69
  str_slice_start="{x}.Substring({start})",
70
70
  str_slice_end="{x}.Substring(0, {end})",
@@ -62,7 +62,7 @@ SPEC = Spec(
62
62
  indent="\t",
63
63
  str_concat="{l} + {r}",
64
64
  str_len="int64(len({x}))",
65
- str_index="int64({x}[{i}])",
65
+ str_index="string(({x})[({i})])",
66
66
  str_slice="{x}[{start}:{end}]",
67
67
  str_slice_start="{x}[{start}:]",
68
68
  str_slice_end="{x}[:{end}]",
@@ -61,7 +61,7 @@ SPEC = Spec(
61
61
  indent=" ",
62
62
  str_concat="{l} + {r}",
63
63
  str_len="{x}.length.toLong()",
64
- str_index="{x}[{i}.toInt()].toLong()",
64
+ str_index="({x})[({i}).toInt()].toString()",
65
65
  str_slice="{x}.substring({start}.toInt(), {end}.toInt())",
66
66
  str_slice_start="{x}.substring({start}.toInt())",
67
67
  str_slice_end="{x}.substring(0, {end}.toInt())",
@@ -24,7 +24,9 @@ SPEC = Spec(
24
24
  borrow_list_arg=True,
25
25
  range_call="({lo}..{hi})",
26
26
  range_step_call="({lo}..{hi}).step_by({step} as usize)",
27
- len_call="{x}.len() as i64",
27
+ # parenthesised: an unparenthesised cast makes `x.len() as i64 < 2`
28
+ # parse as a generic argument list, not a comparison
29
+ len_call="({x}.len() as i64)",
28
30
  list_len="{x}.len() as i64",
29
31
  print_int='println!("{{}}", {v})',
30
32
  print_float='println!("{{}}", {v})',
@@ -55,11 +57,12 @@ SPEC = Spec(
55
57
  # `String + &String` is not Add in Rust and `a + b` moves `a`, so a
56
58
  # format! is used instead — always correct, and it accepts &str too.
57
59
  str_concat="format!(\"{{}}{{}}\", {l}, {r})",
58
- str_len="{x}.len() as i64",
59
- str_index="{x}.as_bytes()[{i} as usize] as i64",
60
- str_slice="{x}[{start}..{end}].to_string()",
61
- str_slice_start="{x}[{start}..].to_string()",
62
- str_slice_end="{x}[..{end}].to_string()",
60
+ str_len="({x}.len() as i64)",
61
+ str_index="{x}[({i}) as usize..].chars().next().unwrap_or(' ').to_string()",
62
+ # ranges must be usize, and slicing a String needs a panic-free bound
63
+ str_slice="{x}[({start}) as usize..({end}) as usize].to_string()",
64
+ str_slice_start="{x}[({start}) as usize..].to_string()",
65
+ str_slice_end="{x}[..({end}) as usize].to_string()",
63
66
  list_concat="{{ let mut t = {l}; t.extend({r}.iter()); t }}",
64
67
  condition_parens=False,
65
68
  # .cloned() so the loop variable is owned: iterating a &[i64]
@@ -32,6 +32,7 @@ SPEC = Spec(
32
32
  list_reverse="std.mem.reverse(i64, {x}.items)",
33
33
  print_list='gePrintList({v}.items)',
34
34
  int_cast="@as(i64, {x})",
35
+ int_cast_float="@as(i64, @intFromFloat({x}))",
35
36
  float_cast="@as(f64, {x})",
36
37
  float_div="(@as(f64, {l}) / @as(f64, {r}))",
37
38
  floor_div="@divFloor({l}, {r})",
@@ -55,7 +56,7 @@ SPEC = Spec(
55
56
  indent=" ",
56
57
  str_concat="{l} ++ {r}",
57
58
  str_len="{x}.len",
58
- str_index="{x}[{i}]",
59
+ str_index="({x})[@as(usize, @intCast({i}))..][0..1]",
59
60
  str_slice="{x}[{start}..{end}]",
60
61
  str_slice_start="{x}[{start}..]",
61
62
  str_slice_end="{x}[..{end}]",
@@ -269,6 +270,11 @@ class ZigEmitter(Emitter):
269
270
  params = []
270
271
  for pname, ptype in unit.params:
271
272
  nt = self.param_native_type(ptype, pname)
273
+ # A parameter this function does not mutate is borrowed const, so
274
+ # the caller may keep its own binding `const`.
275
+ if (ptype == "list" and nt.startswith("*std.ArrayList")
276
+ and pname not in self.mutated_list_params):
277
+ nt = "*const " + nt[1:]
272
278
  params.append(f"{pname}: {nt}")
273
279
  ret = self.py_to_native(unit.ret_type) if unit.ret_type != "None" else "void"
274
280
  param_str = ", ".join(params)
@@ -238,8 +238,12 @@ def cmd_analyze(args) -> int:
238
238
  print(f" Import resolution error: {e}")
239
239
  units, classes, warnings = [], [], []
240
240
 
241
- # Run type checker
242
- reporter = check_source(source, file=str(source_path), entry=entry)
241
+ # Run type checker. The resolved units are passed in so that functions
242
+ # brought in by an import are known — otherwise every call across a module
243
+ # boundary is reported as GE007 "called but not defined".
244
+ reporter = check_source(source, file=str(source_path), entry=entry,
245
+ units=units or None,
246
+ classes=classes or None)
243
247
 
244
248
  # Print diagnostics
245
249
  errors = [d for d in reporter.diagnostics if d.severity == "error"]