@tishlang/tish 2.8.0 → 2.10.1

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.
@@ -737,6 +737,41 @@ pub fn compile_with_native_modules_emit(
737
737
  Ok(g.output)
738
738
  }
739
739
 
740
+ /// #177 (S-E/S-F): the return shape of a de-virtualized aggregate (struct/array) free fn.
741
+ #[derive(Debug, Clone, PartialEq)]
742
+ enum AggRet {
743
+ /// Returns the unboxed struct by value (the `body()` factory).
744
+ Struct,
745
+ /// Returns `Vec<TishStruct_alias>` by value (the `makeBodies()` array factory).
746
+ ArrayOfStruct,
747
+ /// Returns a plain `f64` (`energy()`).
748
+ F64,
749
+ /// Returns nothing (`advance()`, `offsetMomentum()` — JS `undefined`).
750
+ Unit,
751
+ }
752
+
753
+ /// #177: one parameter of an aggregate free fn, in source order.
754
+ #[derive(Debug, Clone)]
755
+ enum AggParamKind {
756
+ /// The `Vec<TishStruct_alias>` array param, threaded by shared reference
757
+ /// (`&mut` if the fn mutates an element, `&` if read-only).
758
+ Array { is_mut: bool },
759
+ /// A scalar param (always `f64` for the nbody shape).
760
+ Scalar(RustType),
761
+ }
762
+
763
+ /// #177: the de-virtualized native signature of one aggregate fn.
764
+ #[derive(Debug, Clone)]
765
+ struct AggFnSig {
766
+ /// Source params in order: (name, kind).
767
+ params: Vec<(String, AggParamKind)>,
768
+ /// Top-level numeric globals the body references, appended as trailing `f64`
769
+ /// params (sorted for a stable decl/call-site order).
770
+ captured: Vec<String>,
771
+ /// What the fn returns.
772
+ ret: AggRet,
773
+ }
774
+
740
775
  struct Codegen {
741
776
  output: String,
742
777
  indent: usize,
@@ -774,6 +809,20 @@ struct Codegen {
774
809
  /// free `fn f_native(f64,..)->f64` (all params `: number`, returns `number`, native-safe
775
810
  /// body). Direct calls to these route to the native fn, bypassing the boxed `value_call`.
776
811
  native_fns: std::collections::HashSet<String>,
812
+ /// #177 S-E/S-F (dark-shipped behind `TISH_AGGREGATE_INFER`): the unboxed struct alias name
813
+ /// (e.g. `TishAnon_0`) when the interprocedural aggregate path is active for this program,
814
+ /// else `None`. Set in `emit_program` only after the de-virtualized fns emit successfully.
815
+ aggregate_alias: Option<String>,
816
+ /// #177: fn name → its de-virtualized native signature. Calls to these route directly to the
817
+ /// `fn name_agg(..)` free fn (threading the `Vec<TishStruct_alias>` by reference), bypassing
818
+ /// the boxed `value_call`. Empty when the aggregate path is inactive.
819
+ aggregate_fns: std::collections::HashMap<String, AggFnSig>,
820
+ /// #177: top-level `let` names bound to the unboxed `Vec<TishStruct_alias>` (e.g. `bodies`).
821
+ /// These are emitted `let mut` and passed `&mut`/`&` into the aggregate fns.
822
+ aggregate_array_locals: std::collections::HashSet<String>,
823
+ /// #177: while emitting an aggregate fn body, the return shape of the fn currently being
824
+ /// emitted (drives `Return` lowering). `None` outside aggregate-fn emission.
825
+ agg_cur_ret: Option<AggRet>,
777
826
  /// Names of `number`-typed locals demoted to a boxed `Value` because some reassignment can
778
827
  /// store a non-number — e.g. `let s = 0; s = s + arr[i]` where `arr` is a boxed Value: `+` is
779
828
  /// JS string concat, so `s` may become a `String`. Lowering `s` to a native `f64` would panic
@@ -856,6 +905,10 @@ impl Codegen {
856
905
  outer_vars_stack: vec![Vec::new()], // Start with module-level scope
857
906
  refcell_wrapped_vars: std::collections::HashSet::new(),
858
907
  native_fns: std::collections::HashSet::new(),
908
+ aggregate_alias: None,
909
+ aggregate_fns: std::collections::HashMap::new(),
910
+ aggregate_array_locals: std::collections::HashSet::new(),
911
+ agg_cur_ret: None,
859
912
  demoted_numeric_locals: std::collections::HashSet::new(),
860
913
  int_range_locals: std::collections::HashMap::new(),
861
914
  int_valued_locals: std::collections::HashSet::new(),
@@ -1542,6 +1595,13 @@ impl Codegen {
1542
1595
  self.writeln("");
1543
1596
  }
1544
1597
  }
1598
+ // #177 (S-E/S-F, dark-shipped behind TISH_AGGREGATE_INFER): de-virtualize the nbody-shape
1599
+ // aggregate fns into native Rust free fns operating on an unboxed `Vec<TishStruct_alias>`
1600
+ // threaded by reference. Computed + emitted here (before `run()`); if any fn can't be
1601
+ // lowered the whole path is disabled and we fall back to the boxed closures unchanged.
1602
+ if std::env::var("TISH_AGGREGATE_INFER").map(|v| v != "0").unwrap_or(false) {
1603
+ self.setup_aggregate_fns(program);
1604
+ }
1545
1605
  // Soundness pass — must run after type aliases + `native_fns` are known (both feed the
1546
1606
  // native-type oracle): find `number`-typed locals a reassignment can turn non-numeric so
1547
1607
  // `VarDecl` lowers them as boxed `Value` rather than native `f64` (else the store coerces
@@ -1836,6 +1896,11 @@ impl Codegen {
1836
1896
  let top_level_funcs = self.prescan_function_decls(&program.statements);
1837
1897
  *self.function_scope_stack.last_mut().unwrap() = top_level_funcs.clone();
1838
1898
  for func_name in &top_level_funcs {
1899
+ // #177: functions promoted to native aggregate free fns (`<name>_agg`) have their
1900
+ // boxed closure + cell suppressed — no boxed value exists to back-patch.
1901
+ if self.aggregate_alias.is_some() && self.aggregate_fns.contains_key(func_name) {
1902
+ continue;
1903
+ }
1839
1904
  let escaped = Self::escape_ident(func_name);
1840
1905
  self.writeln(&format!(
1841
1906
  "let {}_cell: VmRef<Value> = VmRef::new(Value::Null);",
@@ -2032,6 +2097,148 @@ impl Codegen {
2032
2097
  self.emit_expr(expr)
2033
2098
  }
2034
2099
 
2100
+ /// Is `update` a `+1` step on `var` (`var++`, `++var`, `var += 1`, or `var = var + 1`)?
2101
+ fn is_increment_of(update: &Expr, var: &str) -> bool {
2102
+ match update {
2103
+ Expr::PostfixInc { name, .. } | Expr::PrefixInc { name, .. } => name.as_ref() == var,
2104
+ Expr::CompoundAssign {
2105
+ name,
2106
+ op: CompoundOp::Add,
2107
+ value,
2108
+ ..
2109
+ } => name.as_ref() == var && Self::int_literal_value_of(value) == Some(1),
2110
+ Expr::Assign { name, value, .. } => {
2111
+ name.as_ref() == var
2112
+ && matches!(
2113
+ value.as_ref(),
2114
+ Expr::Binary { left, op: BinOp::Add, right, .. }
2115
+ if matches!(left.as_ref(), Expr::Ident { name: l, .. } if l.as_ref() == var)
2116
+ && Self::int_literal_value_of(right) == Some(1)
2117
+ )
2118
+ }
2119
+ _ => false,
2120
+ }
2121
+ }
2122
+
2123
+ /// #173: detect a fill loop `for (let i = 0; i < N; i++) { a.push(K) }` over a native `Vec<T>`
2124
+ /// and emit it as a single bulk `a.extend(std::iter::repeat(K).take((N) as usize))` — one
2125
+ /// allocation instead of N per-element pushes that repeatedly realloc as the Vec grows. Returns
2126
+ /// `Ok(true)` when the fused form was emitted (the caller then skips the normal loop).
2127
+ ///
2128
+ /// Sound only when `N` is a proven, side-effect-free integer (so the bulk count matches the loop
2129
+ /// iteration count exactly, including the truncating `as usize` for `0`/negative) and `K` is a
2130
+ /// constant of the element type (no per-element variation). Any miss returns `Ok(false)` and the
2131
+ /// normal loop is emitted — correctness over coverage.
2132
+ fn try_emit_native_fill_loop(
2133
+ &mut self,
2134
+ init: Option<&Statement>,
2135
+ cond: Option<&Expr>,
2136
+ update: Option<&Expr>,
2137
+ body: &Statement,
2138
+ ) -> Result<bool, CompileError> {
2139
+ // init: `let i = 0`
2140
+ let (
2141
+ Some(Statement::VarDecl {
2142
+ name: i_name,
2143
+ init: Some(i_init),
2144
+ ..
2145
+ }),
2146
+ Some(cond),
2147
+ Some(update),
2148
+ ) = (init, cond, update)
2149
+ else {
2150
+ return Ok(false);
2151
+ };
2152
+ if Self::int_literal_value_of(i_init) != Some(0) {
2153
+ return Ok(false);
2154
+ }
2155
+ // cond: `i < N`
2156
+ let Expr::Binary {
2157
+ left,
2158
+ op: BinOp::Lt,
2159
+ right: bound,
2160
+ ..
2161
+ } = cond
2162
+ else {
2163
+ return Ok(false);
2164
+ };
2165
+ let Expr::Ident { name: c_name, .. } = left.as_ref() else {
2166
+ return Ok(false);
2167
+ };
2168
+ if c_name.as_ref() != i_name.as_ref() {
2169
+ return Ok(false);
2170
+ }
2171
+ // update: `i++` / `++i` / `i += 1` / `i = i + 1`
2172
+ if !Self::is_increment_of(update, i_name.as_ref()) {
2173
+ return Ok(false);
2174
+ }
2175
+ // body: exactly one statement `a.push(K)`
2176
+ let push_stmt = match body {
2177
+ Statement::Block { statements, .. } if statements.len() == 1 => &statements[0],
2178
+ Statement::ExprStmt { .. } => body,
2179
+ _ => return Ok(false),
2180
+ };
2181
+ let Statement::ExprStmt {
2182
+ expr: Expr::Call { callee, args, .. },
2183
+ ..
2184
+ } = push_stmt
2185
+ else {
2186
+ return Ok(false);
2187
+ };
2188
+ let Expr::Member {
2189
+ object,
2190
+ prop: MemberProp::Name { name: method, .. },
2191
+ optional: false,
2192
+ ..
2193
+ } = callee.as_ref()
2194
+ else {
2195
+ return Ok(false);
2196
+ };
2197
+ if method.as_ref() != "push" || args.len() != 1 {
2198
+ return Ok(false);
2199
+ }
2200
+ let Expr::Ident { name: arr_name, .. } = object.as_ref() else {
2201
+ return Ok(false);
2202
+ };
2203
+ // `a` must be a native `Vec<T>`. A closure-captured (RefCell) Vec would need a borrow_mut;
2204
+ // skip it (rare) and keep the plain loop.
2205
+ let RustType::Vec(elem) = self.type_context.get_type(arr_name.as_ref()) else {
2206
+ return Ok(false);
2207
+ };
2208
+ if self.refcell_wrapped_vars.contains(arr_name.as_ref()) {
2209
+ return Ok(false);
2210
+ }
2211
+ let CallArg::Expr(k_expr) = &args[0] else {
2212
+ return Ok(false);
2213
+ };
2214
+ // K must be a constant literal of the element type (no per-element variation, no `i` ref).
2215
+ let k_code = match (&*elem, k_expr) {
2216
+ (RustType::F64, Expr::Literal { value: Literal::Number(n), .. }) => Self::f64_lit(*n),
2217
+ (RustType::Bool, Expr::Literal { value: Literal::Bool(b), .. }) => format!("{}", b),
2218
+ _ => return Ok(false),
2219
+ };
2220
+ // N must be a proven, side-effect-free integer: an integer literal or an int-range local.
2221
+ let n_code = match bound.as_ref() {
2222
+ Expr::Literal {
2223
+ value: Literal::Number(_),
2224
+ ..
2225
+ } if Self::int_literal_value_of(bound).is_some() => self.emit_typed_expr(bound)?.0,
2226
+ Expr::Ident { name, .. }
2227
+ if self.int_range_locals.contains_key(name.as_ref())
2228
+ && self.type_context.get_type(name.as_ref()) == RustType::F64 =>
2229
+ {
2230
+ Self::escape_ident(name.as_ref()).into_owned()
2231
+ }
2232
+ _ => return Ok(false),
2233
+ };
2234
+ let arr_esc = Self::escape_ident(arr_name.as_ref()).into_owned();
2235
+ self.writeln(&format!(
2236
+ "{}.extend(std::iter::repeat({}).take(({}) as usize));",
2237
+ arr_esc, k_code, n_code
2238
+ ));
2239
+ Ok(true)
2240
+ }
2241
+
2035
2242
  fn emit_statement(&mut self, stmt: &Statement) -> Result<(), CompileError> {
2036
2243
  match stmt {
2037
2244
  Statement::Block { statements, .. } => {
@@ -2135,7 +2342,11 @@ impl Codegen {
2135
2342
  // Track the variable type
2136
2343
  self.type_context.define(name.as_ref(), rust_type.clone());
2137
2344
 
2138
- let mutability = if *mutable { "let mut" } else { "let" };
2345
+ // #177: the unboxed `Vec<TishStruct>` local is threaded `&mut` into the aggregate
2346
+ // operators (`advance`/`offsetMomentum`), so it must be `mut` even when never
2347
+ // directly reassigned in source.
2348
+ let force_mut = self.aggregate_array_locals.contains(name.as_ref());
2349
+ let mutability = if *mutable || force_mut { "let mut" } else { "let" };
2139
2350
  let escaped_name = Self::escape_ident(name.as_ref());
2140
2351
 
2141
2352
  if rust_type.is_native() {
@@ -2355,6 +2566,18 @@ impl Codegen {
2355
2566
  body,
2356
2567
  ..
2357
2568
  } => {
2569
+ // #173: fuse a fill loop `for (let i = 0; i < N; i++) { a.push(K) }` over a native
2570
+ // `Vec<T>` into a single bulk `extend` — one allocation instead of N per-element
2571
+ // pushes (which repeatedly realloc as the Vec grows). Sound only when `N` is a proven,
2572
+ // side-effect-free integer; otherwise the normal loop is emitted below.
2573
+ if self.try_emit_native_fill_loop(
2574
+ init.as_deref(),
2575
+ cond.as_ref(),
2576
+ update.as_ref(),
2577
+ body,
2578
+ )? {
2579
+ return Ok(());
2580
+ }
2358
2581
  self.writeln("{");
2359
2582
  self.indent += 1;
2360
2583
  if let Some(i) = init {
@@ -2605,6 +2828,15 @@ impl Codegen {
2605
2828
  span,
2606
2829
  ..
2607
2830
  } => {
2831
+ // #177: this function was de-virtualized into a native aggregate free fn
2832
+ // (`<name>_agg`, emitted before `run()`); all call sites were routed there.
2833
+ // Skip the boxed closure entirely — its body now references unboxed structs
2834
+ // that no longer fit the boxed `Value` ABI.
2835
+ if self.aggregate_alias.is_some()
2836
+ && self.aggregate_fns.contains_key(name.as_ref())
2837
+ {
2838
+ return Ok(());
2839
+ }
2608
2840
  // Use Rc<RefCell<>> pattern to allow recursive function calls
2609
2841
  // The function can reference itself through the cell
2610
2842
  let name_raw = name.as_ref();
@@ -3321,6 +3553,14 @@ impl Codegen {
3321
3553
  }
3322
3554
  }
3323
3555
  Expr::Call { callee, args, .. } => {
3556
+ // #177: route a top-level call to a de-virtualized aggregate fn. Void fns
3557
+ // (`advance`/`offsetMomentum`) emit `name_agg(&mut bodies, …)` (a `()` statement);
3558
+ // an f64-returning fn (`energy`) is boxed back into `Value::Number` for this path.
3559
+ if !self.aggregate_fns.is_empty() {
3560
+ if let Some((code, _)) = self.try_emit_toplevel_agg_call(callee, args, true)? {
3561
+ return Ok(code);
3562
+ }
3563
+ }
3324
3564
  // Typed-struct shortcut for `JSON.stringify(typedValue)`.
3325
3565
  // When the single arg has a known native type that owns a
3326
3566
  // hand-rolled `_tish_write_json` (struct or `Vec<struct>`),
@@ -5897,6 +6137,16 @@ impl Codegen {
5897
6137
  expr: &Expr,
5898
6138
  target_type: &RustType,
5899
6139
  ) -> Result<String, CompileError> {
6140
+ // #177: `let bodies = makeBodies()` — route the array-factory call to its native free fn
6141
+ // returning `Vec<TishStruct_alias>` directly (no boxed `Value::Array` round-trip).
6142
+ if !self.aggregate_fns.is_empty() {
6143
+ if let Expr::Call { callee, args, .. } = expr {
6144
+ if let Some((code, _)) = self.try_emit_toplevel_agg_call(callee, args, false)? {
6145
+ return Ok(code);
6146
+ }
6147
+ }
6148
+ }
6149
+
5900
6150
  // Try to emit literals directly as native types
5901
6151
  if let Expr::Literal { value, .. } = expr {
5902
6152
  match (target_type, value) {
@@ -7441,6 +7691,13 @@ impl Codegen {
7441
7691
  ..
7442
7692
  } => {
7443
7693
  if let Expr::Ident { name: var_name, .. } = object.as_ref() {
7694
+ // #173: `vec.length` on a native `Vec<_>` is a native `f64` (the emitter lowers it
7695
+ // to `(vec.len() as f64)`), so a local fed by `arr.length` stays native.
7696
+ if let Some(RustType::Vec(_)) = env.get(var_name.as_ref()) {
7697
+ if prop_name.as_ref() == "length" {
7698
+ return RustType::F64;
7699
+ }
7700
+ }
7444
7701
  if let Some(RustType::Named { fields, .. }) = env.get(var_name.as_ref()) {
7445
7702
  if let Some((_, field_ty)) =
7446
7703
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
@@ -7481,6 +7738,12 @@ impl Codegen {
7481
7738
  }
7482
7739
  }
7483
7740
  }
7741
+ // #169: a fused native `Vec<f64>` reduce produces an `f64` (the emitter lowers it via
7742
+ // `native_vec_hof_for_call`). Model it here so an accumulator fed by `xs.reduce(...)`
7743
+ // is not wrongly demoted to a boxed `Value`. Conservative: any miss → `Value`.
7744
+ if let Some(t) = self.native_vec_reduce_result_type(callee, args, env) {
7745
+ return t;
7746
+ }
7484
7747
  RustType::Value
7485
7748
  }
7486
7749
  // Unary, Conditional, etc. are not modelled by `emit_typed_expr` (it boxes them), so a
@@ -7490,6 +7753,81 @@ impl Codegen {
7490
7753
  }
7491
7754
  }
7492
7755
 
7756
+ /// #169: read-only mirror of [`try_native_vec_hof`]'s `reduce` preconditions, for
7757
+ /// [`expr_native_type`]. Returns `Some(F64)` exactly when a `xs.reduce((acc, x) => body, init)`
7758
+ /// call would fuse to a native `f64` fold (so an accumulator it feeds stays native instead of
7759
+ /// being demoted to a boxed `Value`). Any uncertainty returns `None` — the oracle never claims
7760
+ /// `F64` where the emitter would box, which is what keeps the demotion analysis sound.
7761
+ fn native_vec_reduce_result_type(
7762
+ &self,
7763
+ callee: &Expr,
7764
+ args: &[CallArg],
7765
+ env: &HashMap<String, RustType>,
7766
+ ) -> Option<RustType> {
7767
+ if std::env::var("TISH_NATIVE_HOF").is_err() {
7768
+ return None;
7769
+ }
7770
+ let Expr::Member {
7771
+ object,
7772
+ prop: MemberProp::Name { name: method, .. },
7773
+ optional: false,
7774
+ ..
7775
+ } = callee
7776
+ else {
7777
+ return None;
7778
+ };
7779
+ if method.as_ref() != "reduce" {
7780
+ return None;
7781
+ }
7782
+ let Expr::Ident { name: recv_name, .. } = object.as_ref() else {
7783
+ return None;
7784
+ };
7785
+ // Receiver must be a native `Vec<f64>` (`.copied()` needs a `Copy` element).
7786
+ match env.get(recv_name.as_ref()) {
7787
+ Some(RustType::Vec(inner)) if **inner == RustType::F64 => {}
7788
+ _ => return None,
7789
+ }
7790
+ // `reduce(callback, init)` with a simple-param expression-body arrow that does not touch the
7791
+ // receiver (an alias inside the closure would break the `.iter()` borrow).
7792
+ if args.len() != 2 {
7793
+ return None;
7794
+ }
7795
+ let Some(CallArg::Expr(Expr::ArrowFunction { params, body, .. })) = args.first() else {
7796
+ return None;
7797
+ };
7798
+ if params.len() != 2 {
7799
+ return None;
7800
+ }
7801
+ let (FunParam::Simple(acc_p), FunParam::Simple(x_p)) = (&params[0], &params[1]) else {
7802
+ return None;
7803
+ };
7804
+ if acc_p.default.is_some() || x_p.default.is_some() {
7805
+ return None;
7806
+ }
7807
+ let ArrowBody::Expr(be) = body else {
7808
+ return None;
7809
+ };
7810
+ if crate::infer::pi_mentions(be, recv_name.as_ref()) {
7811
+ return None;
7812
+ }
7813
+ // The init must be native-numeric, and the body must lower to `f64` with both closure params
7814
+ // bound `f64` — exactly the emitter's preconditions, evaluated read-only.
7815
+ let CallArg::Expr(init_e) = &args[1] else {
7816
+ return None;
7817
+ };
7818
+ if self.expr_native_type(init_e, env) != RustType::F64 {
7819
+ return None;
7820
+ }
7821
+ let mut benv = env.clone();
7822
+ benv.insert(acc_p.name.to_string(), RustType::F64);
7823
+ benv.insert(x_p.name.to_string(), RustType::F64);
7824
+ if self.expr_native_type(be, &benv) == RustType::F64 {
7825
+ Some(RustType::F64)
7826
+ } else {
7827
+ None
7828
+ }
7829
+ }
7830
+
7493
7831
  /// Names of top-level fns eligible for a parallel native `fn f_native(f64,..)->f64`:
7494
7832
  /// non-async, every param `: number` (no default), `: number` return, and a native-safe
7495
7833
  /// body (only block/if/return/expr-stmt over native exprs + calls to other eligible fns or
@@ -7769,121 +8107,1225 @@ impl Codegen {
7769
8107
  Ok(())
7770
8108
  }
7771
8109
 
7772
- /// Lower an expression that JS will coerce to **int32** inside a bitwise/shift computation,
7773
- /// staying in the integer domain instead of round-tripping every intermediate through `f64`.
7774
- ///
7775
- /// Returns `Ok(Some(code))` where `code` is an `i32`-typed Rust expression equal to
7776
- /// `ToInt32(e)`, or `Ok(None)` if a leaf can't be proven `F64` (then the caller keeps the
7777
- /// existing per-op lowering purely additive, never a regression).
7778
- ///
7779
- /// This is behaviour-identical to the nested `to_int32`/`to_uint32` lowering: an intermediate
7780
- /// `(i32 as f64)` immediately re-narrowed by `to_int32` is exact (every `i32` is representable
7781
- /// in `f64`, and `to_int32` of a finite value recovers it), so erasing it changes nothing but
7782
- /// the round-trips. Crucially, only bitwise/shift nodes recurse — an `f64` `*`/`+`/`-` node is
7783
- /// a *leaf* here, so e.g. `(h * 16777619) >>> 0` keeps its `f64` multiply (the 2^53 rule: the
7784
- /// product exceeds 2^53 and must round in `f64` *before* `ToUint32`, exactly as V8 does).
7785
- fn emit_int32_operand(&mut self, e: &Expr) -> Result<Option<String>, CompileError> {
7786
- if let Expr::Binary {
7787
- left, op, right, ..
7788
- } = e
7789
- {
7790
- let bitwise = matches!(
7791
- op,
7792
- BinOp::BitAnd
7793
- | BinOp::BitOr
7794
- | BinOp::BitXor
7795
- | BinOp::Shl
7796
- | BinOp::Shr
7797
- | BinOp::UShr
7798
- );
7799
- if bitwise {
7800
- let li = match self.emit_int32_operand(left)? {
7801
- Some(c) => c,
7802
- None => return Ok(None),
7803
- };
7804
- let ri = match self.emit_int32_operand(right)? {
7805
- Some(c) => c,
7806
- None => return Ok(None),
7807
- };
7808
- // Shift counts: `(ri as u32)` shares its low 5 bits with `to_uint32(rhs)`, and
7809
- // `wrapping_sh*` masks the count mod 32 — exactly JS's `count & 31`.
7810
- let code = match op {
7811
- BinOp::BitAnd => format!("({} & {})", li, ri),
7812
- BinOp::BitOr => format!("({} | {})", li, ri),
7813
- BinOp::BitXor => format!("({} ^ {})", li, ri),
7814
- BinOp::Shl => format!("({}).wrapping_shl(({}) as u32)", li, ri),
7815
- BinOp::Shr => format!("({}).wrapping_shr(({}) as u32)", li, ri),
7816
- // `>>>` is a logical shift on the uint32 view; reinterpret back to `i32` to
7817
- // stay in the integer domain (the unsigned value is recovered at the f64 edge).
7818
- BinOp::UShr => {
7819
- format!("((({}) as u32).wrapping_shr(({}) as u32) as i32)", li, ri)
7820
- }
7821
- _ => unreachable!(),
7822
- };
7823
- return Ok(Some(code));
7824
- }
7825
- }
7826
- // Leaf: fold only when it is a plain `f64` (so `to_int32` applies directly). `to_int32`
7827
- // keeps its `is_finite` guard here — a leaf may legitimately be NaN/±Infinity (→ 0).
7828
- let (code, ty) = self.emit_typed_expr(e)?;
7829
- if ty == RustType::F64 {
7830
- // When the leaf is an ARITHMETIC node PROVABLY finite with `|x| < 2^62` (operands are
7831
- // i32-register reads and finite literals — e.g. the FNV `h * 16777619` excursion), drop
7832
- // the `is_finite` guard and Rust's saturating cast and truncate directly. Bit-identical
7833
- // on this domain (`x as i64` truncates toward zero = JS ToInt32 truncation; `as i32` =
7834
- // modulo 2^32), a few instructions cheaper per iteration. Emitted inline so the generated
7835
- // crate needs no new runtime symbol. Any unproven leaf keeps the guarded `to_int32`.
7836
- if matches!(e, Expr::Binary { .. }) && self.f64_finite_bounded_below_2pow62(e) {
7837
- Ok(Some(format!(
7838
- "(unsafe {{ ({}).to_int_unchecked::<i64>() }} as i32)",
7839
- code
7840
- )))
7841
- } else {
7842
- Ok(Some(format!("tishlang_runtime::to_int32({})", code)))
7843
- }
7844
- } else if ty == RustType::I32 {
7845
- // An `I32` loop-accumulator already holds its JS ToInt32 bit-pattern in an integer
7846
- // register — feed it straight in, NO `to_int32` round-trip. This is the perf win: the
7847
- // per-op `f64`→i32 narrowing across the hash loop collapses to a register read.
7848
- Ok(Some(code))
7849
- } else {
7850
- Ok(None)
8110
+ // ====================================================================================
8111
+ // #177 (S-E / S-F): interprocedural aggregate (unboxed struct + Vec<Struct>) free fns.
8112
+ //
8113
+ // The infer front-end (S-0..S-D, behind TISH_AGGREGATE_INFER) stamps a struct alias and
8114
+ // `: alias` / `: alias[]` annotations onto the nbody-shape factory/array/operator fns iff a
8115
+ // whole-program candidacy predicate holds (monomorphic all-f64 struct, no `===`/escape/
8116
+ // reshape, write-permitting field ops). Here we consume that: emit each such fn as a native
8117
+ // Rust free fn over `Vec<TishStruct_alias>` threaded by `&mut`/`&`, with element access by
8118
+ // index (so JS reference aliasing — `let bi = bodies[i]; bi.vx = …` lowers to in-place
8119
+ // `bodies[i].vx = …` and the mutation persists, exactly like the boxed `Value::Array`).
8120
+ //
8121
+ // SOUNDNESS: this is the codegen-side gate. We re-run `analyze_aggregate` to recover the
8122
+ // verdict, then emit every fn into a scratch buffer; if ANY construct can't be lowered the
8123
+ // whole path is disabled (the fns + the call hooks fall back to the boxed closures, byte-
8124
+ // identical to flag-off). So we never half-wire the feature or miscompile.
8125
+ // ====================================================================================
8126
+
8127
+ /// Compute the aggregate group from the stamped `: alias` / `: alias[]` annotations the infer
8128
+ /// front-end wrote (the infer→codegen contract — re-running `analyze_aggregate` on the already
8129
+ /// stamped program is NOT idempotent), emit the native free fns, and (on full success) record
8130
+ /// the routing state so call sites de-virtualize. On any failure the state is left empty (path
8131
+ /// disabled) and nothing is appended to the output.
8132
+ fn setup_aggregate_fns(&mut self, program: &Program) {
8133
+ let dbg = std::env::var("TISH_AGG_DEBUG").is_ok();
8134
+ // The unboxed struct alias: the (unique) name `A` used as an `A[]` array param of some fn,
8135
+ // and registered as an all-`Copy`-field struct alias.
8136
+ let Some(alias) = self.detect_aggregate_alias(program) else {
8137
+ if dbg {
8138
+ eprintln!("[agg] detect_aggregate_alias = None; aliases={:?}", self.type_aliases.keys().collect::<Vec<_>>());
8139
+ }
8140
+ return;
8141
+ };
8142
+ if dbg {
8143
+ eprintln!("[agg] alias = {}", alias);
7851
8144
  }
7852
- }
7853
-
7854
- fn emit_typed_expr(&mut self, expr: &Expr) -> Result<(String, RustType), CompileError> {
7855
- match expr {
7856
- // ── literals ─────────────────────────────────────────────────────────
7857
- Expr::Literal { value, .. } => match value {
7858
- Literal::Number(n) => Ok((Self::f64_lit(*n), RustType::F64)),
7859
- Literal::String(s) => {
7860
- Ok((format!("{:?}.to_string()", s.as_ref()), RustType::String))
7861
- }
7862
- Literal::Bool(b) => Ok((format!("{}", b), RustType::Bool)),
7863
- Literal::Null => Ok(("Value::Null".to_string(), RustType::Value)),
7864
- },
8145
+ // Top-level numeric global `let` names available to capture as trailing params.
8146
+ let globals = Self::collect_toplevel_global_lets(program);
7865
8147
 
7866
- // ── identifiers ──────────────────────────────────────────────────────
7867
- Expr::Ident { name, .. } => {
7868
- let escaped = Self::escape_ident(name.as_ref());
7869
- if self.refcell_wrapped_vars.contains(name.as_ref()) {
7870
- let var_type = self.type_context.get_type(name.as_ref());
7871
- if var_type.is_native() {
7872
- Ok((format!("(*{}.borrow()).clone()", escaped), var_type))
8148
+ // Build a signature for every fn in the group from its stamped annotations.
8149
+ let mut sigs: std::collections::HashMap<String, AggFnSig> = std::collections::HashMap::new();
8150
+ let mut decls: Vec<(String, Vec<FunParam>, Statement)> = Vec::new();
8151
+ for s in &program.statements {
8152
+ if let Statement::FunDecl {
8153
+ async_: false,
8154
+ name,
8155
+ params,
8156
+ rest_param: None,
8157
+ return_type,
8158
+ body,
8159
+ ..
8160
+ } = s
8161
+ {
8162
+ let nm = name.to_string();
8163
+ // The array param: a `Simple`-param annotated `: alias[]`.
8164
+ let array_pi = params.iter().position(|p| {
8165
+ matches!(p, FunParam::Simple(tp)
8166
+ if Self::ann_is_array_of(tp.type_ann.as_ref(), &alias))
8167
+ });
8168
+ // Return shape from the stamped return type, else from the body.
8169
+ let ret = if Self::ann_is_simple(return_type.as_ref(), &alias) {
8170
+ AggRet::Struct
8171
+ } else if Self::ann_is_array_of(return_type.as_ref(), &alias) {
8172
+ AggRet::ArrayOfStruct
8173
+ } else if array_pi.is_some() {
8174
+ if Self::stmt_returns_value(body) {
8175
+ AggRet::F64
7873
8176
  } else {
7874
- Ok((format!("(*{}.borrow()).clone()", escaped), RustType::Value))
8177
+ AggRet::Unit
7875
8178
  }
7876
8179
  } else {
7877
- let var_type = self.type_context.get_type(name.as_ref());
7878
- if var_type.is_native() {
7879
- Ok((escaped.into_owned(), var_type))
7880
- } else {
7881
- Ok((escaped.into_owned(), RustType::Value))
8180
+ // Not a factory and takes no array param → not in the group.
8181
+ continue;
8182
+ };
8183
+ let array_pname = array_pi.and_then(|pi| match params.get(pi) {
8184
+ Some(FunParam::Simple(tp)) => Some(tp.name.to_string()),
8185
+ _ => None,
8186
+ });
8187
+ let is_mut = array_pname
8188
+ .as_deref()
8189
+ .map(|p| Self::agg_fn_mutates_array(body, p))
8190
+ .unwrap_or(false);
8191
+ // Per-source-param kind.
8192
+ let mut sig_params: Vec<(String, AggParamKind)> = Vec::new();
8193
+ let mut ok = true;
8194
+ for (pi, p) in params.iter().enumerate() {
8195
+ match p {
8196
+ FunParam::Simple(tp) => {
8197
+ let kind = if Some(pi) == array_pi {
8198
+ AggParamKind::Array { is_mut }
8199
+ } else {
8200
+ // Scalar param: must be annotated `: number` (→ f64).
8201
+ let ty = tp
8202
+ .type_ann
8203
+ .as_ref()
8204
+ .map(|t| {
8205
+ crate::types::RustType::from_annotation_with_aliases(
8206
+ t,
8207
+ &self.type_aliases,
8208
+ )
8209
+ })
8210
+ .unwrap_or(RustType::Value);
8211
+ if ty != RustType::F64 {
8212
+ ok = false;
8213
+ break;
8214
+ }
8215
+ AggParamKind::Scalar(ty)
8216
+ };
8217
+ sig_params.push((tp.name.to_string(), kind));
8218
+ }
8219
+ _ => {
8220
+ ok = false;
8221
+ break;
8222
+ }
7882
8223
  }
7883
8224
  }
8225
+ if !ok {
8226
+ continue;
8227
+ }
8228
+ // Captured globals: free idents in the body that are top-level numeric globals and
8229
+ // not params/locals/group-fn names.
8230
+ let captured = Self::agg_captured_globals(body, params, &globals, &sigs, &nm, &alias);
8231
+ sigs.insert(
8232
+ nm.clone(),
8233
+ AggFnSig {
8234
+ params: sig_params,
8235
+ captured,
8236
+ ret,
8237
+ },
8238
+ );
8239
+ decls.push((nm, params.clone(), (**body).clone()));
7884
8240
  }
8241
+ }
8242
+ if dbg {
8243
+ eprintln!("[agg] sigs = {:?}", sigs.keys().collect::<Vec<_>>());
8244
+ }
8245
+ if sigs.is_empty() {
8246
+ return;
8247
+ }
7885
8248
 
7886
- // ── binary expressions ───────────────────────────────────────────────
8249
+ // Top-level array locals: a `let bodies: alias[] = …` VarDecl.
8250
+ let array_locals: std::collections::HashSet<String> = program
8251
+ .statements
8252
+ .iter()
8253
+ .filter_map(|s| match s {
8254
+ Statement::VarDecl { name, type_ann, .. }
8255
+ if Self::ann_is_array_of(type_ann.as_ref(), &alias) =>
8256
+ {
8257
+ Some(name.to_string())
8258
+ }
8259
+ _ => None,
8260
+ })
8261
+ .collect();
8262
+
8263
+ // Tentatively commit the routing state so `emit_agg_*` can resolve nested group calls.
8264
+ self.aggregate_alias = Some(alias.clone());
8265
+ self.aggregate_fns = sigs;
8266
+ self.aggregate_array_locals = array_locals;
8267
+
8268
+ // Emit every fn into a scratch buffer; on any failure, roll back (disable the path).
8269
+ let saved = std::mem::take(&mut self.output);
8270
+ let saved_indent = self.indent;
8271
+ self.indent = 0;
8272
+ let mut all_ok = true;
8273
+ for (nm, params, body) in &decls {
8274
+ if self.emit_aggregate_fn(nm, params, body).is_err() {
8275
+ all_ok = false;
8276
+ break;
8277
+ }
8278
+ }
8279
+ let emitted = std::mem::replace(&mut self.output, saved);
8280
+ self.indent = saved_indent;
8281
+ if dbg {
8282
+ eprintln!("[agg] all_ok = {} array_locals = {:?}", all_ok, self.aggregate_array_locals);
8283
+ }
8284
+ if all_ok {
8285
+ self.output.push_str(&emitted);
8286
+ self.writeln("");
8287
+ } else {
8288
+ // Roll back: disable the aggregate path entirely.
8289
+ self.aggregate_alias = None;
8290
+ self.aggregate_fns.clear();
8291
+ self.aggregate_array_locals.clear();
8292
+ }
8293
+ }
8294
+
8295
+ /// Emit one aggregate fn as a native Rust free fn (`fn name_agg(..) -> ..`).
8296
+ fn emit_aggregate_fn(
8297
+ &mut self,
8298
+ name: &str,
8299
+ _params: &[FunParam],
8300
+ body: &Statement,
8301
+ ) -> Result<(), CompileError> {
8302
+ let sig = self
8303
+ .aggregate_fns
8304
+ .get(name)
8305
+ .cloned()
8306
+ .expect("emit_aggregate_fn: sig present");
8307
+ let alias = self
8308
+ .aggregate_alias
8309
+ .clone()
8310
+ .expect("emit_aggregate_fn: alias present");
8311
+ let struct_ty = crate::types::named_struct_ident(&alias);
8312
+
8313
+ // Build the param list + register types in a fresh scope.
8314
+ self.type_context.push_scope();
8315
+ let mut plist: Vec<String> = Vec::new();
8316
+ let mut array_param: Option<String> = None;
8317
+ for (pname, kind) in &sig.params {
8318
+ let esc = Self::escape_ident(pname).into_owned();
8319
+ match kind {
8320
+ AggParamKind::Array { is_mut } => {
8321
+ let r = if *is_mut { "&mut " } else { "&" };
8322
+ plist.push(format!("{}: {}Vec<{}>", esc, r, struct_ty));
8323
+ array_param = Some(pname.clone());
8324
+ }
8325
+ AggParamKind::Scalar(ty) => {
8326
+ plist.push(format!("{}: {}", esc, ty.to_rust_type_str()));
8327
+ self.type_context.define(pname, ty.clone());
8328
+ }
8329
+ }
8330
+ }
8331
+ for g in &sig.captured {
8332
+ plist.push(format!("{}: f64", Self::escape_ident(g)));
8333
+ self.type_context.define(g, RustType::F64);
8334
+ }
8335
+ let ret_str = match sig.ret {
8336
+ AggRet::Struct => format!(" -> {}", struct_ty),
8337
+ AggRet::ArrayOfStruct => format!(" -> Vec<{}>", struct_ty),
8338
+ AggRet::F64 => " -> f64".to_string(),
8339
+ AggRet::Unit => String::new(),
8340
+ };
8341
+ self.writeln("#[allow(non_snake_case, unused)]");
8342
+ self.writeln(&format!(
8343
+ "fn {}_agg({}){} {{",
8344
+ Self::escape_ident(name),
8345
+ plist.join(", "),
8346
+ ret_str
8347
+ ));
8348
+ self.indent += 1;
8349
+ let prev_ret = self.agg_cur_ret.replace(sig.ret.clone());
8350
+ let mut aliases: std::collections::HashMap<String, String> = std::collections::HashMap::new();
8351
+ let res = self.emit_agg_stmt(body, array_param.as_deref(), &mut aliases);
8352
+ self.agg_cur_ret = prev_ret;
8353
+ self.indent -= 1;
8354
+ self.writeln("}");
8355
+ self.type_context.pop_scope();
8356
+ res
8357
+ }
8358
+
8359
+ /// Emit a statement inside an aggregate fn body. `arr` is the array param name (if any);
8360
+ /// `aliases` maps element-alias locals (`let bi = bodies[i]`) to their index-var name.
8361
+ fn emit_agg_stmt(
8362
+ &mut self,
8363
+ stmt: &Statement,
8364
+ arr: Option<&str>,
8365
+ aliases: &mut std::collections::HashMap<String, String>,
8366
+ ) -> Result<(), CompileError> {
8367
+ match stmt {
8368
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
8369
+ for s in statements {
8370
+ self.emit_agg_stmt(s, arr, aliases)?;
8371
+ }
8372
+ Ok(())
8373
+ }
8374
+ Statement::VarDecl { name, init, .. } => {
8375
+ let init = init
8376
+ .as_ref()
8377
+ .ok_or_else(|| CompileError::new("agg: uninit let", None))?;
8378
+ // Element alias: `let bi = bodies[i]` (i a bare ident) → record, emit nothing.
8379
+ if let Expr::Index { object, index, .. } = init {
8380
+ if let (Expr::Ident { name: on, .. }, Expr::Ident { name: iv, .. }) =
8381
+ (object.as_ref(), index.as_ref())
8382
+ {
8383
+ if Some(on.as_ref()) == arr {
8384
+ aliases.insert(name.to_string(), iv.to_string());
8385
+ return Ok(());
8386
+ }
8387
+ }
8388
+ }
8389
+ let (code, ty) = self.emit_agg_expr(init, arr, aliases)?;
8390
+ self.type_context.define(name, ty);
8391
+ self.writeln(&format!("let mut {} = {};", Self::escape_ident(name), code));
8392
+ Ok(())
8393
+ }
8394
+ Statement::ExprStmt { expr, .. } => {
8395
+ let code = self.emit_agg_assign(expr, arr, aliases)?;
8396
+ self.writeln(&format!("{};", code));
8397
+ Ok(())
8398
+ }
8399
+ Statement::Return { value, .. } => {
8400
+ let ret = self
8401
+ .agg_cur_ret
8402
+ .clone()
8403
+ .unwrap_or(AggRet::Unit);
8404
+ match (&ret, value) {
8405
+ (AggRet::Unit, _) => {
8406
+ self.writeln("return;");
8407
+ Ok(())
8408
+ }
8409
+ (AggRet::F64, Some(e)) => {
8410
+ let (code, ty) = self.emit_agg_expr(e, arr, aliases)?;
8411
+ let f = if ty == RustType::F64 {
8412
+ code
8413
+ } else {
8414
+ return Err(CompileError::new("agg: non-f64 return", None));
8415
+ };
8416
+ self.writeln(&format!("return {};", f));
8417
+ Ok(())
8418
+ }
8419
+ (AggRet::Struct, Some(e)) => {
8420
+ let code = self.emit_agg_struct_literal(e, arr, aliases)?;
8421
+ self.writeln(&format!("return {};", code));
8422
+ Ok(())
8423
+ }
8424
+ (AggRet::ArrayOfStruct, Some(e)) => {
8425
+ let code = self.emit_agg_array_literal(e, arr, aliases)?;
8426
+ self.writeln(&format!("return {};", code));
8427
+ Ok(())
8428
+ }
8429
+ _ => Err(CompileError::new("agg: bad return", None)),
8430
+ }
8431
+ }
8432
+ Statement::For {
8433
+ init,
8434
+ cond,
8435
+ update,
8436
+ body,
8437
+ ..
8438
+ } => {
8439
+ // `for (init; cond; update) body` → `{ init; while cond { body; update; } }`.
8440
+ // The candidacy predicate forbids `break`/`continue` reaching this emitter (the
8441
+ // codegen gate below bails on them), so the lowering is faithful.
8442
+ self.writeln("{");
8443
+ self.indent += 1;
8444
+ if let Some(i) = init {
8445
+ self.emit_agg_stmt(i, arr, aliases)?;
8446
+ }
8447
+ let cond_code = match cond {
8448
+ Some(c) => {
8449
+ let (code, _) = self.emit_agg_expr(c, arr, aliases)?;
8450
+ code
8451
+ }
8452
+ None => "true".to_string(),
8453
+ };
8454
+ self.writeln(&format!("while {} {{", cond_code));
8455
+ self.indent += 1;
8456
+ self.emit_agg_stmt(body, arr, aliases)?;
8457
+ if let Some(u) = update {
8458
+ let ucode = self.emit_agg_assign(u, arr, aliases)?;
8459
+ self.writeln(&format!("{};", ucode));
8460
+ }
8461
+ self.indent -= 1;
8462
+ self.writeln("}");
8463
+ self.indent -= 1;
8464
+ self.writeln("}");
8465
+ Ok(())
8466
+ }
8467
+ _ => Err(CompileError::new("agg: unsupported statement", None)),
8468
+ }
8469
+ }
8470
+
8471
+ /// Emit an assignment / increment expression in statement position inside an aggregate fn.
8472
+ fn emit_agg_assign(
8473
+ &mut self,
8474
+ expr: &Expr,
8475
+ arr: Option<&str>,
8476
+ aliases: &std::collections::HashMap<String, String>,
8477
+ ) -> Result<String, CompileError> {
8478
+ match expr {
8479
+ // `px = <f64>` — scalar local/param assign.
8480
+ Expr::Assign { name, value, .. } => {
8481
+ let (v, _) = self.emit_agg_expr(value, arr, aliases)?;
8482
+ Ok(format!("{} = {}", Self::escape_ident(name), v))
8483
+ }
8484
+ Expr::CompoundAssign {
8485
+ name, op, value, ..
8486
+ } => {
8487
+ let (v, _) = self.emit_agg_expr(value, arr, aliases)?;
8488
+ let op_str = match op {
8489
+ CompoundOp::Add => "+=",
8490
+ CompoundOp::Sub => "-=",
8491
+ CompoundOp::Mul => "*=",
8492
+ CompoundOp::Div => "/=",
8493
+ CompoundOp::Mod => "%=",
8494
+ };
8495
+ Ok(format!("{} {} {}", Self::escape_ident(name), op_str, v))
8496
+ }
8497
+ // `i++` / `++i` / `i--` / `--i` (loop update) → native f64 step.
8498
+ Expr::PostfixInc { name, .. } | Expr::PrefixInc { name, .. } => {
8499
+ Ok(format!("{} += 1f64", Self::escape_ident(name)))
8500
+ }
8501
+ Expr::PostfixDec { name, .. } | Expr::PrefixDec { name, .. } => {
8502
+ Ok(format!("{} -= 1f64", Self::escape_ident(name)))
8503
+ }
8504
+ // `bi.vx = <f64>` (alias) / `bodies[i].vx = <f64>` (direct index) field write.
8505
+ Expr::MemberAssign {
8506
+ object,
8507
+ prop,
8508
+ value,
8509
+ ..
8510
+ } => {
8511
+ let field = crate::types::field_ident(prop.as_ref());
8512
+ let place = self.emit_agg_place(object, arr, aliases)?;
8513
+ let (v, _) = self.emit_agg_expr(value, arr, aliases)?;
8514
+ Ok(format!("{}.{} = {}", place, field, v))
8515
+ }
8516
+ _ => Err(CompileError::new("agg: unsupported statement expr", None)),
8517
+ }
8518
+ }
8519
+
8520
+ /// Emit the array-element place for a field write target: an alias ident `bi` or `bodies[i]`.
8521
+ fn emit_agg_place(
8522
+ &mut self,
8523
+ object: &Expr,
8524
+ arr: Option<&str>,
8525
+ aliases: &std::collections::HashMap<String, String>,
8526
+ ) -> Result<String, CompileError> {
8527
+ match object {
8528
+ Expr::Ident { name, .. } => {
8529
+ if let Some(idxvar) = aliases.get(name.as_ref()) {
8530
+ let a = arr.ok_or_else(|| CompileError::new("agg: alias no array", None))?;
8531
+ return Ok(format!(
8532
+ "{}[({}) as usize]",
8533
+ Self::escape_ident(a),
8534
+ Self::escape_ident(idxvar)
8535
+ ));
8536
+ }
8537
+ Err(CompileError::new("agg: bad write target", None))
8538
+ }
8539
+ Expr::Index { object: io, index, .. } => {
8540
+ if let Expr::Ident { name: on, .. } = io.as_ref() {
8541
+ if Some(on.as_ref()) == arr {
8542
+ let (idx, _) = self.emit_agg_expr(index, arr, aliases)?;
8543
+ return Ok(format!("{}[({}) as usize]", Self::escape_ident(on.as_ref()), idx));
8544
+ }
8545
+ }
8546
+ Err(CompileError::new("agg: bad index write target", None))
8547
+ }
8548
+ _ => Err(CompileError::new("agg: bad write target", None)),
8549
+ }
8550
+ }
8551
+
8552
+ /// Emit a (scalar / bool) expression inside an aggregate fn body.
8553
+ fn emit_agg_expr(
8554
+ &mut self,
8555
+ e: &Expr,
8556
+ arr: Option<&str>,
8557
+ aliases: &std::collections::HashMap<String, String>,
8558
+ ) -> Result<(String, RustType), CompileError> {
8559
+ match e {
8560
+ Expr::Literal {
8561
+ value: Literal::Number(n),
8562
+ ..
8563
+ } => Ok((Self::f64_lit(*n), RustType::F64)),
8564
+ Expr::Literal {
8565
+ value: Literal::Bool(b),
8566
+ ..
8567
+ } => Ok((format!("{}", b), RustType::Bool)),
8568
+ Expr::Ident { name, .. } => {
8569
+ let ty = self.type_context.get_type(name.as_ref());
8570
+ Ok((Self::escape_ident(name.as_ref()).into_owned(), ty))
8571
+ }
8572
+ Expr::Unary {
8573
+ op: UnaryOp::Neg,
8574
+ operand,
8575
+ ..
8576
+ } => {
8577
+ let (o, _) = self.emit_agg_expr(operand, arr, aliases)?;
8578
+ Ok((format!("(-({}))", o), RustType::F64))
8579
+ }
8580
+ Expr::Unary {
8581
+ op: UnaryOp::Pos,
8582
+ operand,
8583
+ ..
8584
+ } => {
8585
+ let (o, _) = self.emit_agg_expr(operand, arr, aliases)?;
8586
+ Ok((format!("({})", o), RustType::F64))
8587
+ }
8588
+ Expr::Binary {
8589
+ left, op, right, ..
8590
+ } => {
8591
+ let (l, _) = self.emit_agg_expr(left, arr, aliases)?;
8592
+ let (r, _) = self.emit_agg_expr(right, arr, aliases)?;
8593
+ let (code, ty) = match op {
8594
+ BinOp::Add => (format!("({} + {})", l, r), RustType::F64),
8595
+ BinOp::Sub => (format!("({} - {})", l, r), RustType::F64),
8596
+ BinOp::Mul => (format!("({} * {})", l, r), RustType::F64),
8597
+ BinOp::Div => (format!("({} / {})", l, r), RustType::F64),
8598
+ BinOp::Mod => (format!("({} % {})", l, r), RustType::F64),
8599
+ BinOp::Pow => (format!("({}).powf({})", l, r), RustType::F64),
8600
+ BinOp::Lt => (format!("({} < {})", l, r), RustType::Bool),
8601
+ BinOp::Le => (format!("({} <= {})", l, r), RustType::Bool),
8602
+ BinOp::Gt => (format!("({} > {})", l, r), RustType::Bool),
8603
+ BinOp::Ge => (format!("({} >= {})", l, r), RustType::Bool),
8604
+ _ => {
8605
+ return Err(CompileError::new("agg: unsupported binop", None))
8606
+ }
8607
+ };
8608
+ Ok((code, ty))
8609
+ }
8610
+ Expr::Member {
8611
+ object,
8612
+ prop: MemberProp::Name { name: m, .. },
8613
+ optional: false,
8614
+ ..
8615
+ } => {
8616
+ if let Expr::Ident { name: on, .. } = object.as_ref() {
8617
+ // `bodies.length` → `(len() as f64)`.
8618
+ if Some(on.as_ref()) == arr && m.as_ref() == "length" {
8619
+ return Ok((
8620
+ format!("({}.len() as f64)", Self::escape_ident(on.as_ref())),
8621
+ RustType::F64,
8622
+ ));
8623
+ }
8624
+ // `bi.field` (element alias) → `bodies[i].field`.
8625
+ if let Some(idxvar) = aliases.get(on.as_ref()) {
8626
+ let a = arr
8627
+ .ok_or_else(|| CompileError::new("agg: alias no array", None))?;
8628
+ return Ok((
8629
+ format!(
8630
+ "{}[({}) as usize].{}",
8631
+ Self::escape_ident(a),
8632
+ Self::escape_ident(idxvar),
8633
+ crate::types::field_ident(m.as_ref())
8634
+ ),
8635
+ RustType::F64,
8636
+ ));
8637
+ }
8638
+ // `localStruct.field` (a `Named` local).
8639
+ let ty = self.type_context.get_type(on.as_ref());
8640
+ if let RustType::Named { fields, .. } = &ty {
8641
+ if let Some((_, ft)) =
8642
+ fields.iter().find(|(k, _)| k.as_ref() == m.as_ref())
8643
+ {
8644
+ return Ok((
8645
+ format!(
8646
+ "{}.{}",
8647
+ Self::escape_ident(on.as_ref()),
8648
+ crate::types::field_ident(m.as_ref())
8649
+ ),
8650
+ ft.clone(),
8651
+ ));
8652
+ }
8653
+ }
8654
+ }
8655
+ // `bodies[i].field` read.
8656
+ if let Expr::Index { object: io, index, .. } = object.as_ref() {
8657
+ if let Expr::Ident { name: on, .. } = io.as_ref() {
8658
+ if Some(on.as_ref()) == arr {
8659
+ let (idx, _) = self.emit_agg_expr(index, arr, aliases)?;
8660
+ return Ok((
8661
+ format!(
8662
+ "{}[({}) as usize].{}",
8663
+ Self::escape_ident(on.as_ref()),
8664
+ idx,
8665
+ crate::types::field_ident(m.as_ref())
8666
+ ),
8667
+ RustType::F64,
8668
+ ));
8669
+ }
8670
+ }
8671
+ }
8672
+ Err(CompileError::new("agg: unsupported member", None))
8673
+ }
8674
+ Expr::Call { callee, args, .. } => {
8675
+ // Nested call to another group fn (e.g. `body(...)` from `makeBodies`).
8676
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8677
+ if self.aggregate_fns.contains_key(fname.as_ref()) {
8678
+ let (code, ret) =
8679
+ self.emit_agg_group_call(fname.as_ref(), args, arr, aliases)?;
8680
+ let ty = match ret {
8681
+ AggRet::F64 => RustType::F64,
8682
+ AggRet::Struct => {
8683
+ let alias = self.aggregate_alias.clone().unwrap();
8684
+ RustType::Named {
8685
+ name: alias.as_str().into(),
8686
+ fields: Vec::new(),
8687
+ }
8688
+ }
8689
+ _ => {
8690
+ return Err(CompileError::new(
8691
+ "agg: call return not usable here",
8692
+ None,
8693
+ ))
8694
+ }
8695
+ };
8696
+ return Ok((code, ty));
8697
+ }
8698
+ }
8699
+ // `Math.<fn>(x)` clean f64-method intrinsics.
8700
+ if let Expr::Member {
8701
+ object,
8702
+ prop: MemberProp::Name { name: method, .. },
8703
+ ..
8704
+ } = callee.as_ref()
8705
+ {
8706
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
8707
+ {
8708
+ if let Some(m) = Self::agg_clean_math_method(method.as_ref()) {
8709
+ if let [CallArg::Expr(a)] = args.as_slice() {
8710
+ let (ac, _) = self.emit_agg_expr(a, arr, aliases)?;
8711
+ return Ok((format!("({}).{}()", ac, m), RustType::F64));
8712
+ }
8713
+ }
8714
+ }
8715
+ }
8716
+ Err(CompileError::new("agg: unsupported call", None))
8717
+ }
8718
+ Expr::Conditional {
8719
+ cond,
8720
+ then_branch,
8721
+ else_branch,
8722
+ ..
8723
+ } => {
8724
+ let (c, _) = self.emit_agg_expr(cond, arr, aliases)?;
8725
+ let (t, _) = self.emit_agg_expr(then_branch, arr, aliases)?;
8726
+ let (el, _) = self.emit_agg_expr(else_branch, arr, aliases)?;
8727
+ Ok((format!("(if {} {{ {} }} else {{ {} }})", c, t, el), RustType::F64))
8728
+ }
8729
+ _ => Err(CompileError::new("agg: unsupported expr", None)),
8730
+ }
8731
+ }
8732
+
8733
+ /// Emit a `return { ... }` object literal as a native struct literal (the `body()` factory).
8734
+ fn emit_agg_struct_literal(
8735
+ &mut self,
8736
+ e: &Expr,
8737
+ arr: Option<&str>,
8738
+ aliases: &std::collections::HashMap<String, String>,
8739
+ ) -> Result<String, CompileError> {
8740
+ let alias = self.aggregate_alias.clone().unwrap();
8741
+ let struct_ty = crate::types::named_struct_ident(&alias);
8742
+ let fields = match self.type_aliases.get(&alias) {
8743
+ Some(RustType::Named { fields, .. }) | Some(RustType::Object(fields)) => fields.clone(),
8744
+ _ => return Err(CompileError::new("agg: alias not a struct", None)),
8745
+ };
8746
+ let Expr::Object { props, .. } = e else {
8747
+ return Err(CompileError::new("agg: struct return not literal", None));
8748
+ };
8749
+ use std::collections::HashMap;
8750
+ let mut by_key: HashMap<String, &Expr> = HashMap::new();
8751
+ for p in props {
8752
+ match p {
8753
+ ObjectProp::KeyValue(k, v, _) => {
8754
+ by_key.insert(k.to_string(), v);
8755
+ }
8756
+ ObjectProp::Spread(_) => {
8757
+ return Err(CompileError::new("agg: struct spread", None))
8758
+ }
8759
+ }
8760
+ }
8761
+ let mut inits: Vec<String> = Vec::new();
8762
+ for (k, _) in &fields {
8763
+ let v = by_key
8764
+ .get(k.as_ref())
8765
+ .ok_or_else(|| CompileError::new("agg: struct missing field", None))?;
8766
+ let (code, _) = self.emit_agg_expr(v, arr, aliases)?;
8767
+ inits.push(format!("{}: {}", crate::types::field_ident(k.as_ref()), code));
8768
+ }
8769
+ Ok(format!("{} {{ {} }}", struct_ty, inits.join(", ")))
8770
+ }
8771
+
8772
+ /// Emit a `return [a, b, c]` array literal of struct-typed idents as `vec![a, b, c]`.
8773
+ fn emit_agg_array_literal(
8774
+ &mut self,
8775
+ e: &Expr,
8776
+ _arr: Option<&str>,
8777
+ _aliases: &std::collections::HashMap<String, String>,
8778
+ ) -> Result<String, CompileError> {
8779
+ let Expr::Array { elements, .. } = e else {
8780
+ return Err(CompileError::new("agg: array return not literal", None));
8781
+ };
8782
+ let mut items: Vec<String> = Vec::new();
8783
+ for el in elements {
8784
+ match el {
8785
+ ArrayElement::Expr(Expr::Ident { name, .. }) => {
8786
+ items.push(Self::escape_ident(name.as_ref()).into_owned());
8787
+ }
8788
+ _ => {
8789
+ return Err(CompileError::new(
8790
+ "agg: array element not a struct ident",
8791
+ None,
8792
+ ))
8793
+ }
8794
+ }
8795
+ }
8796
+ Ok(format!("vec![{}]", items.join(", ")))
8797
+ }
8798
+
8799
+ /// Emit a direct call to a group fn, threading the array by `&mut`/`&` plus captured globals.
8800
+ /// Returns the call code and the callee's return shape. `arr`/`aliases` describe the CALLER's
8801
+ /// context (so an array arg can be the caller's array param, though nbody only passes scalars
8802
+ /// across nested group calls).
8803
+ fn emit_agg_group_call(
8804
+ &mut self,
8805
+ name: &str,
8806
+ args: &[CallArg],
8807
+ arr: Option<&str>,
8808
+ aliases: &std::collections::HashMap<String, String>,
8809
+ ) -> Result<(String, AggRet), CompileError> {
8810
+ let sig = self
8811
+ .aggregate_fns
8812
+ .get(name)
8813
+ .cloned()
8814
+ .ok_or_else(|| CompileError::new("agg: unknown group fn", None))?;
8815
+ let mut call_args: Vec<String> = Vec::new();
8816
+ for (i, (_pname, kind)) in sig.params.iter().enumerate() {
8817
+ let a = match args.get(i) {
8818
+ Some(CallArg::Expr(e)) => e,
8819
+ _ => return Err(CompileError::new("agg: call arg shape", None)),
8820
+ };
8821
+ match kind {
8822
+ AggParamKind::Array { is_mut } => {
8823
+ // The arg must be a bare ident naming an array (caller's param or local).
8824
+ let Expr::Ident { name: an, .. } = a else {
8825
+ return Err(CompileError::new("agg: array arg not ident", None));
8826
+ };
8827
+ let r = if *is_mut { "&mut " } else { "&" };
8828
+ call_args.push(format!("{}{}", r, Self::escape_ident(an.as_ref())));
8829
+ }
8830
+ AggParamKind::Scalar(_) => {
8831
+ let (code, _) = self.emit_agg_expr(a, arr, aliases)?;
8832
+ call_args.push(code);
8833
+ }
8834
+ }
8835
+ }
8836
+ // Captured globals are visible as same-named f64 params/locals in the caller too.
8837
+ for g in &sig.captured {
8838
+ call_args.push(Self::escape_ident(g).into_owned());
8839
+ }
8840
+ Ok((
8841
+ format!("{}_agg({})", Self::escape_ident(name), call_args.join(", ")),
8842
+ sig.ret,
8843
+ ))
8844
+ }
8845
+
8846
+ /// Try to route a top-level call `name(args)` to its aggregate free fn. `as_value` wraps an
8847
+ /// f64 result in `Value::Number` for the boxed `emit_expr` context. Returns `None` if `name`
8848
+ /// isn't a group fn (caller falls back to the normal path).
8849
+ fn try_emit_toplevel_agg_call(
8850
+ &mut self,
8851
+ callee: &Expr,
8852
+ args: &[CallArg],
8853
+ as_value: bool,
8854
+ ) -> Result<Option<(String, RustType)>, CompileError> {
8855
+ let Expr::Ident { name, .. } = callee else {
8856
+ return Ok(None);
8857
+ };
8858
+ if !self.aggregate_fns.contains_key(name.as_ref()) {
8859
+ return Ok(None);
8860
+ }
8861
+ let sig = self.aggregate_fns.get(name.as_ref()).cloned().unwrap();
8862
+ let mut call_args: Vec<String> = Vec::new();
8863
+ for (i, (_pname, kind)) in sig.params.iter().enumerate() {
8864
+ let a = match args.get(i) {
8865
+ Some(CallArg::Expr(e)) => e,
8866
+ _ => return Ok(None),
8867
+ };
8868
+ match kind {
8869
+ AggParamKind::Array { is_mut } => {
8870
+ let Expr::Ident { name: an, .. } = a else {
8871
+ return Ok(None);
8872
+ };
8873
+ let r = if *is_mut { "&mut " } else { "&" };
8874
+ call_args.push(format!("{}{}", r, Self::escape_ident(an.as_ref())));
8875
+ }
8876
+ AggParamKind::Scalar(_) => {
8877
+ let (code, ty) = self.emit_typed_expr(a)?;
8878
+ let f = if ty == RustType::F64 {
8879
+ code
8880
+ } else if ty == RustType::Value {
8881
+ RustType::F64.from_value_expr(&code)
8882
+ } else {
8883
+ code
8884
+ };
8885
+ call_args.push(f);
8886
+ }
8887
+ }
8888
+ }
8889
+ for g in &sig.captured {
8890
+ let (code, ty) = self.emit_typed_expr(&Expr::Ident {
8891
+ name: g.as_str().into(),
8892
+ span: tishlang_ast::Span::default(),
8893
+ })?;
8894
+ let f = if ty == RustType::F64 {
8895
+ code
8896
+ } else if ty == RustType::Value {
8897
+ RustType::F64.from_value_expr(&code)
8898
+ } else {
8899
+ code
8900
+ };
8901
+ call_args.push(f);
8902
+ }
8903
+ let call = format!("{}_agg({})", Self::escape_ident(name.as_ref()), call_args.join(", "));
8904
+ let (code, ty) = match sig.ret {
8905
+ AggRet::F64 => {
8906
+ if as_value {
8907
+ (format!("Value::Number({})", call), RustType::Value)
8908
+ } else {
8909
+ (call, RustType::F64)
8910
+ }
8911
+ }
8912
+ AggRet::ArrayOfStruct => {
8913
+ let alias = self.aggregate_alias.clone().unwrap();
8914
+ (
8915
+ call,
8916
+ RustType::Vec(Box::new(RustType::Named {
8917
+ name: alias.as_str().into(),
8918
+ fields: Vec::new(),
8919
+ })),
8920
+ )
8921
+ }
8922
+ AggRet::Struct => {
8923
+ let alias = self.aggregate_alias.clone().unwrap();
8924
+ (
8925
+ call,
8926
+ RustType::Named {
8927
+ name: alias.as_str().into(),
8928
+ fields: Vec::new(),
8929
+ },
8930
+ )
8931
+ }
8932
+ // void call: `()`; valid only in statement position (ExprStmt).
8933
+ AggRet::Unit => (call, RustType::Unit),
8934
+ };
8935
+ Ok(Some((code, ty)))
8936
+ }
8937
+
8938
+ /// Detect the unboxed struct alias: the unique type-alias name `A` that is (a) used as an
8939
+ /// `A[]` array param of some top-level fn and (b) registered as a struct whose fields are all
8940
+ /// `Copy` (numeric/bool) — so element field reads/writes by index are sound. Returns `None`
8941
+ /// if there is no such alias or more than one (ambiguous → bail to boxed).
8942
+ fn detect_aggregate_alias(&self, program: &Program) -> Option<String> {
8943
+ let mut found: Option<String> = None;
8944
+ for s in &program.statements {
8945
+ if let Statement::FunDecl { params, .. } = s {
8946
+ for p in params {
8947
+ if let FunParam::Simple(tp) = p {
8948
+ if let Some(TypeAnnotation::Array(inner)) = tp.type_ann.as_ref() {
8949
+ if let TypeAnnotation::Simple(a, _) = inner.as_ref() {
8950
+ let a = a.to_string();
8951
+ if !self.alias_is_copy_struct(&a) {
8952
+ continue;
8953
+ }
8954
+ match &found {
8955
+ Some(prev) if prev != &a => return None, // ambiguous
8956
+ _ => found = Some(a),
8957
+ }
8958
+ }
8959
+ }
8960
+ }
8961
+ }
8962
+ }
8963
+ }
8964
+ found
8965
+ }
8966
+
8967
+ /// Is `name` a registered struct alias whose every field is a `Copy` scalar (f64/bool/i32)?
8968
+ fn alias_is_copy_struct(&self, name: &str) -> bool {
8969
+ match self.type_aliases.get(name) {
8970
+ Some(RustType::Named { fields, .. }) | Some(RustType::Object(fields)) => {
8971
+ !fields.is_empty()
8972
+ && fields.iter().all(|(_, t)| {
8973
+ matches!(t, RustType::F64 | RustType::Bool | RustType::I32)
8974
+ })
8975
+ }
8976
+ _ => false,
8977
+ }
8978
+ }
8979
+
8980
+ /// Is `ann` exactly `Simple(alias)`?
8981
+ fn ann_is_simple(ann: Option<&TypeAnnotation>, alias: &str) -> bool {
8982
+ matches!(ann, Some(TypeAnnotation::Simple(a, _)) if a.as_ref() == alias)
8983
+ }
8984
+
8985
+ /// Is `ann` exactly `Array(Simple(alias))` (i.e. `alias[]`)?
8986
+ fn ann_is_array_of(ann: Option<&TypeAnnotation>, alias: &str) -> bool {
8987
+ matches!(ann, Some(TypeAnnotation::Array(inner))
8988
+ if matches!(inner.as_ref(), TypeAnnotation::Simple(a, _) if a.as_ref() == alias))
8989
+ }
8990
+
8991
+ /// Math methods whose JS semantics match the Rust `f64` method 1:1 (no rounding/sign quirks).
8992
+ fn agg_clean_math_method(m: &str) -> Option<&'static str> {
8993
+ Some(match m {
8994
+ "sqrt" => "sqrt",
8995
+ "sin" => "sin",
8996
+ "cos" => "cos",
8997
+ "tan" => "tan",
8998
+ "exp" => "exp",
8999
+ "log" => "ln",
9000
+ "sinh" => "sinh",
9001
+ "cosh" => "cosh",
9002
+ "tanh" => "tanh",
9003
+ "asin" => "asin",
9004
+ "acos" => "acos",
9005
+ "atan" => "atan",
9006
+ "asinh" => "asinh",
9007
+ "acosh" => "acosh",
9008
+ "atanh" => "atanh",
9009
+ "cbrt" => "cbrt",
9010
+ "log2" => "log2",
9011
+ "log10" => "log10",
9012
+ _ => return None,
9013
+ })
9014
+ }
9015
+
9016
+ /// Does `body` contain a write through array param `p` (element field write / index write)?
9017
+ fn agg_fn_mutates_array(body: &Statement, p: &str) -> bool {
9018
+ let mut aliases: std::collections::HashSet<String> = std::collections::HashSet::new();
9019
+ Self::agg_collect_aliases(body, p, &mut aliases);
9020
+ Self::agg_stmt_writes(body, p, &aliases)
9021
+ }
9022
+
9023
+ fn agg_collect_aliases(s: &Statement, p: &str, out: &mut std::collections::HashSet<String>) {
9024
+ match s {
9025
+ Statement::VarDecl {
9026
+ name,
9027
+ init: Some(Expr::Index { object, index, .. }),
9028
+ ..
9029
+ } => {
9030
+ if matches!(object.as_ref(), Expr::Ident { name: o, .. } if o.as_ref() == p)
9031
+ && matches!(index.as_ref(), Expr::Ident { .. })
9032
+ {
9033
+ out.insert(name.to_string());
9034
+ }
9035
+ }
9036
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
9037
+ statements.iter().for_each(|x| Self::agg_collect_aliases(x, p, out));
9038
+ }
9039
+ Statement::If {
9040
+ then_branch,
9041
+ else_branch,
9042
+ ..
9043
+ } => {
9044
+ Self::agg_collect_aliases(then_branch, p, out);
9045
+ if let Some(e) = else_branch {
9046
+ Self::agg_collect_aliases(e, p, out);
9047
+ }
9048
+ }
9049
+ Statement::For { init, body, .. } => {
9050
+ if let Some(i) = init {
9051
+ Self::agg_collect_aliases(i, p, out);
9052
+ }
9053
+ Self::agg_collect_aliases(body, p, out);
9054
+ }
9055
+ Statement::While { body, .. }
9056
+ | Statement::DoWhile { body, .. }
9057
+ | Statement::ForOf { body, .. } => Self::agg_collect_aliases(body, p, out),
9058
+ _ => {}
9059
+ }
9060
+ }
9061
+
9062
+ fn agg_stmt_writes(
9063
+ s: &Statement,
9064
+ p: &str,
9065
+ aliases: &std::collections::HashSet<String>,
9066
+ ) -> bool {
9067
+ match s {
9068
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
9069
+ statements.iter().any(|x| Self::agg_stmt_writes(x, p, aliases))
9070
+ }
9071
+ Statement::ExprStmt { expr, .. } => Self::agg_expr_writes(expr, p, aliases),
9072
+ Statement::If {
9073
+ then_branch,
9074
+ else_branch,
9075
+ ..
9076
+ } => {
9077
+ Self::agg_stmt_writes(then_branch, p, aliases)
9078
+ || else_branch
9079
+ .as_ref()
9080
+ .is_some_and(|e| Self::agg_stmt_writes(e, p, aliases))
9081
+ }
9082
+ Statement::For { body, .. }
9083
+ | Statement::While { body, .. }
9084
+ | Statement::DoWhile { body, .. }
9085
+ | Statement::ForOf { body, .. } => Self::agg_stmt_writes(body, p, aliases),
9086
+ _ => false,
9087
+ }
9088
+ }
9089
+
9090
+ fn agg_expr_writes(
9091
+ e: &Expr,
9092
+ p: &str,
9093
+ aliases: &std::collections::HashSet<String>,
9094
+ ) -> bool {
9095
+ match e {
9096
+ Expr::MemberAssign { object, .. } => match object.as_ref() {
9097
+ Expr::Ident { name, .. } => aliases.contains(name.as_ref()),
9098
+ Expr::Index { object: io, .. } => {
9099
+ matches!(io.as_ref(), Expr::Ident { name, .. } if name.as_ref() == p)
9100
+ }
9101
+ _ => false,
9102
+ },
9103
+ Expr::IndexAssign { object, .. } => {
9104
+ matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == p)
9105
+ }
9106
+ _ => false,
9107
+ }
9108
+ }
9109
+
9110
+ /// Top-level `let` names whose initializer is a numeric constant — the only globals safe to
9111
+ /// thread into an aggregate fn as a trailing `f64` param. A `let bodies = makeBodies()` or
9112
+ /// `let t0 = Date.now()` is excluded so it can never be mistyped as `f64`.
9113
+ fn collect_toplevel_global_lets(program: &Program) -> std::collections::HashSet<String> {
9114
+ let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
9115
+ for s in &program.statements {
9116
+ if let Statement::VarDecl {
9117
+ name,
9118
+ init: Some(e),
9119
+ ..
9120
+ } = s
9121
+ {
9122
+ if Self::expr_is_numeric_const(e, &out) {
9123
+ out.insert(name.to_string());
9124
+ }
9125
+ }
9126
+ }
9127
+ out
9128
+ }
9129
+
9130
+ /// Conservatively: a numeric literal, an arithmetic combination of such, or a reference to an
9131
+ /// already-proven numeric global (`numeric` carries the names accepted so far, in source order).
9132
+ fn expr_is_numeric_const(e: &Expr, numeric: &std::collections::HashSet<String>) -> bool {
9133
+ match e {
9134
+ Expr::Literal {
9135
+ value: Literal::Number(_),
9136
+ ..
9137
+ } => true,
9138
+ Expr::Ident { name, .. } => numeric.contains(name.as_ref()),
9139
+ Expr::Unary {
9140
+ op: UnaryOp::Neg | UnaryOp::Pos,
9141
+ operand,
9142
+ ..
9143
+ } => Self::expr_is_numeric_const(operand, numeric),
9144
+ Expr::Binary {
9145
+ left, op, right, ..
9146
+ } => {
9147
+ matches!(
9148
+ op,
9149
+ BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow
9150
+ ) && Self::expr_is_numeric_const(left, numeric)
9151
+ && Self::expr_is_numeric_const(right, numeric)
9152
+ }
9153
+ _ => false,
9154
+ }
9155
+ }
9156
+
9157
+ /// Captured globals a fn body references: free idents that are top-level globals, excluding
9158
+ /// the fn's own params, its locals, the other group-fn names, and the struct alias.
9159
+ fn agg_captured_globals(
9160
+ body: &Statement,
9161
+ params: &[FunParam],
9162
+ globals: &std::collections::HashSet<String>,
9163
+ group_fns: &std::collections::HashMap<String, AggFnSig>,
9164
+ self_name: &str,
9165
+ alias: &str,
9166
+ ) -> Vec<String> {
9167
+ let mut idents: std::collections::HashSet<String> = std::collections::HashSet::new();
9168
+ Self::collect_stmt_idents(body, &mut idents);
9169
+ let mut locals: std::collections::HashSet<String> = std::collections::HashSet::new();
9170
+ Self::collect_local_var_names(body, &mut locals);
9171
+ let pnames: std::collections::HashSet<String> = params
9172
+ .iter()
9173
+ .flat_map(|p| p.bound_names())
9174
+ .map(|n| n.to_string())
9175
+ .collect();
9176
+ let mut out: Vec<String> = idents
9177
+ .into_iter()
9178
+ .filter(|id| {
9179
+ globals.contains(id)
9180
+ && !pnames.contains(id)
9181
+ && !locals.contains(id)
9182
+ && !group_fns.contains_key(id)
9183
+ && id != self_name
9184
+ && id != alias
9185
+ })
9186
+ .collect();
9187
+ out.sort();
9188
+ out
9189
+ }
9190
+
9191
+ /// Does `s` contain a `return <value>` (vs only bare `return;` / no return)?
9192
+ fn stmt_returns_value(s: &Statement) -> bool {
9193
+ match s {
9194
+ Statement::Return { value, .. } => value.is_some(),
9195
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
9196
+ statements.iter().any(Self::stmt_returns_value)
9197
+ }
9198
+ Statement::If {
9199
+ then_branch,
9200
+ else_branch,
9201
+ ..
9202
+ } => {
9203
+ Self::stmt_returns_value(then_branch)
9204
+ || else_branch.as_ref().is_some_and(|e| Self::stmt_returns_value(e))
9205
+ }
9206
+ Statement::For { body, .. }
9207
+ | Statement::While { body, .. }
9208
+ | Statement::DoWhile { body, .. }
9209
+ | Statement::ForOf { body, .. } => Self::stmt_returns_value(body),
9210
+ _ => false,
9211
+ }
9212
+ }
9213
+
9214
+ /// Lower an expression that JS will coerce to **int32** inside a bitwise/shift computation,
9215
+ /// staying in the integer domain instead of round-tripping every intermediate through `f64`.
9216
+ ///
9217
+ /// Returns `Ok(Some(code))` where `code` is an `i32`-typed Rust expression equal to
9218
+ /// `ToInt32(e)`, or `Ok(None)` if a leaf can't be proven `F64` (then the caller keeps the
9219
+ /// existing per-op lowering — purely additive, never a regression).
9220
+ ///
9221
+ /// This is behaviour-identical to the nested `to_int32`/`to_uint32` lowering: an intermediate
9222
+ /// `(i32 as f64)` immediately re-narrowed by `to_int32` is exact (every `i32` is representable
9223
+ /// in `f64`, and `to_int32` of a finite value recovers it), so erasing it changes nothing but
9224
+ /// the round-trips. Crucially, only bitwise/shift nodes recurse — an `f64` `*`/`+`/`-` node is
9225
+ /// a *leaf* here, so e.g. `(h * 16777619) >>> 0` keeps its `f64` multiply (the 2^53 rule: the
9226
+ /// product exceeds 2^53 and must round in `f64` *before* `ToUint32`, exactly as V8 does).
9227
+ fn emit_int32_operand(&mut self, e: &Expr) -> Result<Option<String>, CompileError> {
9228
+ if let Expr::Binary {
9229
+ left, op, right, ..
9230
+ } = e
9231
+ {
9232
+ let bitwise = matches!(
9233
+ op,
9234
+ BinOp::BitAnd
9235
+ | BinOp::BitOr
9236
+ | BinOp::BitXor
9237
+ | BinOp::Shl
9238
+ | BinOp::Shr
9239
+ | BinOp::UShr
9240
+ );
9241
+ if bitwise {
9242
+ let li = match self.emit_int32_operand(left)? {
9243
+ Some(c) => c,
9244
+ None => return Ok(None),
9245
+ };
9246
+ let ri = match self.emit_int32_operand(right)? {
9247
+ Some(c) => c,
9248
+ None => return Ok(None),
9249
+ };
9250
+ // Shift counts: `(ri as u32)` shares its low 5 bits with `to_uint32(rhs)`, and
9251
+ // `wrapping_sh*` masks the count mod 32 — exactly JS's `count & 31`.
9252
+ let code = match op {
9253
+ BinOp::BitAnd => format!("({} & {})", li, ri),
9254
+ BinOp::BitOr => format!("({} | {})", li, ri),
9255
+ BinOp::BitXor => format!("({} ^ {})", li, ri),
9256
+ BinOp::Shl => format!("({}).wrapping_shl(({}) as u32)", li, ri),
9257
+ BinOp::Shr => format!("({}).wrapping_shr(({}) as u32)", li, ri),
9258
+ // `>>>` is a logical shift on the uint32 view; reinterpret back to `i32` to
9259
+ // stay in the integer domain (the unsigned value is recovered at the f64 edge).
9260
+ BinOp::UShr => {
9261
+ format!("((({}) as u32).wrapping_shr(({}) as u32) as i32)", li, ri)
9262
+ }
9263
+ _ => unreachable!(),
9264
+ };
9265
+ return Ok(Some(code));
9266
+ }
9267
+ }
9268
+ // Leaf: fold only when it is a plain `f64` (so `to_int32` applies directly). `to_int32`
9269
+ // keeps its `is_finite` guard here — a leaf may legitimately be NaN/±Infinity (→ 0).
9270
+ let (code, ty) = self.emit_typed_expr(e)?;
9271
+ if ty == RustType::F64 {
9272
+ // When the leaf is an ARITHMETIC node PROVABLY finite with `|x| < 2^62` (operands are
9273
+ // i32-register reads and finite literals — e.g. the FNV `h * 16777619` excursion), drop
9274
+ // the `is_finite` guard and Rust's saturating cast and truncate directly. Bit-identical
9275
+ // on this domain (`x as i64` truncates toward zero = JS ToInt32 truncation; `as i32` =
9276
+ // modulo 2^32), a few instructions cheaper per iteration. Emitted inline so the generated
9277
+ // crate needs no new runtime symbol. Any unproven leaf keeps the guarded `to_int32`.
9278
+ if matches!(e, Expr::Binary { .. }) && self.f64_finite_bounded_below_2pow62(e) {
9279
+ Ok(Some(format!(
9280
+ "(unsafe {{ ({}).to_int_unchecked::<i64>() }} as i32)",
9281
+ code
9282
+ )))
9283
+ } else {
9284
+ Ok(Some(format!("tishlang_runtime::to_int32({})", code)))
9285
+ }
9286
+ } else if ty == RustType::I32 {
9287
+ // An `I32` loop-accumulator already holds its JS ToInt32 bit-pattern in an integer
9288
+ // register — feed it straight in, NO `to_int32` round-trip. This is the perf win: the
9289
+ // per-op `f64`→i32 narrowing across the hash loop collapses to a register read.
9290
+ Ok(Some(code))
9291
+ } else {
9292
+ Ok(None)
9293
+ }
9294
+ }
9295
+
9296
+ fn emit_typed_expr(&mut self, expr: &Expr) -> Result<(String, RustType), CompileError> {
9297
+ match expr {
9298
+ // ── literals ─────────────────────────────────────────────────────────
9299
+ Expr::Literal { value, .. } => match value {
9300
+ Literal::Number(n) => Ok((Self::f64_lit(*n), RustType::F64)),
9301
+ Literal::String(s) => {
9302
+ Ok((format!("{:?}.to_string()", s.as_ref()), RustType::String))
9303
+ }
9304
+ Literal::Bool(b) => Ok((format!("{}", b), RustType::Bool)),
9305
+ Literal::Null => Ok(("Value::Null".to_string(), RustType::Value)),
9306
+ },
9307
+
9308
+ // ── identifiers ──────────────────────────────────────────────────────
9309
+ Expr::Ident { name, .. } => {
9310
+ let escaped = Self::escape_ident(name.as_ref());
9311
+ if self.refcell_wrapped_vars.contains(name.as_ref()) {
9312
+ let var_type = self.type_context.get_type(name.as_ref());
9313
+ if var_type.is_native() {
9314
+ Ok((format!("(*{}.borrow()).clone()", escaped), var_type))
9315
+ } else {
9316
+ Ok((format!("(*{}.borrow()).clone()", escaped), RustType::Value))
9317
+ }
9318
+ } else {
9319
+ let var_type = self.type_context.get_type(name.as_ref());
9320
+ if var_type.is_native() {
9321
+ Ok((escaped.into_owned(), var_type))
9322
+ } else {
9323
+ Ok((escaped.into_owned(), RustType::Value))
9324
+ }
9325
+ }
9326
+ }
9327
+
9328
+ // ── binary expressions ───────────────────────────────────────────────
7887
9329
  Expr::Binary {
7888
9330
  left,
7889
9331
  op,
@@ -8138,6 +9580,13 @@ impl Codegen {
8138
9580
  // skipping the boxed value_call per element. Only methods whose Rust f64 op
8139
9581
  // matches JS semantics (round half-up & sign(0) differ → left to the runtime).
8140
9582
  Expr::Call { callee, args, .. } => {
9583
+ // #177: a de-virtualized aggregate fn used in native arithmetic (e.g. `energy(bodies)`
9584
+ // feeding an f64 expression) → call `name_agg(..)` returning the native type.
9585
+ if !self.aggregate_fns.is_empty() {
9586
+ if let Some((code, ty)) = self.try_emit_toplevel_agg_call(callee, args, false)? {
9587
+ return Ok((code, ty));
9588
+ }
9589
+ }
8141
9590
  // M5: direct call to an eligible native fn -> `name_native(<native args>)`.
8142
9591
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8143
9592
  if self.native_fns.contains(fname.as_ref()) {
@@ -8237,6 +9686,19 @@ impl Codegen {
8237
9686
  } => {
8238
9687
  if let Expr::Ident { name: var_name, .. } = object.as_ref() {
8239
9688
  let var_type = self.type_context.get_type(var_name.as_ref());
9689
+ // #173: `vec.length` on a native `Vec<_>` → `(vec.len() as f64)`, so the length
9690
+ // (and arithmetic derived from it) stays native instead of a boxed `get_prop`.
9691
+ if let RustType::Vec(_) = &var_type {
9692
+ if prop_name.as_ref() == "length" {
9693
+ let var_esc = Self::escape_ident(var_name.as_ref()).into_owned();
9694
+ let code = if self.refcell_wrapped_vars.contains(var_name.as_ref()) {
9695
+ format!("({}.borrow().len() as f64)", var_esc)
9696
+ } else {
9697
+ format!("({}.len() as f64)", var_esc)
9698
+ };
9699
+ return Ok((code, RustType::F64));
9700
+ }
9701
+ }
8240
9702
  if let RustType::Named { fields, .. } = &var_type {
8241
9703
  if let Some((_, field_ty)) =
8242
9704
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())