@tishlang/tish 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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);",
@@ -2135,7 +2200,11 @@ impl Codegen {
2135
2200
  // Track the variable type
2136
2201
  self.type_context.define(name.as_ref(), rust_type.clone());
2137
2202
 
2138
- let mutability = if *mutable { "let mut" } else { "let" };
2203
+ // #177: the unboxed `Vec<TishStruct>` local is threaded `&mut` into the aggregate
2204
+ // operators (`advance`/`offsetMomentum`), so it must be `mut` even when never
2205
+ // directly reassigned in source.
2206
+ let force_mut = self.aggregate_array_locals.contains(name.as_ref());
2207
+ let mutability = if *mutable || force_mut { "let mut" } else { "let" };
2139
2208
  let escaped_name = Self::escape_ident(name.as_ref());
2140
2209
 
2141
2210
  if rust_type.is_native() {
@@ -2605,6 +2674,15 @@ impl Codegen {
2605
2674
  span,
2606
2675
  ..
2607
2676
  } => {
2677
+ // #177: this function was de-virtualized into a native aggregate free fn
2678
+ // (`<name>_agg`, emitted before `run()`); all call sites were routed there.
2679
+ // Skip the boxed closure entirely — its body now references unboxed structs
2680
+ // that no longer fit the boxed `Value` ABI.
2681
+ if self.aggregate_alias.is_some()
2682
+ && self.aggregate_fns.contains_key(name.as_ref())
2683
+ {
2684
+ return Ok(());
2685
+ }
2608
2686
  // Use Rc<RefCell<>> pattern to allow recursive function calls
2609
2687
  // The function can reference itself through the cell
2610
2688
  let name_raw = name.as_ref();
@@ -3321,6 +3399,14 @@ impl Codegen {
3321
3399
  }
3322
3400
  }
3323
3401
  Expr::Call { callee, args, .. } => {
3402
+ // #177: route a top-level call to a de-virtualized aggregate fn. Void fns
3403
+ // (`advance`/`offsetMomentum`) emit `name_agg(&mut bodies, …)` (a `()` statement);
3404
+ // an f64-returning fn (`energy`) is boxed back into `Value::Number` for this path.
3405
+ if !self.aggregate_fns.is_empty() {
3406
+ if let Some((code, _)) = self.try_emit_toplevel_agg_call(callee, args, true)? {
3407
+ return Ok(code);
3408
+ }
3409
+ }
3324
3410
  // Typed-struct shortcut for `JSON.stringify(typedValue)`.
3325
3411
  // When the single arg has a known native type that owns a
3326
3412
  // hand-rolled `_tish_write_json` (struct or `Vec<struct>`),
@@ -5897,6 +5983,16 @@ impl Codegen {
5897
5983
  expr: &Expr,
5898
5984
  target_type: &RustType,
5899
5985
  ) -> Result<String, CompileError> {
5986
+ // #177: `let bodies = makeBodies()` — route the array-factory call to its native free fn
5987
+ // returning `Vec<TishStruct_alias>` directly (no boxed `Value::Array` round-trip).
5988
+ if !self.aggregate_fns.is_empty() {
5989
+ if let Expr::Call { callee, args, .. } = expr {
5990
+ if let Some((code, _)) = self.try_emit_toplevel_agg_call(callee, args, false)? {
5991
+ return Ok(code);
5992
+ }
5993
+ }
5994
+ }
5995
+
5900
5996
  // Try to emit literals directly as native types
5901
5997
  if let Expr::Literal { value, .. } = expr {
5902
5998
  match (target_type, value) {
@@ -7769,6 +7865,1110 @@ impl Codegen {
7769
7865
  Ok(())
7770
7866
  }
7771
7867
 
7868
+ // ====================================================================================
7869
+ // #177 (S-E / S-F): interprocedural aggregate (unboxed struct + Vec<Struct>) free fns.
7870
+ //
7871
+ // The infer front-end (S-0..S-D, behind TISH_AGGREGATE_INFER) stamps a struct alias and
7872
+ // `: alias` / `: alias[]` annotations onto the nbody-shape factory/array/operator fns iff a
7873
+ // whole-program candidacy predicate holds (monomorphic all-f64 struct, no `===`/escape/
7874
+ // reshape, write-permitting field ops). Here we consume that: emit each such fn as a native
7875
+ // Rust free fn over `Vec<TishStruct_alias>` threaded by `&mut`/`&`, with element access by
7876
+ // index (so JS reference aliasing — `let bi = bodies[i]; bi.vx = …` — lowers to in-place
7877
+ // `bodies[i].vx = …` and the mutation persists, exactly like the boxed `Value::Array`).
7878
+ //
7879
+ // SOUNDNESS: this is the codegen-side gate. We re-run `analyze_aggregate` to recover the
7880
+ // verdict, then emit every fn into a scratch buffer; if ANY construct can't be lowered the
7881
+ // whole path is disabled (the fns + the call hooks fall back to the boxed closures, byte-
7882
+ // identical to flag-off). So we never half-wire the feature or miscompile.
7883
+ // ====================================================================================
7884
+
7885
+ /// Compute the aggregate group from the stamped `: alias` / `: alias[]` annotations the infer
7886
+ /// front-end wrote (the infer→codegen contract — re-running `analyze_aggregate` on the already
7887
+ /// stamped program is NOT idempotent), emit the native free fns, and (on full success) record
7888
+ /// the routing state so call sites de-virtualize. On any failure the state is left empty (path
7889
+ /// disabled) and nothing is appended to the output.
7890
+ fn setup_aggregate_fns(&mut self, program: &Program) {
7891
+ let dbg = std::env::var("TISH_AGG_DEBUG").is_ok();
7892
+ // The unboxed struct alias: the (unique) name `A` used as an `A[]` array param of some fn,
7893
+ // and registered as an all-`Copy`-field struct alias.
7894
+ let Some(alias) = self.detect_aggregate_alias(program) else {
7895
+ if dbg {
7896
+ eprintln!("[agg] detect_aggregate_alias = None; aliases={:?}", self.type_aliases.keys().collect::<Vec<_>>());
7897
+ }
7898
+ return;
7899
+ };
7900
+ if dbg {
7901
+ eprintln!("[agg] alias = {}", alias);
7902
+ }
7903
+ // Top-level numeric global `let` names available to capture as trailing params.
7904
+ let globals = Self::collect_toplevel_global_lets(program);
7905
+
7906
+ // Build a signature for every fn in the group from its stamped annotations.
7907
+ let mut sigs: std::collections::HashMap<String, AggFnSig> = std::collections::HashMap::new();
7908
+ let mut decls: Vec<(String, Vec<FunParam>, Statement)> = Vec::new();
7909
+ for s in &program.statements {
7910
+ if let Statement::FunDecl {
7911
+ async_: false,
7912
+ name,
7913
+ params,
7914
+ rest_param: None,
7915
+ return_type,
7916
+ body,
7917
+ ..
7918
+ } = s
7919
+ {
7920
+ let nm = name.to_string();
7921
+ // The array param: a `Simple`-param annotated `: alias[]`.
7922
+ let array_pi = params.iter().position(|p| {
7923
+ matches!(p, FunParam::Simple(tp)
7924
+ if Self::ann_is_array_of(tp.type_ann.as_ref(), &alias))
7925
+ });
7926
+ // Return shape from the stamped return type, else from the body.
7927
+ let ret = if Self::ann_is_simple(return_type.as_ref(), &alias) {
7928
+ AggRet::Struct
7929
+ } else if Self::ann_is_array_of(return_type.as_ref(), &alias) {
7930
+ AggRet::ArrayOfStruct
7931
+ } else if array_pi.is_some() {
7932
+ if Self::stmt_returns_value(body) {
7933
+ AggRet::F64
7934
+ } else {
7935
+ AggRet::Unit
7936
+ }
7937
+ } else {
7938
+ // Not a factory and takes no array param → not in the group.
7939
+ continue;
7940
+ };
7941
+ let array_pname = array_pi.and_then(|pi| match params.get(pi) {
7942
+ Some(FunParam::Simple(tp)) => Some(tp.name.to_string()),
7943
+ _ => None,
7944
+ });
7945
+ let is_mut = array_pname
7946
+ .as_deref()
7947
+ .map(|p| Self::agg_fn_mutates_array(body, p))
7948
+ .unwrap_or(false);
7949
+ // Per-source-param kind.
7950
+ let mut sig_params: Vec<(String, AggParamKind)> = Vec::new();
7951
+ let mut ok = true;
7952
+ for (pi, p) in params.iter().enumerate() {
7953
+ match p {
7954
+ FunParam::Simple(tp) => {
7955
+ let kind = if Some(pi) == array_pi {
7956
+ AggParamKind::Array { is_mut }
7957
+ } else {
7958
+ // Scalar param: must be annotated `: number` (→ f64).
7959
+ let ty = tp
7960
+ .type_ann
7961
+ .as_ref()
7962
+ .map(|t| {
7963
+ crate::types::RustType::from_annotation_with_aliases(
7964
+ t,
7965
+ &self.type_aliases,
7966
+ )
7967
+ })
7968
+ .unwrap_or(RustType::Value);
7969
+ if ty != RustType::F64 {
7970
+ ok = false;
7971
+ break;
7972
+ }
7973
+ AggParamKind::Scalar(ty)
7974
+ };
7975
+ sig_params.push((tp.name.to_string(), kind));
7976
+ }
7977
+ _ => {
7978
+ ok = false;
7979
+ break;
7980
+ }
7981
+ }
7982
+ }
7983
+ if !ok {
7984
+ continue;
7985
+ }
7986
+ // Captured globals: free idents in the body that are top-level numeric globals and
7987
+ // not params/locals/group-fn names.
7988
+ let captured = Self::agg_captured_globals(body, params, &globals, &sigs, &nm, &alias);
7989
+ sigs.insert(
7990
+ nm.clone(),
7991
+ AggFnSig {
7992
+ params: sig_params,
7993
+ captured,
7994
+ ret,
7995
+ },
7996
+ );
7997
+ decls.push((nm, params.clone(), (**body).clone()));
7998
+ }
7999
+ }
8000
+ if dbg {
8001
+ eprintln!("[agg] sigs = {:?}", sigs.keys().collect::<Vec<_>>());
8002
+ }
8003
+ if sigs.is_empty() {
8004
+ return;
8005
+ }
8006
+
8007
+ // Top-level array locals: a `let bodies: alias[] = …` VarDecl.
8008
+ let array_locals: std::collections::HashSet<String> = program
8009
+ .statements
8010
+ .iter()
8011
+ .filter_map(|s| match s {
8012
+ Statement::VarDecl { name, type_ann, .. }
8013
+ if Self::ann_is_array_of(type_ann.as_ref(), &alias) =>
8014
+ {
8015
+ Some(name.to_string())
8016
+ }
8017
+ _ => None,
8018
+ })
8019
+ .collect();
8020
+
8021
+ // Tentatively commit the routing state so `emit_agg_*` can resolve nested group calls.
8022
+ self.aggregate_alias = Some(alias.clone());
8023
+ self.aggregate_fns = sigs;
8024
+ self.aggregate_array_locals = array_locals;
8025
+
8026
+ // Emit every fn into a scratch buffer; on any failure, roll back (disable the path).
8027
+ let saved = std::mem::take(&mut self.output);
8028
+ let saved_indent = self.indent;
8029
+ self.indent = 0;
8030
+ let mut all_ok = true;
8031
+ for (nm, params, body) in &decls {
8032
+ if self.emit_aggregate_fn(nm, params, body).is_err() {
8033
+ all_ok = false;
8034
+ break;
8035
+ }
8036
+ }
8037
+ let emitted = std::mem::replace(&mut self.output, saved);
8038
+ self.indent = saved_indent;
8039
+ if dbg {
8040
+ eprintln!("[agg] all_ok = {} array_locals = {:?}", all_ok, self.aggregate_array_locals);
8041
+ }
8042
+ if all_ok {
8043
+ self.output.push_str(&emitted);
8044
+ self.writeln("");
8045
+ } else {
8046
+ // Roll back: disable the aggregate path entirely.
8047
+ self.aggregate_alias = None;
8048
+ self.aggregate_fns.clear();
8049
+ self.aggregate_array_locals.clear();
8050
+ }
8051
+ }
8052
+
8053
+ /// Emit one aggregate fn as a native Rust free fn (`fn name_agg(..) -> ..`).
8054
+ fn emit_aggregate_fn(
8055
+ &mut self,
8056
+ name: &str,
8057
+ _params: &[FunParam],
8058
+ body: &Statement,
8059
+ ) -> Result<(), CompileError> {
8060
+ let sig = self
8061
+ .aggregate_fns
8062
+ .get(name)
8063
+ .cloned()
8064
+ .expect("emit_aggregate_fn: sig present");
8065
+ let alias = self
8066
+ .aggregate_alias
8067
+ .clone()
8068
+ .expect("emit_aggregate_fn: alias present");
8069
+ let struct_ty = crate::types::named_struct_ident(&alias);
8070
+
8071
+ // Build the param list + register types in a fresh scope.
8072
+ self.type_context.push_scope();
8073
+ let mut plist: Vec<String> = Vec::new();
8074
+ let mut array_param: Option<String> = None;
8075
+ for (pname, kind) in &sig.params {
8076
+ let esc = Self::escape_ident(pname).into_owned();
8077
+ match kind {
8078
+ AggParamKind::Array { is_mut } => {
8079
+ let r = if *is_mut { "&mut " } else { "&" };
8080
+ plist.push(format!("{}: {}Vec<{}>", esc, r, struct_ty));
8081
+ array_param = Some(pname.clone());
8082
+ }
8083
+ AggParamKind::Scalar(ty) => {
8084
+ plist.push(format!("{}: {}", esc, ty.to_rust_type_str()));
8085
+ self.type_context.define(pname, ty.clone());
8086
+ }
8087
+ }
8088
+ }
8089
+ for g in &sig.captured {
8090
+ plist.push(format!("{}: f64", Self::escape_ident(g)));
8091
+ self.type_context.define(g, RustType::F64);
8092
+ }
8093
+ let ret_str = match sig.ret {
8094
+ AggRet::Struct => format!(" -> {}", struct_ty),
8095
+ AggRet::ArrayOfStruct => format!(" -> Vec<{}>", struct_ty),
8096
+ AggRet::F64 => " -> f64".to_string(),
8097
+ AggRet::Unit => String::new(),
8098
+ };
8099
+ self.writeln("#[allow(non_snake_case, unused)]");
8100
+ self.writeln(&format!(
8101
+ "fn {}_agg({}){} {{",
8102
+ Self::escape_ident(name),
8103
+ plist.join(", "),
8104
+ ret_str
8105
+ ));
8106
+ self.indent += 1;
8107
+ let prev_ret = self.agg_cur_ret.replace(sig.ret.clone());
8108
+ let mut aliases: std::collections::HashMap<String, String> = std::collections::HashMap::new();
8109
+ let res = self.emit_agg_stmt(body, array_param.as_deref(), &mut aliases);
8110
+ self.agg_cur_ret = prev_ret;
8111
+ self.indent -= 1;
8112
+ self.writeln("}");
8113
+ self.type_context.pop_scope();
8114
+ res
8115
+ }
8116
+
8117
+ /// Emit a statement inside an aggregate fn body. `arr` is the array param name (if any);
8118
+ /// `aliases` maps element-alias locals (`let bi = bodies[i]`) to their index-var name.
8119
+ fn emit_agg_stmt(
8120
+ &mut self,
8121
+ stmt: &Statement,
8122
+ arr: Option<&str>,
8123
+ aliases: &mut std::collections::HashMap<String, String>,
8124
+ ) -> Result<(), CompileError> {
8125
+ match stmt {
8126
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
8127
+ for s in statements {
8128
+ self.emit_agg_stmt(s, arr, aliases)?;
8129
+ }
8130
+ Ok(())
8131
+ }
8132
+ Statement::VarDecl { name, init, .. } => {
8133
+ let init = init
8134
+ .as_ref()
8135
+ .ok_or_else(|| CompileError::new("agg: uninit let", None))?;
8136
+ // Element alias: `let bi = bodies[i]` (i a bare ident) → record, emit nothing.
8137
+ if let Expr::Index { object, index, .. } = init {
8138
+ if let (Expr::Ident { name: on, .. }, Expr::Ident { name: iv, .. }) =
8139
+ (object.as_ref(), index.as_ref())
8140
+ {
8141
+ if Some(on.as_ref()) == arr {
8142
+ aliases.insert(name.to_string(), iv.to_string());
8143
+ return Ok(());
8144
+ }
8145
+ }
8146
+ }
8147
+ let (code, ty) = self.emit_agg_expr(init, arr, aliases)?;
8148
+ self.type_context.define(name, ty);
8149
+ self.writeln(&format!("let mut {} = {};", Self::escape_ident(name), code));
8150
+ Ok(())
8151
+ }
8152
+ Statement::ExprStmt { expr, .. } => {
8153
+ let code = self.emit_agg_assign(expr, arr, aliases)?;
8154
+ self.writeln(&format!("{};", code));
8155
+ Ok(())
8156
+ }
8157
+ Statement::Return { value, .. } => {
8158
+ let ret = self
8159
+ .agg_cur_ret
8160
+ .clone()
8161
+ .unwrap_or(AggRet::Unit);
8162
+ match (&ret, value) {
8163
+ (AggRet::Unit, _) => {
8164
+ self.writeln("return;");
8165
+ Ok(())
8166
+ }
8167
+ (AggRet::F64, Some(e)) => {
8168
+ let (code, ty) = self.emit_agg_expr(e, arr, aliases)?;
8169
+ let f = if ty == RustType::F64 {
8170
+ code
8171
+ } else {
8172
+ return Err(CompileError::new("agg: non-f64 return", None));
8173
+ };
8174
+ self.writeln(&format!("return {};", f));
8175
+ Ok(())
8176
+ }
8177
+ (AggRet::Struct, Some(e)) => {
8178
+ let code = self.emit_agg_struct_literal(e, arr, aliases)?;
8179
+ self.writeln(&format!("return {};", code));
8180
+ Ok(())
8181
+ }
8182
+ (AggRet::ArrayOfStruct, Some(e)) => {
8183
+ let code = self.emit_agg_array_literal(e, arr, aliases)?;
8184
+ self.writeln(&format!("return {};", code));
8185
+ Ok(())
8186
+ }
8187
+ _ => Err(CompileError::new("agg: bad return", None)),
8188
+ }
8189
+ }
8190
+ Statement::For {
8191
+ init,
8192
+ cond,
8193
+ update,
8194
+ body,
8195
+ ..
8196
+ } => {
8197
+ // `for (init; cond; update) body` → `{ init; while cond { body; update; } }`.
8198
+ // The candidacy predicate forbids `break`/`continue` reaching this emitter (the
8199
+ // codegen gate below bails on them), so the lowering is faithful.
8200
+ self.writeln("{");
8201
+ self.indent += 1;
8202
+ if let Some(i) = init {
8203
+ self.emit_agg_stmt(i, arr, aliases)?;
8204
+ }
8205
+ let cond_code = match cond {
8206
+ Some(c) => {
8207
+ let (code, _) = self.emit_agg_expr(c, arr, aliases)?;
8208
+ code
8209
+ }
8210
+ None => "true".to_string(),
8211
+ };
8212
+ self.writeln(&format!("while {} {{", cond_code));
8213
+ self.indent += 1;
8214
+ self.emit_agg_stmt(body, arr, aliases)?;
8215
+ if let Some(u) = update {
8216
+ let ucode = self.emit_agg_assign(u, arr, aliases)?;
8217
+ self.writeln(&format!("{};", ucode));
8218
+ }
8219
+ self.indent -= 1;
8220
+ self.writeln("}");
8221
+ self.indent -= 1;
8222
+ self.writeln("}");
8223
+ Ok(())
8224
+ }
8225
+ _ => Err(CompileError::new("agg: unsupported statement", None)),
8226
+ }
8227
+ }
8228
+
8229
+ /// Emit an assignment / increment expression in statement position inside an aggregate fn.
8230
+ fn emit_agg_assign(
8231
+ &mut self,
8232
+ expr: &Expr,
8233
+ arr: Option<&str>,
8234
+ aliases: &std::collections::HashMap<String, String>,
8235
+ ) -> Result<String, CompileError> {
8236
+ match expr {
8237
+ // `px = <f64>` — scalar local/param assign.
8238
+ Expr::Assign { name, value, .. } => {
8239
+ let (v, _) = self.emit_agg_expr(value, arr, aliases)?;
8240
+ Ok(format!("{} = {}", Self::escape_ident(name), v))
8241
+ }
8242
+ Expr::CompoundAssign {
8243
+ name, op, value, ..
8244
+ } => {
8245
+ let (v, _) = self.emit_agg_expr(value, arr, aliases)?;
8246
+ let op_str = match op {
8247
+ CompoundOp::Add => "+=",
8248
+ CompoundOp::Sub => "-=",
8249
+ CompoundOp::Mul => "*=",
8250
+ CompoundOp::Div => "/=",
8251
+ CompoundOp::Mod => "%=",
8252
+ };
8253
+ Ok(format!("{} {} {}", Self::escape_ident(name), op_str, v))
8254
+ }
8255
+ // `i++` / `++i` / `i--` / `--i` (loop update) → native f64 step.
8256
+ Expr::PostfixInc { name, .. } | Expr::PrefixInc { name, .. } => {
8257
+ Ok(format!("{} += 1f64", Self::escape_ident(name)))
8258
+ }
8259
+ Expr::PostfixDec { name, .. } | Expr::PrefixDec { name, .. } => {
8260
+ Ok(format!("{} -= 1f64", Self::escape_ident(name)))
8261
+ }
8262
+ // `bi.vx = <f64>` (alias) / `bodies[i].vx = <f64>` (direct index) field write.
8263
+ Expr::MemberAssign {
8264
+ object,
8265
+ prop,
8266
+ value,
8267
+ ..
8268
+ } => {
8269
+ let field = crate::types::field_ident(prop.as_ref());
8270
+ let place = self.emit_agg_place(object, arr, aliases)?;
8271
+ let (v, _) = self.emit_agg_expr(value, arr, aliases)?;
8272
+ Ok(format!("{}.{} = {}", place, field, v))
8273
+ }
8274
+ _ => Err(CompileError::new("agg: unsupported statement expr", None)),
8275
+ }
8276
+ }
8277
+
8278
+ /// Emit the array-element place for a field write target: an alias ident `bi` or `bodies[i]`.
8279
+ fn emit_agg_place(
8280
+ &mut self,
8281
+ object: &Expr,
8282
+ arr: Option<&str>,
8283
+ aliases: &std::collections::HashMap<String, String>,
8284
+ ) -> Result<String, CompileError> {
8285
+ match object {
8286
+ Expr::Ident { name, .. } => {
8287
+ if let Some(idxvar) = aliases.get(name.as_ref()) {
8288
+ let a = arr.ok_or_else(|| CompileError::new("agg: alias no array", None))?;
8289
+ return Ok(format!(
8290
+ "{}[({}) as usize]",
8291
+ Self::escape_ident(a),
8292
+ Self::escape_ident(idxvar)
8293
+ ));
8294
+ }
8295
+ Err(CompileError::new("agg: bad write target", None))
8296
+ }
8297
+ Expr::Index { object: io, index, .. } => {
8298
+ if let Expr::Ident { name: on, .. } = io.as_ref() {
8299
+ if Some(on.as_ref()) == arr {
8300
+ let (idx, _) = self.emit_agg_expr(index, arr, aliases)?;
8301
+ return Ok(format!("{}[({}) as usize]", Self::escape_ident(on.as_ref()), idx));
8302
+ }
8303
+ }
8304
+ Err(CompileError::new("agg: bad index write target", None))
8305
+ }
8306
+ _ => Err(CompileError::new("agg: bad write target", None)),
8307
+ }
8308
+ }
8309
+
8310
+ /// Emit a (scalar / bool) expression inside an aggregate fn body.
8311
+ fn emit_agg_expr(
8312
+ &mut self,
8313
+ e: &Expr,
8314
+ arr: Option<&str>,
8315
+ aliases: &std::collections::HashMap<String, String>,
8316
+ ) -> Result<(String, RustType), CompileError> {
8317
+ match e {
8318
+ Expr::Literal {
8319
+ value: Literal::Number(n),
8320
+ ..
8321
+ } => Ok((Self::f64_lit(*n), RustType::F64)),
8322
+ Expr::Literal {
8323
+ value: Literal::Bool(b),
8324
+ ..
8325
+ } => Ok((format!("{}", b), RustType::Bool)),
8326
+ Expr::Ident { name, .. } => {
8327
+ let ty = self.type_context.get_type(name.as_ref());
8328
+ Ok((Self::escape_ident(name.as_ref()).into_owned(), ty))
8329
+ }
8330
+ Expr::Unary {
8331
+ op: UnaryOp::Neg,
8332
+ operand,
8333
+ ..
8334
+ } => {
8335
+ let (o, _) = self.emit_agg_expr(operand, arr, aliases)?;
8336
+ Ok((format!("(-({}))", o), RustType::F64))
8337
+ }
8338
+ Expr::Unary {
8339
+ op: UnaryOp::Pos,
8340
+ operand,
8341
+ ..
8342
+ } => {
8343
+ let (o, _) = self.emit_agg_expr(operand, arr, aliases)?;
8344
+ Ok((format!("({})", o), RustType::F64))
8345
+ }
8346
+ Expr::Binary {
8347
+ left, op, right, ..
8348
+ } => {
8349
+ let (l, _) = self.emit_agg_expr(left, arr, aliases)?;
8350
+ let (r, _) = self.emit_agg_expr(right, arr, aliases)?;
8351
+ let (code, ty) = match op {
8352
+ BinOp::Add => (format!("({} + {})", l, r), RustType::F64),
8353
+ BinOp::Sub => (format!("({} - {})", l, r), RustType::F64),
8354
+ BinOp::Mul => (format!("({} * {})", l, r), RustType::F64),
8355
+ BinOp::Div => (format!("({} / {})", l, r), RustType::F64),
8356
+ BinOp::Mod => (format!("({} % {})", l, r), RustType::F64),
8357
+ BinOp::Pow => (format!("({}).powf({})", l, r), RustType::F64),
8358
+ BinOp::Lt => (format!("({} < {})", l, r), RustType::Bool),
8359
+ BinOp::Le => (format!("({} <= {})", l, r), RustType::Bool),
8360
+ BinOp::Gt => (format!("({} > {})", l, r), RustType::Bool),
8361
+ BinOp::Ge => (format!("({} >= {})", l, r), RustType::Bool),
8362
+ _ => {
8363
+ return Err(CompileError::new("agg: unsupported binop", None))
8364
+ }
8365
+ };
8366
+ Ok((code, ty))
8367
+ }
8368
+ Expr::Member {
8369
+ object,
8370
+ prop: MemberProp::Name { name: m, .. },
8371
+ optional: false,
8372
+ ..
8373
+ } => {
8374
+ if let Expr::Ident { name: on, .. } = object.as_ref() {
8375
+ // `bodies.length` → `(len() as f64)`.
8376
+ if Some(on.as_ref()) == arr && m.as_ref() == "length" {
8377
+ return Ok((
8378
+ format!("({}.len() as f64)", Self::escape_ident(on.as_ref())),
8379
+ RustType::F64,
8380
+ ));
8381
+ }
8382
+ // `bi.field` (element alias) → `bodies[i].field`.
8383
+ if let Some(idxvar) = aliases.get(on.as_ref()) {
8384
+ let a = arr
8385
+ .ok_or_else(|| CompileError::new("agg: alias no array", None))?;
8386
+ return Ok((
8387
+ format!(
8388
+ "{}[({}) as usize].{}",
8389
+ Self::escape_ident(a),
8390
+ Self::escape_ident(idxvar),
8391
+ crate::types::field_ident(m.as_ref())
8392
+ ),
8393
+ RustType::F64,
8394
+ ));
8395
+ }
8396
+ // `localStruct.field` (a `Named` local).
8397
+ let ty = self.type_context.get_type(on.as_ref());
8398
+ if let RustType::Named { fields, .. } = &ty {
8399
+ if let Some((_, ft)) =
8400
+ fields.iter().find(|(k, _)| k.as_ref() == m.as_ref())
8401
+ {
8402
+ return Ok((
8403
+ format!(
8404
+ "{}.{}",
8405
+ Self::escape_ident(on.as_ref()),
8406
+ crate::types::field_ident(m.as_ref())
8407
+ ),
8408
+ ft.clone(),
8409
+ ));
8410
+ }
8411
+ }
8412
+ }
8413
+ // `bodies[i].field` read.
8414
+ if let Expr::Index { object: io, index, .. } = object.as_ref() {
8415
+ if let Expr::Ident { name: on, .. } = io.as_ref() {
8416
+ if Some(on.as_ref()) == arr {
8417
+ let (idx, _) = self.emit_agg_expr(index, arr, aliases)?;
8418
+ return Ok((
8419
+ format!(
8420
+ "{}[({}) as usize].{}",
8421
+ Self::escape_ident(on.as_ref()),
8422
+ idx,
8423
+ crate::types::field_ident(m.as_ref())
8424
+ ),
8425
+ RustType::F64,
8426
+ ));
8427
+ }
8428
+ }
8429
+ }
8430
+ Err(CompileError::new("agg: unsupported member", None))
8431
+ }
8432
+ Expr::Call { callee, args, .. } => {
8433
+ // Nested call to another group fn (e.g. `body(...)` from `makeBodies`).
8434
+ if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8435
+ if self.aggregate_fns.contains_key(fname.as_ref()) {
8436
+ let (code, ret) =
8437
+ self.emit_agg_group_call(fname.as_ref(), args, arr, aliases)?;
8438
+ let ty = match ret {
8439
+ AggRet::F64 => RustType::F64,
8440
+ AggRet::Struct => {
8441
+ let alias = self.aggregate_alias.clone().unwrap();
8442
+ RustType::Named {
8443
+ name: alias.as_str().into(),
8444
+ fields: Vec::new(),
8445
+ }
8446
+ }
8447
+ _ => {
8448
+ return Err(CompileError::new(
8449
+ "agg: call return not usable here",
8450
+ None,
8451
+ ))
8452
+ }
8453
+ };
8454
+ return Ok((code, ty));
8455
+ }
8456
+ }
8457
+ // `Math.<fn>(x)` clean f64-method intrinsics.
8458
+ if let Expr::Member {
8459
+ object,
8460
+ prop: MemberProp::Name { name: method, .. },
8461
+ ..
8462
+ } = callee.as_ref()
8463
+ {
8464
+ if matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
8465
+ {
8466
+ if let Some(m) = Self::agg_clean_math_method(method.as_ref()) {
8467
+ if let [CallArg::Expr(a)] = args.as_slice() {
8468
+ let (ac, _) = self.emit_agg_expr(a, arr, aliases)?;
8469
+ return Ok((format!("({}).{}()", ac, m), RustType::F64));
8470
+ }
8471
+ }
8472
+ }
8473
+ }
8474
+ Err(CompileError::new("agg: unsupported call", None))
8475
+ }
8476
+ Expr::Conditional {
8477
+ cond,
8478
+ then_branch,
8479
+ else_branch,
8480
+ ..
8481
+ } => {
8482
+ let (c, _) = self.emit_agg_expr(cond, arr, aliases)?;
8483
+ let (t, _) = self.emit_agg_expr(then_branch, arr, aliases)?;
8484
+ let (el, _) = self.emit_agg_expr(else_branch, arr, aliases)?;
8485
+ Ok((format!("(if {} {{ {} }} else {{ {} }})", c, t, el), RustType::F64))
8486
+ }
8487
+ _ => Err(CompileError::new("agg: unsupported expr", None)),
8488
+ }
8489
+ }
8490
+
8491
+ /// Emit a `return { ... }` object literal as a native struct literal (the `body()` factory).
8492
+ fn emit_agg_struct_literal(
8493
+ &mut self,
8494
+ e: &Expr,
8495
+ arr: Option<&str>,
8496
+ aliases: &std::collections::HashMap<String, String>,
8497
+ ) -> Result<String, CompileError> {
8498
+ let alias = self.aggregate_alias.clone().unwrap();
8499
+ let struct_ty = crate::types::named_struct_ident(&alias);
8500
+ let fields = match self.type_aliases.get(&alias) {
8501
+ Some(RustType::Named { fields, .. }) | Some(RustType::Object(fields)) => fields.clone(),
8502
+ _ => return Err(CompileError::new("agg: alias not a struct", None)),
8503
+ };
8504
+ let Expr::Object { props, .. } = e else {
8505
+ return Err(CompileError::new("agg: struct return not literal", None));
8506
+ };
8507
+ use std::collections::HashMap;
8508
+ let mut by_key: HashMap<String, &Expr> = HashMap::new();
8509
+ for p in props {
8510
+ match p {
8511
+ ObjectProp::KeyValue(k, v, _) => {
8512
+ by_key.insert(k.to_string(), v);
8513
+ }
8514
+ ObjectProp::Spread(_) => {
8515
+ return Err(CompileError::new("agg: struct spread", None))
8516
+ }
8517
+ }
8518
+ }
8519
+ let mut inits: Vec<String> = Vec::new();
8520
+ for (k, _) in &fields {
8521
+ let v = by_key
8522
+ .get(k.as_ref())
8523
+ .ok_or_else(|| CompileError::new("agg: struct missing field", None))?;
8524
+ let (code, _) = self.emit_agg_expr(v, arr, aliases)?;
8525
+ inits.push(format!("{}: {}", crate::types::field_ident(k.as_ref()), code));
8526
+ }
8527
+ Ok(format!("{} {{ {} }}", struct_ty, inits.join(", ")))
8528
+ }
8529
+
8530
+ /// Emit a `return [a, b, c]` array literal of struct-typed idents as `vec![a, b, c]`.
8531
+ fn emit_agg_array_literal(
8532
+ &mut self,
8533
+ e: &Expr,
8534
+ _arr: Option<&str>,
8535
+ _aliases: &std::collections::HashMap<String, String>,
8536
+ ) -> Result<String, CompileError> {
8537
+ let Expr::Array { elements, .. } = e else {
8538
+ return Err(CompileError::new("agg: array return not literal", None));
8539
+ };
8540
+ let mut items: Vec<String> = Vec::new();
8541
+ for el in elements {
8542
+ match el {
8543
+ ArrayElement::Expr(Expr::Ident { name, .. }) => {
8544
+ items.push(Self::escape_ident(name.as_ref()).into_owned());
8545
+ }
8546
+ _ => {
8547
+ return Err(CompileError::new(
8548
+ "agg: array element not a struct ident",
8549
+ None,
8550
+ ))
8551
+ }
8552
+ }
8553
+ }
8554
+ Ok(format!("vec![{}]", items.join(", ")))
8555
+ }
8556
+
8557
+ /// Emit a direct call to a group fn, threading the array by `&mut`/`&` plus captured globals.
8558
+ /// Returns the call code and the callee's return shape. `arr`/`aliases` describe the CALLER's
8559
+ /// context (so an array arg can be the caller's array param, though nbody only passes scalars
8560
+ /// across nested group calls).
8561
+ fn emit_agg_group_call(
8562
+ &mut self,
8563
+ name: &str,
8564
+ args: &[CallArg],
8565
+ arr: Option<&str>,
8566
+ aliases: &std::collections::HashMap<String, String>,
8567
+ ) -> Result<(String, AggRet), CompileError> {
8568
+ let sig = self
8569
+ .aggregate_fns
8570
+ .get(name)
8571
+ .cloned()
8572
+ .ok_or_else(|| CompileError::new("agg: unknown group fn", None))?;
8573
+ let mut call_args: Vec<String> = Vec::new();
8574
+ for (i, (_pname, kind)) in sig.params.iter().enumerate() {
8575
+ let a = match args.get(i) {
8576
+ Some(CallArg::Expr(e)) => e,
8577
+ _ => return Err(CompileError::new("agg: call arg shape", None)),
8578
+ };
8579
+ match kind {
8580
+ AggParamKind::Array { is_mut } => {
8581
+ // The arg must be a bare ident naming an array (caller's param or local).
8582
+ let Expr::Ident { name: an, .. } = a else {
8583
+ return Err(CompileError::new("agg: array arg not ident", None));
8584
+ };
8585
+ let r = if *is_mut { "&mut " } else { "&" };
8586
+ call_args.push(format!("{}{}", r, Self::escape_ident(an.as_ref())));
8587
+ }
8588
+ AggParamKind::Scalar(_) => {
8589
+ let (code, _) = self.emit_agg_expr(a, arr, aliases)?;
8590
+ call_args.push(code);
8591
+ }
8592
+ }
8593
+ }
8594
+ // Captured globals are visible as same-named f64 params/locals in the caller too.
8595
+ for g in &sig.captured {
8596
+ call_args.push(Self::escape_ident(g).into_owned());
8597
+ }
8598
+ Ok((
8599
+ format!("{}_agg({})", Self::escape_ident(name), call_args.join(", ")),
8600
+ sig.ret,
8601
+ ))
8602
+ }
8603
+
8604
+ /// Try to route a top-level call `name(args)` to its aggregate free fn. `as_value` wraps an
8605
+ /// f64 result in `Value::Number` for the boxed `emit_expr` context. Returns `None` if `name`
8606
+ /// isn't a group fn (caller falls back to the normal path).
8607
+ fn try_emit_toplevel_agg_call(
8608
+ &mut self,
8609
+ callee: &Expr,
8610
+ args: &[CallArg],
8611
+ as_value: bool,
8612
+ ) -> Result<Option<(String, RustType)>, CompileError> {
8613
+ let Expr::Ident { name, .. } = callee else {
8614
+ return Ok(None);
8615
+ };
8616
+ if !self.aggregate_fns.contains_key(name.as_ref()) {
8617
+ return Ok(None);
8618
+ }
8619
+ let sig = self.aggregate_fns.get(name.as_ref()).cloned().unwrap();
8620
+ let mut call_args: Vec<String> = Vec::new();
8621
+ for (i, (_pname, kind)) in sig.params.iter().enumerate() {
8622
+ let a = match args.get(i) {
8623
+ Some(CallArg::Expr(e)) => e,
8624
+ _ => return Ok(None),
8625
+ };
8626
+ match kind {
8627
+ AggParamKind::Array { is_mut } => {
8628
+ let Expr::Ident { name: an, .. } = a else {
8629
+ return Ok(None);
8630
+ };
8631
+ let r = if *is_mut { "&mut " } else { "&" };
8632
+ call_args.push(format!("{}{}", r, Self::escape_ident(an.as_ref())));
8633
+ }
8634
+ AggParamKind::Scalar(_) => {
8635
+ let (code, ty) = self.emit_typed_expr(a)?;
8636
+ let f = if ty == RustType::F64 {
8637
+ code
8638
+ } else if ty == RustType::Value {
8639
+ RustType::F64.from_value_expr(&code)
8640
+ } else {
8641
+ code
8642
+ };
8643
+ call_args.push(f);
8644
+ }
8645
+ }
8646
+ }
8647
+ for g in &sig.captured {
8648
+ let (code, ty) = self.emit_typed_expr(&Expr::Ident {
8649
+ name: g.as_str().into(),
8650
+ span: tishlang_ast::Span::default(),
8651
+ })?;
8652
+ let f = if ty == RustType::F64 {
8653
+ code
8654
+ } else if ty == RustType::Value {
8655
+ RustType::F64.from_value_expr(&code)
8656
+ } else {
8657
+ code
8658
+ };
8659
+ call_args.push(f);
8660
+ }
8661
+ let call = format!("{}_agg({})", Self::escape_ident(name.as_ref()), call_args.join(", "));
8662
+ let (code, ty) = match sig.ret {
8663
+ AggRet::F64 => {
8664
+ if as_value {
8665
+ (format!("Value::Number({})", call), RustType::Value)
8666
+ } else {
8667
+ (call, RustType::F64)
8668
+ }
8669
+ }
8670
+ AggRet::ArrayOfStruct => {
8671
+ let alias = self.aggregate_alias.clone().unwrap();
8672
+ (
8673
+ call,
8674
+ RustType::Vec(Box::new(RustType::Named {
8675
+ name: alias.as_str().into(),
8676
+ fields: Vec::new(),
8677
+ })),
8678
+ )
8679
+ }
8680
+ AggRet::Struct => {
8681
+ let alias = self.aggregate_alias.clone().unwrap();
8682
+ (
8683
+ call,
8684
+ RustType::Named {
8685
+ name: alias.as_str().into(),
8686
+ fields: Vec::new(),
8687
+ },
8688
+ )
8689
+ }
8690
+ // void call: `()`; valid only in statement position (ExprStmt).
8691
+ AggRet::Unit => (call, RustType::Unit),
8692
+ };
8693
+ Ok(Some((code, ty)))
8694
+ }
8695
+
8696
+ /// Detect the unboxed struct alias: the unique type-alias name `A` that is (a) used as an
8697
+ /// `A[]` array param of some top-level fn and (b) registered as a struct whose fields are all
8698
+ /// `Copy` (numeric/bool) — so element field reads/writes by index are sound. Returns `None`
8699
+ /// if there is no such alias or more than one (ambiguous → bail to boxed).
8700
+ fn detect_aggregate_alias(&self, program: &Program) -> Option<String> {
8701
+ let mut found: Option<String> = None;
8702
+ for s in &program.statements {
8703
+ if let Statement::FunDecl { params, .. } = s {
8704
+ for p in params {
8705
+ if let FunParam::Simple(tp) = p {
8706
+ if let Some(TypeAnnotation::Array(inner)) = tp.type_ann.as_ref() {
8707
+ if let TypeAnnotation::Simple(a, _) = inner.as_ref() {
8708
+ let a = a.to_string();
8709
+ if !self.alias_is_copy_struct(&a) {
8710
+ continue;
8711
+ }
8712
+ match &found {
8713
+ Some(prev) if prev != &a => return None, // ambiguous
8714
+ _ => found = Some(a),
8715
+ }
8716
+ }
8717
+ }
8718
+ }
8719
+ }
8720
+ }
8721
+ }
8722
+ found
8723
+ }
8724
+
8725
+ /// Is `name` a registered struct alias whose every field is a `Copy` scalar (f64/bool/i32)?
8726
+ fn alias_is_copy_struct(&self, name: &str) -> bool {
8727
+ match self.type_aliases.get(name) {
8728
+ Some(RustType::Named { fields, .. }) | Some(RustType::Object(fields)) => {
8729
+ !fields.is_empty()
8730
+ && fields.iter().all(|(_, t)| {
8731
+ matches!(t, RustType::F64 | RustType::Bool | RustType::I32)
8732
+ })
8733
+ }
8734
+ _ => false,
8735
+ }
8736
+ }
8737
+
8738
+ /// Is `ann` exactly `Simple(alias)`?
8739
+ fn ann_is_simple(ann: Option<&TypeAnnotation>, alias: &str) -> bool {
8740
+ matches!(ann, Some(TypeAnnotation::Simple(a, _)) if a.as_ref() == alias)
8741
+ }
8742
+
8743
+ /// Is `ann` exactly `Array(Simple(alias))` (i.e. `alias[]`)?
8744
+ fn ann_is_array_of(ann: Option<&TypeAnnotation>, alias: &str) -> bool {
8745
+ matches!(ann, Some(TypeAnnotation::Array(inner))
8746
+ if matches!(inner.as_ref(), TypeAnnotation::Simple(a, _) if a.as_ref() == alias))
8747
+ }
8748
+
8749
+ /// Math methods whose JS semantics match the Rust `f64` method 1:1 (no rounding/sign quirks).
8750
+ fn agg_clean_math_method(m: &str) -> Option<&'static str> {
8751
+ Some(match m {
8752
+ "sqrt" => "sqrt",
8753
+ "sin" => "sin",
8754
+ "cos" => "cos",
8755
+ "tan" => "tan",
8756
+ "exp" => "exp",
8757
+ "log" => "ln",
8758
+ "sinh" => "sinh",
8759
+ "cosh" => "cosh",
8760
+ "tanh" => "tanh",
8761
+ "asin" => "asin",
8762
+ "acos" => "acos",
8763
+ "atan" => "atan",
8764
+ "asinh" => "asinh",
8765
+ "acosh" => "acosh",
8766
+ "atanh" => "atanh",
8767
+ "cbrt" => "cbrt",
8768
+ "log2" => "log2",
8769
+ "log10" => "log10",
8770
+ _ => return None,
8771
+ })
8772
+ }
8773
+
8774
+ /// Does `body` contain a write through array param `p` (element field write / index write)?
8775
+ fn agg_fn_mutates_array(body: &Statement, p: &str) -> bool {
8776
+ let mut aliases: std::collections::HashSet<String> = std::collections::HashSet::new();
8777
+ Self::agg_collect_aliases(body, p, &mut aliases);
8778
+ Self::agg_stmt_writes(body, p, &aliases)
8779
+ }
8780
+
8781
+ fn agg_collect_aliases(s: &Statement, p: &str, out: &mut std::collections::HashSet<String>) {
8782
+ match s {
8783
+ Statement::VarDecl {
8784
+ name,
8785
+ init: Some(Expr::Index { object, index, .. }),
8786
+ ..
8787
+ } => {
8788
+ if matches!(object.as_ref(), Expr::Ident { name: o, .. } if o.as_ref() == p)
8789
+ && matches!(index.as_ref(), Expr::Ident { .. })
8790
+ {
8791
+ out.insert(name.to_string());
8792
+ }
8793
+ }
8794
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
8795
+ statements.iter().for_each(|x| Self::agg_collect_aliases(x, p, out));
8796
+ }
8797
+ Statement::If {
8798
+ then_branch,
8799
+ else_branch,
8800
+ ..
8801
+ } => {
8802
+ Self::agg_collect_aliases(then_branch, p, out);
8803
+ if let Some(e) = else_branch {
8804
+ Self::agg_collect_aliases(e, p, out);
8805
+ }
8806
+ }
8807
+ Statement::For { init, body, .. } => {
8808
+ if let Some(i) = init {
8809
+ Self::agg_collect_aliases(i, p, out);
8810
+ }
8811
+ Self::agg_collect_aliases(body, p, out);
8812
+ }
8813
+ Statement::While { body, .. }
8814
+ | Statement::DoWhile { body, .. }
8815
+ | Statement::ForOf { body, .. } => Self::agg_collect_aliases(body, p, out),
8816
+ _ => {}
8817
+ }
8818
+ }
8819
+
8820
+ fn agg_stmt_writes(
8821
+ s: &Statement,
8822
+ p: &str,
8823
+ aliases: &std::collections::HashSet<String>,
8824
+ ) -> bool {
8825
+ match s {
8826
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
8827
+ statements.iter().any(|x| Self::agg_stmt_writes(x, p, aliases))
8828
+ }
8829
+ Statement::ExprStmt { expr, .. } => Self::agg_expr_writes(expr, p, aliases),
8830
+ Statement::If {
8831
+ then_branch,
8832
+ else_branch,
8833
+ ..
8834
+ } => {
8835
+ Self::agg_stmt_writes(then_branch, p, aliases)
8836
+ || else_branch
8837
+ .as_ref()
8838
+ .is_some_and(|e| Self::agg_stmt_writes(e, p, aliases))
8839
+ }
8840
+ Statement::For { body, .. }
8841
+ | Statement::While { body, .. }
8842
+ | Statement::DoWhile { body, .. }
8843
+ | Statement::ForOf { body, .. } => Self::agg_stmt_writes(body, p, aliases),
8844
+ _ => false,
8845
+ }
8846
+ }
8847
+
8848
+ fn agg_expr_writes(
8849
+ e: &Expr,
8850
+ p: &str,
8851
+ aliases: &std::collections::HashSet<String>,
8852
+ ) -> bool {
8853
+ match e {
8854
+ Expr::MemberAssign { object, .. } => match object.as_ref() {
8855
+ Expr::Ident { name, .. } => aliases.contains(name.as_ref()),
8856
+ Expr::Index { object: io, .. } => {
8857
+ matches!(io.as_ref(), Expr::Ident { name, .. } if name.as_ref() == p)
8858
+ }
8859
+ _ => false,
8860
+ },
8861
+ Expr::IndexAssign { object, .. } => {
8862
+ matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == p)
8863
+ }
8864
+ _ => false,
8865
+ }
8866
+ }
8867
+
8868
+ /// Top-level `let` names whose initializer is a numeric constant — the only globals safe to
8869
+ /// thread into an aggregate fn as a trailing `f64` param. A `let bodies = makeBodies()` or
8870
+ /// `let t0 = Date.now()` is excluded so it can never be mistyped as `f64`.
8871
+ fn collect_toplevel_global_lets(program: &Program) -> std::collections::HashSet<String> {
8872
+ let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
8873
+ for s in &program.statements {
8874
+ if let Statement::VarDecl {
8875
+ name,
8876
+ init: Some(e),
8877
+ ..
8878
+ } = s
8879
+ {
8880
+ if Self::expr_is_numeric_const(e, &out) {
8881
+ out.insert(name.to_string());
8882
+ }
8883
+ }
8884
+ }
8885
+ out
8886
+ }
8887
+
8888
+ /// Conservatively: a numeric literal, an arithmetic combination of such, or a reference to an
8889
+ /// already-proven numeric global (`numeric` carries the names accepted so far, in source order).
8890
+ fn expr_is_numeric_const(e: &Expr, numeric: &std::collections::HashSet<String>) -> bool {
8891
+ match e {
8892
+ Expr::Literal {
8893
+ value: Literal::Number(_),
8894
+ ..
8895
+ } => true,
8896
+ Expr::Ident { name, .. } => numeric.contains(name.as_ref()),
8897
+ Expr::Unary {
8898
+ op: UnaryOp::Neg | UnaryOp::Pos,
8899
+ operand,
8900
+ ..
8901
+ } => Self::expr_is_numeric_const(operand, numeric),
8902
+ Expr::Binary {
8903
+ left, op, right, ..
8904
+ } => {
8905
+ matches!(
8906
+ op,
8907
+ BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow
8908
+ ) && Self::expr_is_numeric_const(left, numeric)
8909
+ && Self::expr_is_numeric_const(right, numeric)
8910
+ }
8911
+ _ => false,
8912
+ }
8913
+ }
8914
+
8915
+ /// Captured globals a fn body references: free idents that are top-level globals, excluding
8916
+ /// the fn's own params, its locals, the other group-fn names, and the struct alias.
8917
+ fn agg_captured_globals(
8918
+ body: &Statement,
8919
+ params: &[FunParam],
8920
+ globals: &std::collections::HashSet<String>,
8921
+ group_fns: &std::collections::HashMap<String, AggFnSig>,
8922
+ self_name: &str,
8923
+ alias: &str,
8924
+ ) -> Vec<String> {
8925
+ let mut idents: std::collections::HashSet<String> = std::collections::HashSet::new();
8926
+ Self::collect_stmt_idents(body, &mut idents);
8927
+ let mut locals: std::collections::HashSet<String> = std::collections::HashSet::new();
8928
+ Self::collect_local_var_names(body, &mut locals);
8929
+ let pnames: std::collections::HashSet<String> = params
8930
+ .iter()
8931
+ .flat_map(|p| p.bound_names())
8932
+ .map(|n| n.to_string())
8933
+ .collect();
8934
+ let mut out: Vec<String> = idents
8935
+ .into_iter()
8936
+ .filter(|id| {
8937
+ globals.contains(id)
8938
+ && !pnames.contains(id)
8939
+ && !locals.contains(id)
8940
+ && !group_fns.contains_key(id)
8941
+ && id != self_name
8942
+ && id != alias
8943
+ })
8944
+ .collect();
8945
+ out.sort();
8946
+ out
8947
+ }
8948
+
8949
+ /// Does `s` contain a `return <value>` (vs only bare `return;` / no return)?
8950
+ fn stmt_returns_value(s: &Statement) -> bool {
8951
+ match s {
8952
+ Statement::Return { value, .. } => value.is_some(),
8953
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
8954
+ statements.iter().any(Self::stmt_returns_value)
8955
+ }
8956
+ Statement::If {
8957
+ then_branch,
8958
+ else_branch,
8959
+ ..
8960
+ } => {
8961
+ Self::stmt_returns_value(then_branch)
8962
+ || else_branch.as_ref().is_some_and(|e| Self::stmt_returns_value(e))
8963
+ }
8964
+ Statement::For { body, .. }
8965
+ | Statement::While { body, .. }
8966
+ | Statement::DoWhile { body, .. }
8967
+ | Statement::ForOf { body, .. } => Self::stmt_returns_value(body),
8968
+ _ => false,
8969
+ }
8970
+ }
8971
+
7772
8972
  /// Lower an expression that JS will coerce to **int32** inside a bitwise/shift computation,
7773
8973
  /// staying in the integer domain instead of round-tripping every intermediate through `f64`.
7774
8974
  ///
@@ -8138,6 +9338,13 @@ impl Codegen {
8138
9338
  // skipping the boxed value_call per element. Only methods whose Rust f64 op
8139
9339
  // matches JS semantics (round half-up & sign(0) differ → left to the runtime).
8140
9340
  Expr::Call { callee, args, .. } => {
9341
+ // #177: a de-virtualized aggregate fn used in native arithmetic (e.g. `energy(bodies)`
9342
+ // feeding an f64 expression) → call `name_agg(..)` returning the native type.
9343
+ if !self.aggregate_fns.is_empty() {
9344
+ if let Some((code, ty)) = self.try_emit_toplevel_agg_call(callee, args, false)? {
9345
+ return Ok((code, ty));
9346
+ }
9347
+ }
8141
9348
  // M5: direct call to an eligible native fn -> `name_native(<native args>)`.
8142
9349
  if let Expr::Ident { name: fname, .. } = callee.as_ref() {
8143
9350
  if self.native_fns.contains(fname.as_ref()) {