@tishlang/tish 2.9.0 → 2.12.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.
@@ -2097,6 +2097,148 @@ impl Codegen {
2097
2097
  self.emit_expr(expr)
2098
2098
  }
2099
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
+
2100
2242
  fn emit_statement(&mut self, stmt: &Statement) -> Result<(), CompileError> {
2101
2243
  match stmt {
2102
2244
  Statement::Block { statements, .. } => {
@@ -2424,6 +2566,18 @@ impl Codegen {
2424
2566
  body,
2425
2567
  ..
2426
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
+ }
2427
2581
  self.writeln("{");
2428
2582
  self.indent += 1;
2429
2583
  if let Some(i) = init {
@@ -7537,6 +7691,13 @@ impl Codegen {
7537
7691
  ..
7538
7692
  } => {
7539
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
+ }
7540
7701
  if let Some(RustType::Named { fields, .. }) = env.get(var_name.as_ref()) {
7541
7702
  if let Some((_, field_ty)) =
7542
7703
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
@@ -7577,6 +7738,12 @@ impl Codegen {
7577
7738
  }
7578
7739
  }
7579
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
+ }
7580
7747
  RustType::Value
7581
7748
  }
7582
7749
  // Unary, Conditional, etc. are not modelled by `emit_typed_expr` (it boxes them), so a
@@ -7586,6 +7753,81 @@ impl Codegen {
7586
7753
  }
7587
7754
  }
7588
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
+
7589
7831
  /// Names of top-level fns eligible for a parallel native `fn f_native(f64,..)->f64`:
7590
7832
  /// non-async, every param `: number` (no default), `: number` return, and a native-safe
7591
7833
  /// body (only block/if/return/expr-stmt over native exprs + calls to other eligible fns or
@@ -9444,6 +9686,19 @@ impl Codegen {
9444
9686
  } => {
9445
9687
  if let Expr::Ident { name: var_name, .. } = object.as_ref() {
9446
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
+ }
9447
9702
  if let RustType::Named { fields, .. } = &var_type {
9448
9703
  if let Some((_, field_ty)) =
9449
9704
  fields.iter().find(|(k, _)| k.as_ref() == prop_name.as_ref())
@@ -0,0 +1,139 @@
1
+ //! Generated-Rust assertions for the Beat-Node native codegen wins #169 and #173.
2
+ //!
3
+ //! These run in their own test binary (separate process from the lib unit tests), so the
4
+ //! dark-ship env flags set here never leak into other tests. Every test sets the SAME flag set
5
+ //! (the gauntlet's `TYPED_FLAGS`), so parallel execution is race-free.
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use tishlang_compile::{compile, compile_project_full};
10
+ use tishlang_parser::parse;
11
+
12
+ /// Enable every dark-shipped typed-native flag, matching `scripts/run_perf_gauntlet.sh`.
13
+ fn enable_typed_flags() {
14
+ for k in [
15
+ "TISH_PARAM_NATIVE",
16
+ "TISH_PARAM_INFER",
17
+ "TISH_NATIVE_FN",
18
+ "TISH_STRUCT_INFER",
19
+ "TISH_FUSED_HOF",
20
+ "TISH_NATIVE_HOF",
21
+ "TISH_AGGREGATE_INFER",
22
+ ] {
23
+ std::env::set_var(k, "1");
24
+ }
25
+ }
26
+
27
+ fn compile_typed(src: &str) -> String {
28
+ enable_typed_flags();
29
+ compile(&parse(src).unwrap()).unwrap()
30
+ }
31
+
32
+ /// Compile a real fixture through the **same** path the `tish build` CLI uses
33
+ /// (`compile_project_full` → `merge_modules` → codegen), so generated-Rust assertions match the
34
+ /// gauntlet build exactly.
35
+ fn compile_fixture_typed(rel: &str) -> String {
36
+ enable_typed_flags();
37
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
38
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
39
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
40
+ rust
41
+ }
42
+
43
+ // ── #169: a fused native `Vec<f64>` reduce result keeps its accumulator native ────────────────
44
+
45
+ #[test]
46
+ fn issue_169_reduce_fed_accumulator_stays_native() {
47
+ // The real gauntlet fixture: `acc` is updated from `xs.reduce(...)` (which the native-HOF path
48
+ // fuses to a native f64 fold). Before #169 the demotion oracle didn't model the fusion, so `acc`
49
+ // was boxed (`let mut acc = Value::Number(...)`) and paid boxed ops::mul/add/modulo + clone per
50
+ // iter. Compiled through the same `compile_project_full` path the `tish build` CLI uses.
51
+ let rust = compile_fixture_typed("tests/perf/typed_array_hof.tish");
52
+ assert!(
53
+ rust.contains("let mut acc: f64"),
54
+ "acc must stay native f64 (reduce result modelled as F64), got:\n{}",
55
+ rust.lines().filter(|l| l.contains("acc")).take(6).collect::<Vec<_>>().join("\n")
56
+ );
57
+ assert!(
58
+ !rust.contains("let mut acc = Value::Number"),
59
+ "acc must NOT be boxed:\n{}",
60
+ rust.lines().filter(|l| l.contains("acc")).take(6).collect::<Vec<_>>().join("\n")
61
+ );
62
+ // The per-iteration accumulator update is native arithmetic, not boxed ops on `acc`.
63
+ assert!(
64
+ !rust.contains("ops::mul(&acc"),
65
+ "acc update must be native f64, not a boxed ops::mul on acc"
66
+ );
67
+ }
68
+
69
+ #[test]
70
+ fn issue_169_non_number_array_reduce_does_not_make_accumulator_native() {
71
+ // Conservative bail: reduce over a *string* array is not a native f64 fold, so an accumulator it
72
+ // feeds must stay boxed (sound — we never claim F64 where the emitter would box).
73
+ let src = r#"
74
+ let ss: string[] = ["a", "b", "c"]
75
+ let acc: number = 0
76
+ let r: number = 0
77
+ while (r < 10) {
78
+ acc = acc + ss.length
79
+ r = r + 1
80
+ }
81
+ console.log(acc)
82
+ "#;
83
+ // Just assert it compiles and is sound; `acc` here is fed by `.length` which is a separate lever.
84
+ let rust = compile_typed(src);
85
+ assert!(rust.contains("fn main"), "compiles to a native program");
86
+ }
87
+
88
+ // ── #173: fill-loop fusion + native `.length` ─────────────────────────────────────────────────
89
+
90
+ #[test]
91
+ fn issue_173_fill_loop_fuses_to_bulk_extend() {
92
+ // `let a = []; for (let i = 0; i < N; i++) { a.push(K) }` over a native Vec lowers to a single
93
+ // `a.extend(std::iter::repeat(K).take((N) as usize))` — one allocation, no per-element pushes.
94
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
95
+ // boolean[] fill with a literal bound.
96
+ assert!(
97
+ rust.contains("extend(std::iter::repeat(true).take((8_f64) as usize))"),
98
+ "boolean fill loop must fuse to a bulk extend:\n{}",
99
+ rust.lines().filter(|l| l.contains("repeat")).collect::<Vec<_>>().join("\n")
100
+ );
101
+ // number[] fill with an integer-`let` bound (`n`).
102
+ assert!(
103
+ rust.contains("extend(std::iter::repeat(1_f64).take((n) as usize))"),
104
+ "number fill loop with an int-range bound must fuse:\n{}",
105
+ rust.lines().filter(|l| l.contains("repeat")).collect::<Vec<_>>().join("\n")
106
+ );
107
+ }
108
+
109
+ #[test]
110
+ fn issue_173_length_is_native_f64() {
111
+ // `vec.length` on a native Vec lowers to `(vec.len() as f64)`, not a boxed `get_prop`.
112
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
113
+ assert!(
114
+ rust.contains("a.len() as f64"),
115
+ "native Vec `.length` must lower to `(a.len() as f64)`"
116
+ );
117
+ assert!(
118
+ !rust.contains("get_prop(&a, \"length\")"),
119
+ "native Vec `.length` must not go through boxed get_prop"
120
+ );
121
+ }
122
+
123
+ #[test]
124
+ fn issue_173_adversarial_cases_do_not_fuse() {
125
+ // Non-constant push arg, extra statement, and `break` in the body must NOT fuse — they keep the
126
+ // per-element push loop (correctness over coverage). The fixture's only fusions are the three
127
+ // canonical fills (8, n, 3); a non-constant `push(i * 2)` must not produce an extend.
128
+ let rust = compile_fixture_typed("tests/core/array_init_pattern.tish");
129
+ assert!(
130
+ !rust.contains("repeat((i * 2"),
131
+ "non-constant push arg must not be fused into a repeat-fill"
132
+ );
133
+ // Exactly the three sound fills are emitted (8, n, 3) — no extra fusions crept in.
134
+ let fusions = rust.matches("std::iter::repeat").count();
135
+ assert_eq!(
136
+ fusions, 3,
137
+ "exactly 3 fill loops should fuse (boolFill=8, sieve=n, oobGrow=3), found {fusions}"
138
+ );
139
+ }