@tishlang/tish 2.2.7 → 2.8.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.
Files changed (52) hide show
  1. package/bin/tish +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +5 -1
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/src/cli_help.rs +12 -0
  5. package/crates/tish/src/main.rs +69 -11
  6. package/crates/tish_ast/src/ast.rs +6 -2
  7. package/crates/tish_builtins/src/array.rs +82 -1
  8. package/crates/tish_builtins/src/globals.rs +50 -16
  9. package/crates/tish_builtins/src/math.rs +63 -9
  10. package/crates/tish_builtins/src/number.rs +20 -1
  11. package/crates/tish_builtins/src/string.rs +68 -4
  12. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  13. package/crates/tish_bytecode/src/compiler.rs +94 -28
  14. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  15. package/crates/tish_compile/src/check.rs +1 -1
  16. package/crates/tish_compile/src/codegen.rs +1386 -42
  17. package/crates/tish_compile/src/infer.rs +4 -4
  18. package/crates/tish_compile/src/lib.rs +38 -0
  19. package/crates/tish_compile/src/resolve.rs +3 -2
  20. package/crates/tish_compile/src/types.rs +17 -0
  21. package/crates/tish_compile_js/src/codegen.rs +55 -4
  22. package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
  23. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  24. package/crates/tish_core/Cargo.toml +2 -0
  25. package/crates/tish_core/src/json.rs +135 -34
  26. package/crates/tish_core/src/lib.rs +23 -1
  27. package/crates/tish_core/src/value.rs +64 -11
  28. package/crates/tish_eval/src/eval.rs +144 -197
  29. package/crates/tish_eval/src/natives.rs +45 -45
  30. package/crates/tish_eval/src/regex.rs +35 -27
  31. package/crates/tish_eval/src/value.rs +8 -0
  32. package/crates/tish_fmt/src/lib.rs +45 -1
  33. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  34. package/crates/tish_lint/src/lib.rs +197 -21
  35. package/crates/tish_lsp/Cargo.toml +6 -0
  36. package/crates/tish_lsp/src/import_goto.rs +52 -7
  37. package/crates/tish_lsp/src/main.rs +856 -140
  38. package/crates/tish_opt/src/lib.rs +5 -3
  39. package/crates/tish_parser/src/lib.rs +23 -5
  40. package/crates/tish_parser/src/parser.rs +15 -26
  41. package/crates/tish_resolve/src/lib.rs +188 -18
  42. package/crates/tish_runtime/src/lib.rs +58 -18
  43. package/crates/tish_ui/src/jsx.rs +2 -2
  44. package/crates/tish_vm/src/jit.rs +212 -10
  45. package/crates/tish_vm/src/vm.rs +39 -18
  46. package/crates/tish_wasm/src/lib.rs +116 -22
  47. package/package.json +1 -1
  48. package/platform/darwin-arm64/tish +0 -0
  49. package/platform/darwin-x64/tish +0 -0
  50. package/platform/linux-arm64/tish +0 -0
  51. package/platform/linux-x64/tish +0 -0
  52. package/platform/win32-x64/tish.exe +0 -0
@@ -16,7 +16,9 @@ pub use tishlang_core::ArcStr;
16
16
  /// Used by native codegen for `f()` / `obj()` dispatch (`Value::Function` or `__call` on objects).
17
17
  pub use tishlang_core::value_call;
18
18
  /// JS ToInt32/ToUint32 for the emitted bitwise/shift code (modulo 2³², NaN/±Infinity → 0).
19
- pub use tishlang_core::{to_int32, to_uint32};
19
+ pub use tishlang_core::{
20
+ to_int32, to_int32_value, to_number_value, to_uint32, to_uint32_value,
21
+ };
20
22
  // Re-export the shared-mutable wrapper so the Rust code emitted by
21
23
  // `tishlang_compile::codegen` can write `VmRef::new(...)` without needing
22
24
  // a direct dependency on `tishlang_core` from the generated crate.
@@ -57,8 +59,9 @@ pub use tishlang_builtins::collections::{
57
59
 
58
60
  // Re-export array methods from tishlang_builtins
59
61
  pub use tishlang_builtins::array::{
60
- concat as array_concat_impl, every as array_every, filter as array_filter, find as array_find,
61
- find_index as array_find_index, flat as array_flat_impl, flat_map as array_flat_map,
62
+ at as array_at, concat as array_concat_impl, every as array_every, filter as array_filter,
63
+ find as array_find, find_index as array_find_index, find_last as array_find_last,
64
+ find_last_index as array_find_last_index, flat as array_flat_impl, flat_map as array_flat_map,
62
65
  for_each as array_for_each, includes as array_includes_impl, index_of as array_index_of_impl,
63
66
  join as array_join_impl, map as array_map, pop as array_pop, push as array_push_impl,
64
67
  fill as array_fill, reduce as array_reduce, reverse as array_reverse, shift as array_shift,
@@ -71,13 +74,13 @@ pub use tishlang_builtins::array::{
71
74
 
72
75
  // Re-export string methods from tishlang_builtins
73
76
  pub use tishlang_builtins::string::{
74
- char_at as string_char_at_impl, char_code_at as string_char_code_at_impl,
77
+ at as string_at_impl, char_at as string_char_at_impl, char_code_at as string_char_code_at_impl,
75
78
  ends_with as string_ends_with_impl, escape_html as string_escape_html_impl,
76
79
  includes as string_includes_impl, index_of as string_index_of_impl,
77
80
  last_index_of as string_last_index_of_impl, pad_end as string_pad_end_impl,
78
81
  pad_start as string_pad_start_impl, repeat as string_repeat_impl,
79
82
  replace as string_replace_impl, replace_all as string_replace_all_impl,
80
- slice as string_slice_impl, split as string_split_impl,
83
+ slice as string_slice_impl, split as string_split_impl, split_limit as string_split_limit_impl,
81
84
  starts_with as string_starts_with_impl, substr as string_substr_impl,
82
85
  substring as string_substring_impl,
83
86
  to_lower_case as string_to_lower_case, to_upper_case as string_to_upper_case,
@@ -141,13 +144,23 @@ pub fn string_substr(s: &Value, start: &Value, length: &Value) -> Value {
141
144
  string_substr_impl(s, start, length)
142
145
  }
143
146
  pub fn string_split(s: &Value, sep: &Value) -> Value {
144
- // A RegExp separator routes to the regex splitter (matches string_replace's regex handling
145
- // and the interpreter/VM), so `"a1b2c".split(RegExp("\\d",""))` works on the rust backend.
147
+ string_split_limit(s, sep, &Value::Null)
148
+ }
149
+
150
+ /// `split(sep, limit)` honoring the optional `limit` argument (passed as a `Value` so the VM and
151
+ /// native codegen can forward the raw call argument). A non-numeric / null `limit` means "no limit".
152
+ /// Routes a RegExp separator to the regex splitter (matching string_replace's regex handling and
153
+ /// the interpreter), so `"a1b2c".split(RegExp("\\d",""))` works on the rust backend.
154
+ pub fn string_split_limit(s: &Value, sep: &Value, limit: &Value) -> Value {
155
+ let max = match limit {
156
+ Value::Number(n) if *n >= 0.0 => Some(*n as usize),
157
+ _ => None,
158
+ };
146
159
  #[cfg(feature = "regex")]
147
160
  if matches!(sep, Value::RegExp(_)) {
148
- return string_split_regex(s, sep, None);
161
+ return string_split_regex(s, sep, max);
149
162
  }
150
- string_split_impl(s, sep)
163
+ string_split_limit_impl(s, sep, max)
151
164
  }
152
165
  pub fn string_starts_with(s: &Value, search: &Value) -> Value {
153
166
  string_starts_with_impl(s, search)
@@ -168,6 +181,17 @@ pub fn string_replace_all(s: &Value, search: &Value, replacement: &Value) -> Val
168
181
  pub fn string_char_at(s: &Value, idx: &Value) -> Value {
169
182
  string_char_at_impl(s, idx)
170
183
  }
184
+ pub fn string_at(s: &Value, idx: &Value) -> Value {
185
+ string_at_impl(s, idx)
186
+ }
187
+ /// `.at(i)` dispatched on the runtime value — `at` exists on both String and Array, and the native
188
+ /// method match is by name (not receiver type), so route here at runtime. #247
189
+ pub fn value_at(recv: &Value, idx: &Value) -> Value {
190
+ match recv {
191
+ Value::String(_) => string_at_impl(recv, idx),
192
+ _ => array_at(recv, idx),
193
+ }
194
+ }
171
195
  pub fn string_char_code_at(s: &Value, idx: &Value) -> Value {
172
196
  string_char_code_at_impl(s, idx)
173
197
  }
@@ -509,6 +533,9 @@ pub use tishlang_builtins::math::{
509
533
  round as tish_math_round_impl, sign as tish_math_sign_impl, sin as tish_math_sin_impl,
510
534
  imul as tish_math_imul_impl,
511
535
  sqrt as tish_math_sqrt_impl, tan as tish_math_tan_impl, trunc as tish_math_trunc_impl,
536
+ // hypot/atan2/asin/acos/atan were missing on the native Math but present on the vm (#247).
537
+ hypot as tish_math_hypot_impl, atan2 as tish_math_atan2_impl, asin as tish_math_asin_impl,
538
+ acos as tish_math_acos_impl, atan as tish_math_atan_impl,
512
539
  };
513
540
 
514
541
  // Wrapper functions to maintain API (existing callers use math_* naming)
@@ -533,6 +560,21 @@ pub fn math_min(args: &[Value]) -> Value {
533
560
  pub fn math_max(args: &[Value]) -> Value {
534
561
  tish_math_max_impl(args)
535
562
  }
563
+ pub fn math_hypot(args: &[Value]) -> Value {
564
+ tish_math_hypot_impl(args)
565
+ }
566
+ pub fn math_atan2(args: &[Value]) -> Value {
567
+ tish_math_atan2_impl(args)
568
+ }
569
+ pub fn math_asin(args: &[Value]) -> Value {
570
+ tish_math_asin_impl(args)
571
+ }
572
+ pub fn math_acos(args: &[Value]) -> Value {
573
+ tish_math_acos_impl(args)
574
+ }
575
+ pub fn math_atan(args: &[Value]) -> Value {
576
+ tish_math_atan_impl(args)
577
+ }
536
578
  pub fn math_sin(args: &[Value]) -> Value {
537
579
  tish_math_sin_impl(args)
538
580
  }
@@ -1288,33 +1330,31 @@ pub fn string_split_regex(s: &Value, separator: &Value, limit: Option<usize>) ->
1288
1330
  match separator {
1289
1331
  Value::RegExp(re) => {
1290
1332
  let re = re.borrow();
1333
+ // JS semantics: split fully, then truncate to `limit` (don't keep the unsplit remainder
1334
+ // in the last slot). `truncate(usize::MAX)` is a no-op, so the no-limit path is unchanged.
1291
1335
  let mut result = Vec::new();
1292
1336
  let mut last_end = 0;
1293
1337
 
1294
1338
  for mat in re.regex.find_iter(input) {
1295
1339
  match mat {
1296
1340
  Ok(m) => {
1297
- if result.len() >= max - 1 {
1298
- break;
1299
- }
1300
1341
  result.push(Value::String(input[last_end..m.start()].into()));
1301
1342
  last_end = m.end();
1302
1343
  }
1303
1344
  Err(_) => break,
1304
1345
  }
1305
1346
  }
1306
-
1307
- if result.len() < max {
1308
- result.push(Value::String(input[last_end..].into()));
1309
- }
1347
+ result.push(Value::String(input[last_end..].into()));
1348
+ result.truncate(max);
1310
1349
 
1311
1350
  Value::Array(VmRef::new(result))
1312
1351
  }
1313
1352
  Value::String(sep) => {
1314
- let parts: Vec<Value> = input
1315
- .splitn(max, sep.as_str())
1353
+ let mut parts: Vec<Value> = input
1354
+ .split(sep.as_str())
1316
1355
  .map(|s| Value::String(s.into()))
1317
1356
  .collect();
1357
+ parts.truncate(max);
1318
1358
  Value::Array(VmRef::new(parts))
1319
1359
  }
1320
1360
  _ => Value::Array(VmRef::new(vec![Value::String(input.into())])),
@@ -302,7 +302,7 @@ fn collect_fun_decl_names_expr(expr: &Expr, names: &mut HashSet<String>) {
302
302
  Expr::Object { props, .. } => {
303
303
  for p in props {
304
304
  match p {
305
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => {
305
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => {
306
306
  collect_fun_decl_names_expr(e, names);
307
307
  }
308
308
  }
@@ -650,7 +650,7 @@ fn expr_contains_jsx(expr: &Expr) -> bool {
650
650
  ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_contains_jsx(e),
651
651
  }),
652
652
  Expr::Object { props, .. } => props.iter().any(|p| match p {
653
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_contains_jsx(e),
653
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => expr_contains_jsx(e),
654
654
  }),
655
655
  Expr::ArrowFunction { body, .. } => match body {
656
656
  tishlang_ast::ArrowBody::Expr(e) => expr_contains_jsx(e),
@@ -9,11 +9,13 @@
9
9
  //! frame slot and a block per bytecode jump target; handles for/while/nested loops, if/else,
10
10
  //! early return, break, continue. Enabled by slot-based locals making such functions `slot_based`.
11
11
  //!
12
- //! Anything unsupported (member/index, calls, arrays/objects, non-number constants, pow/shift,
12
+ //! Anything unsupported (member/index, calls, arrays/objects, non-number constants, pow,
13
13
  //! booleans in slots, a ternary inside a loop) makes compilation return `None`, and any non-number
14
14
  //! argument at call time falls back to the interpreter — so this is purely ADDITIVE and can never
15
15
  //! change behaviour (a miss runs the VM). Only a logic bug here could, hence the differential
16
- //! validation (vm-JIT ≡ interp ≡ node) + `tests/core/jit_loops.tish`.
16
+ //! validation (vm-JIT ≡ interp ≡ node) + `tests/core/jit_loops.tish`, `tests/core/jit_shifts.tish`.
17
+ //! Bitwise `& | ^ ~` and shifts `<< >> >>>` are lowered (JS ToInt32/ToUint32 semantics, bit-exact
18
+ //! with the VM's `eval_binop`); only `**` (pow) still falls back.
17
19
  //!
18
20
  //! Not compiled for wasm targets (cranelift-jit emits host code).
19
21
 
@@ -179,9 +181,15 @@ impl NumericFn {
179
181
 
180
182
  struct JitGlobal {
181
183
  module: JITModule,
182
- /// Keyed by the address of the (stable, un-cloned) nested `Chunk`. `None`
183
- /// caches "this chunk is not JIT-eligible" so we don't re-analyze it.
184
- cache: HashMap<usize, Option<NumericFn>>,
184
+ /// Keyed by the address of the nested `Chunk`, with a content **fingerprint** alongside the
185
+ /// result. Within one program run a chunk lives for the whole run, so the address is stable and
186
+ /// unique. But this cache is a process-global that is never cleared, and a `Chunk` is dropped when
187
+ /// its program is — so a long-lived process that compiles/drops/recompiles programs (the REPL;
188
+ /// embedders running multiple scripts) can allocate a *different* chunk at a freed address that is
189
+ /// still cached. We therefore verify the fingerprint on every hit: a mismatch means the address was
190
+ /// reused by a different chunk, so we recompile (and overwrite) instead of returning stale native
191
+ /// code. `None` still caches "not JIT-eligible". See [`chunk_fingerprint`].
192
+ cache: HashMap<usize, (u64, Option<NumericFn>)>,
185
193
  counter: usize,
186
194
  }
187
195
 
@@ -228,6 +236,65 @@ fn read_u16(code: &[u8], ip: &mut usize) -> Option<u16> {
228
236
  Some((a << 8) | b)
229
237
  }
230
238
 
239
+ /// Content fingerprint of everything `compile_chunk` reads, so a cache entry can be validated against
240
+ /// the chunk currently at a (possibly reused) address. FNV-1a over the compile-relevant fields:
241
+ /// shape (`param_count`, `num_slots`, `rest_param_index`, `slot_based`), the full `code` bytes, and
242
+ /// the `constants` (the JIT emits `f64const`/bool from `LoadConst`, so their values matter). The JIT
243
+ /// makes no cross-chunk calls (`op_size` allows only `SelfCall`, which recurses into *this* function),
244
+ /// so nothing outside the chunk affects the result — this fingerprint is complete. Deterministic
245
+ /// within a process (fixed FNV constants, not a randomized hasher), which is all the cache needs.
246
+ fn chunk_fingerprint(chunk: &Chunk) -> u64 {
247
+ // Mixes a u64 at a time (FNV-prime multiply + an avalanche shift). Eight bytes per round keeps
248
+ // this cheap on the hot closure-creation path; correctness only needs determinism + good
249
+ // distinction, not cryptographic strength.
250
+ #[inline]
251
+ fn mix(h: &mut u64, v: u64) {
252
+ *h ^= v;
253
+ *h = h.wrapping_mul(0x0000_0100_0000_01b3);
254
+ *h ^= *h >> 29;
255
+ }
256
+ #[inline]
257
+ fn mix_bytes(h: &mut u64, bytes: &[u8]) {
258
+ let mut it = bytes.chunks_exact(8);
259
+ for w in &mut it {
260
+ mix(h, u64::from_le_bytes(w.try_into().unwrap()));
261
+ }
262
+ let rem = it.remainder();
263
+ if !rem.is_empty() {
264
+ let mut buf = [0u8; 8];
265
+ buf[..rem.len()].copy_from_slice(rem);
266
+ mix(h, u64::from_le_bytes(buf));
267
+ }
268
+ mix(h, bytes.len() as u64);
269
+ }
270
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
271
+ mix(&mut h, chunk.param_count as u64);
272
+ mix(&mut h, chunk.num_slots as u64);
273
+ mix(&mut h, chunk.rest_param_index as u64);
274
+ mix(&mut h, chunk.slot_based as u64);
275
+ mix_bytes(&mut h, &chunk.code);
276
+ mix(&mut h, chunk.constants.len() as u64);
277
+ for c in &chunk.constants {
278
+ match c {
279
+ Constant::Number(n) => {
280
+ mix(&mut h, 1);
281
+ mix(&mut h, n.to_bits());
282
+ }
283
+ Constant::String(s) => {
284
+ mix(&mut h, 2);
285
+ mix_bytes(&mut h, s.as_bytes());
286
+ }
287
+ Constant::Bool(b) => mix(&mut h, if *b { 4 } else { 3 }),
288
+ Constant::Null => mix(&mut h, 5),
289
+ Constant::Closure(idx) => {
290
+ mix(&mut h, 6);
291
+ mix(&mut h, *idx as u64);
292
+ }
293
+ }
294
+ }
295
+ h
296
+ }
297
+
231
298
  /// Get (or compile, then cache) the native numeric function for `chunk`.
232
299
  /// Returns `None` if the chunk isn't a straight-line numeric function.
233
300
  pub fn try_compile_numeric(chunk: &Chunk) -> Option<NumericFn> {
@@ -239,13 +306,18 @@ pub fn try_compile_numeric(chunk: &Chunk) -> Option<NumericFn> {
239
306
  return None;
240
307
  }
241
308
  let key = chunk as *const Chunk as usize;
309
+ let fp = chunk_fingerprint(chunk);
242
310
  let lock = jit()?;
243
311
  let mut g = lock.lock().ok()?;
244
- if let Some(cached) = g.cache.get(&key).copied() {
245
- return cached;
312
+ // Hit only counts if the fingerprint matches: otherwise this address was freed and reused by a
313
+ // *different* chunk, and the cached `NumericFn` is native code for the old one (a miscompile).
314
+ if let Some(&(cached_fp, cached)) = g.cache.get(&key) {
315
+ if cached_fp == fp {
316
+ return cached;
317
+ }
246
318
  }
247
319
  let result = compile_chunk(&mut g, chunk);
248
- g.cache.insert(key, result);
320
+ g.cache.insert(key, (fp, result));
249
321
  result
250
322
  }
251
323
 
@@ -561,7 +633,6 @@ fn emit_simple_op(
561
633
  bcx.ins().fsub(l, p)
562
634
  }
563
635
  // Bitwise AND/OR/XOR via JS ToInt32 (modulo 2³², NaN/±∞ → 0) — see [`js_to_int32`].
564
- // Shifts / `>>>` stay on the VM (shift-amount edge cases).
565
636
  BinOp::BitAnd | BinOp::BitOr | BinOp::BitXor => {
566
637
  let li = js_to_int32(bcx, l);
567
638
  let ri = js_to_int32(bcx, r);
@@ -573,7 +644,36 @@ fn emit_simple_op(
573
644
  };
574
645
  bcx.ins().fcvt_from_sint(types::F64, res)
575
646
  }
576
- // Pow/shifts/`>>>`/In/And/Or: fall back to the VM.
647
+ // Shifts. JS masks the count to the low 5 bits (`& 31`); the low 5 bits of `ToInt32(r)`
648
+ // equal `ToUint32(r)`, so `js_to_int32(r)` carries the right amount. We mask explicitly
649
+ // (`& 31`) so correctness never depends on Cranelift's own amount-masking convention.
650
+ // `<<`/`>>` are signed-domain (ToInt32 → i32 → signed→f64); `>>>` is logical on the
651
+ // unsigned bits with an UNSIGNED→f64 convert (result may exceed 2³¹). Bit-for-bit with
652
+ // vm.rs `eval_binop`: Shl/Shr = `to_int32(l).wrapping_sh*(to_uint32(r))`,
653
+ // UShr = `to_uint32(l).wrapping_shr(to_uint32(r))`.
654
+ BinOp::Shl | BinOp::Shr | BinOp::UShr => {
655
+ let li = js_to_int32(bcx, l);
656
+ let amt = js_to_int32(bcx, r);
657
+ let mask = bcx.ins().iconst(types::I32, 31);
658
+ let amt = bcx.ins().band(amt, mask);
659
+ match bop {
660
+ BinOp::Shl => {
661
+ let res = bcx.ins().ishl(li, amt);
662
+ bcx.ins().fcvt_from_sint(types::F64, res)
663
+ }
664
+ BinOp::Shr => {
665
+ let res = bcx.ins().sshr(li, amt);
666
+ bcx.ins().fcvt_from_sint(types::F64, res)
667
+ }
668
+ // UShr: logical shift on the same 32-bit value bits as ToUint32(l), then
669
+ // unsigned→f64 so a result with bit 31 set stays a positive number (JS `>>>`).
670
+ _ => {
671
+ let res = bcx.ins().ushr(li, amt);
672
+ bcx.ins().fcvt_from_uint(types::F64, res)
673
+ }
674
+ }
675
+ }
676
+ // Pow/In/And/Or: fall back to the VM.
577
677
  _ => return SimpleOp::Unsupported,
578
678
  };
579
679
  stack.push((v, is_cmp));
@@ -1048,3 +1148,105 @@ fn build_body(
1048
1148
  bcx.finalize();
1049
1149
  result
1050
1150
  }
1151
+
1152
+ #[cfg(all(test, not(target_arch = "wasm32")))]
1153
+ mod tests {
1154
+ use super::*;
1155
+ use tishlang_core::{to_int32, to_uint32};
1156
+
1157
+ /// First arity-2 slot-based numeric nested chunk in compiled `src` (cloned, so the caller owns it).
1158
+ fn fn_chunk(src: &str) -> Chunk {
1159
+ let prog = tishlang_parser::parse(src).expect("parse");
1160
+ let opt = tishlang_opt::optimize(&prog);
1161
+ let top = tishlang_bytecode::compile(&opt).expect("compile");
1162
+ fn find(c: &Chunk) -> Option<Chunk> {
1163
+ for n in &c.nested {
1164
+ if n.slot_based && n.param_count == 2 && n.rest_param_index == NO_REST_PARAM {
1165
+ return Some(n.clone());
1166
+ }
1167
+ if let Some(x) = find(n) {
1168
+ return Some(x);
1169
+ }
1170
+ }
1171
+ None
1172
+ }
1173
+ find(&top).expect("expected an arity-2 slot-based fn chunk")
1174
+ }
1175
+
1176
+ /// Compile `src`, then return the first arity-2 pure-numeric function the JIT accepts. Panics if
1177
+ /// none compiles — so a change that makes the JIT silently *stop* compiling the target (the exact
1178
+ /// "vacuous fixture" miss that motivated this guard) fails loudly instead of passing emptily.
1179
+ ///
1180
+ /// Bypasses [`try_compile_numeric`]'s cache and calls [`compile_chunk`] directly: the cache is
1181
+ /// keyed by chunk address, unique-and-stable in a real run but reused across this test's transient
1182
+ /// chunks. Compiling fresh is correct here and still exercises the real lowering path.
1183
+ fn jit_arity2(src: &str) -> NumericFn {
1184
+ let prog = tishlang_parser::parse(src).expect("parse");
1185
+ let opt = tishlang_opt::optimize(&prog);
1186
+ let chunk = tishlang_bytecode::compile(&opt).expect("compile");
1187
+ fn compile_uncached(c: &Chunk) -> Option<NumericFn> {
1188
+ if !c.slot_based
1189
+ || c.rest_param_index != NO_REST_PARAM
1190
+ || c.param_count == 0
1191
+ || c.param_count > 8
1192
+ {
1193
+ return None;
1194
+ }
1195
+ let lock = jit()?;
1196
+ let mut g = lock.lock().ok()?;
1197
+ compile_chunk(&mut g, c)
1198
+ }
1199
+ fn find(c: &Chunk) -> Option<NumericFn> {
1200
+ for n in &c.nested {
1201
+ if let Some(f) = compile_uncached(n) {
1202
+ if f.arity == 2 && f.array_param_mask == 0 {
1203
+ return Some(f);
1204
+ }
1205
+ }
1206
+ if let Some(f) = find(n) {
1207
+ return Some(f);
1208
+ }
1209
+ }
1210
+ None
1211
+ }
1212
+ find(&chunk).expect("the JIT must compile this arity-2 numeric fn (did it start bailing?)")
1213
+ }
1214
+
1215
+ /// Regression for the address-reuse stale hit (#247): compile one function, then overwrite the SAME
1216
+ /// heap `Chunk` (same address = the cache key) with a *different* function — what a long-lived
1217
+ /// process (REPL / multi-script embedder) does when a freed chunk address is reused. Before the
1218
+ /// fingerprint check the cache returned the first function's native code for the second.
1219
+ #[test]
1220
+ fn jit_cache_detects_address_reuse() {
1221
+ let mut boxed: Box<Chunk> = Box::new(fn_chunk("const f = (a, b) => a - b\nf(0, 0)\n"));
1222
+ let sub = try_compile_numeric(&boxed).expect("a - b must JIT");
1223
+ assert_eq!(sub.call(&[10.0, 3.0]), 7.0, "sub baseline");
1224
+
1225
+ *boxed = fn_chunk("const f = (a, b) => a * b\nf(0, 0)\n"); // same address, different content
1226
+ let mul = try_compile_numeric(&boxed).expect("a * b must JIT");
1227
+ assert_eq!(
1228
+ mul.call(&[10.0, 3.0]),
1229
+ 30.0,
1230
+ "stale cache hit: reused address returned the old (sub) fn instead of mul"
1231
+ );
1232
+ }
1233
+
1234
+ /// Permanent guard for #168: shifts must (a) actually JIT-compile and (b) be bit-exact with the
1235
+ /// VM's `eval_binop` (`to_int32`/`to_uint32` + wrapping shift). Breaking either fails this test.
1236
+ #[test]
1237
+ fn jit_lowers_shifts_bit_exact_to_vm() {
1238
+ let shl = jit_arity2("const f = (a, b) => a << b\nf(0, 0)\n");
1239
+ let shr = jit_arity2("const f = (a, b) => a >> b\nf(0, 0)\n");
1240
+ let ushr = jit_arity2("const f = (a, b) => a >>> b\nf(0, 0)\n");
1241
+ let cases = [
1242
+ (1.0, 4.0), (1.0, 32.0), (1.0, 33.0), (1.0, -1.0), (-8.0, 1.0), (-1.0, 0.0),
1243
+ (-2.0, 1.0), (4294967295.0, 0.0), (3.9, 0.0), (4294967297.0, 0.0),
1244
+ (-123456789.0, 5.0), (65535.0, 16.0),
1245
+ ];
1246
+ for (a, b) in cases {
1247
+ assert_eq!(shl.call(&[a, b]), to_int32(a).wrapping_shl(to_uint32(b)) as f64, "<< {a} {b}");
1248
+ assert_eq!(shr.call(&[a, b]), to_int32(a).wrapping_shr(to_uint32(b)) as f64, ">> {a} {b}");
1249
+ assert_eq!(ushr.call(&[a, b]), to_uint32(a).wrapping_shr(to_uint32(b)) as f64, ">>> {a} {b}");
1250
+ }
1251
+ }
1252
+ }
@@ -264,7 +264,7 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
264
264
  tishlang_runtime::process_exec(args)
265
265
  })),
266
266
  "argv" => Some(Value::Array(VmRef::new(
267
- std::env::args().map(|s| Value::String(s.into())).collect(),
267
+ tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
268
268
  ))),
269
269
  "env" => Some(value_object_from_map(
270
270
  std::env::vars()
@@ -288,7 +288,7 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
288
288
  m.insert(
289
289
  "argv".into(),
290
290
  Value::Array(VmRef::new(
291
- std::env::args().map(|s| Value::String(s.into())).collect(),
291
+ tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
292
292
  )),
293
293
  );
294
294
  m.insert(
@@ -462,12 +462,11 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
462
462
  Value::Number(n.ceil())
463
463
  }),
464
464
  );
465
+ // round/min/max delegate to the shared builtins (JS round-half-to-+∞, empty → ±∞, NaN
466
+ // propagation) so the vm never diverges from interp/native on Math semantics (#247).
465
467
  math.insert(
466
468
  "round".into(),
467
- Value::native(|args: &[Value]| {
468
- let n = args.first().and_then(|v| v.as_number()).unwrap_or(f64::NAN);
469
- Value::Number(n.round())
470
- }),
469
+ Value::native(|args: &[Value]| math_builtins::round(args)),
471
470
  );
472
471
  math.insert(
473
472
  "random".into(),
@@ -475,17 +474,11 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
475
474
  );
476
475
  math.insert(
477
476
  "min".into(),
478
- Value::native(|args: &[Value]| {
479
- let nums: Vec<f64> = args.iter().filter_map(|v| v.as_number()).collect();
480
- Value::Number(nums.into_iter().fold(f64::NAN, |a, b| a.min(b)))
481
- }),
477
+ Value::native(|args: &[Value]| math_builtins::min(args)),
482
478
  );
483
479
  math.insert(
484
480
  "max".into(),
485
- Value::native(|args: &[Value]| {
486
- let nums: Vec<f64> = args.iter().filter_map(|v| v.as_number()).collect();
487
- Value::Number(nums.into_iter().fold(f64::NAN, |a, b| a.max(b)))
488
- }),
481
+ Value::native(|args: &[Value]| math_builtins::max(args)),
489
482
  );
490
483
  math.insert(
491
484
  "pow".into(),
@@ -795,7 +788,7 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
795
788
  process_obj.insert(
796
789
  "argv".into(),
797
790
  Value::Array(VmRef::new(
798
- std::env::args().map(|s| Value::String(s.into())).collect(),
791
+ tishlang_core::process_argv().into_iter().map(|s| Value::String(s.into())).collect(),
799
792
  )),
800
793
  );
801
794
  process_obj.insert(
@@ -2969,6 +2962,9 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
2969
2962
  "forEach" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::for_each(&bv, &cb) }),
2970
2963
  "find" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find(&bv, &cb) }),
2971
2964
  "findIndex" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find_index(&bv, &cb) }),
2965
+ "findLast" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find_last(&bv, &cb) }),
2966
+ "findLastIndex" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::find_last_index(&bv, &cb) }),
2967
+ "at" => make_native_fn(move |args| { let i = args.first().cloned().unwrap_or(Value::Null); arr_builtins::at(&bv, &i) }),
2972
2968
  "some" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::some(&bv, &cb) }),
2973
2969
  "every" => make_native_fn(move |args| { let cb = args.first().cloned().unwrap_or(Value::Null); arr_builtins::every(&bv, &cb) }),
2974
2970
  "join" => make_native_fn(move |args| { let sep = args.first().cloned().unwrap_or(Value::Null); arr_builtins::join(&bv, &sep) }),
@@ -3073,6 +3069,18 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3073
3069
  let cb = args.first().cloned().unwrap_or(Value::Null);
3074
3070
  arr_builtins::find_index(&Value::Array(a_clone.clone()), &cb)
3075
3071
  }),
3072
+ "findLast" => make_native_fn(move |args: &[Value]| {
3073
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3074
+ arr_builtins::find_last(&Value::Array(a_clone.clone()), &cb)
3075
+ }),
3076
+ "findLastIndex" => make_native_fn(move |args: &[Value]| {
3077
+ let cb = args.first().cloned().unwrap_or(Value::Null);
3078
+ arr_builtins::find_last_index(&Value::Array(a_clone.clone()), &cb)
3079
+ }),
3080
+ "at" => make_native_fn(move |args: &[Value]| {
3081
+ let i = args.first().cloned().unwrap_or(Value::Null);
3082
+ arr_builtins::at(&Value::Array(a_clone.clone()), &i)
3083
+ }),
3076
3084
  "some" => make_native_fn(move |args: &[Value]| {
3077
3085
  let cb = args.first().cloned().unwrap_or(Value::Null);
3078
3086
  arr_builtins::some(&Value::Array(a_clone.clone()), &cb)
@@ -3159,15 +3167,24 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3159
3167
  }),
3160
3168
  "split" => make_native_fn(move |args: &[Value]| {
3161
3169
  let sep = args.first().unwrap_or(&Value::Null);
3170
+ let limit = args.get(1).unwrap_or(&Value::Null);
3171
+ let max = match limit {
3172
+ Value::Number(n) if *n >= 0.0 => Some(*n as usize),
3173
+ _ => None,
3174
+ };
3175
+ // A RegExp separator needs the runtime's regex path — but a `Value::RegExp` can
3176
+ // only exist with the `regex` feature, which is also what pulls in the optional
3177
+ // `tishlang_runtime`. String separators use the always-available builtin, so
3178
+ // `tish_vm` still compiles (and tests) without the optional runtime crate.
3162
3179
  #[cfg(feature = "regex")]
3163
3180
  if matches!(sep, Value::RegExp(_)) {
3164
- return tishlang_runtime::string_split_regex(
3181
+ return tishlang_runtime::string_split_limit(
3165
3182
  &Value::String(s_clone.clone()),
3166
3183
  sep,
3167
- None,
3184
+ limit,
3168
3185
  );
3169
3186
  }
3170
- str_builtins::split(&Value::String(s_clone.clone()), sep)
3187
+ str_builtins::split_limit(&Value::String(s_clone.clone()), sep, max)
3171
3188
  }),
3172
3189
  "trim" => make_native_fn(move |_args: &[Value]| {
3173
3190
  str_builtins::trim(&Value::String(s_clone.clone()))
@@ -3224,6 +3241,10 @@ fn get_member(obj: &Value, key: &Arc<str>) -> Result<Value, String> {
3224
3241
  let idx = args.first().unwrap_or(&Value::Null);
3225
3242
  str_builtins::char_at(&Value::String(s_clone.clone()), idx)
3226
3243
  }),
3244
+ "at" => make_native_fn(move |args: &[Value]| {
3245
+ let idx = args.first().unwrap_or(&Value::Null);
3246
+ str_builtins::at(&Value::String(s_clone.clone()), idx)
3247
+ }),
3227
3248
  "charCodeAt" => make_native_fn(move |args: &[Value]| {
3228
3249
  let idx = args.first().unwrap_or(&Value::Null);
3229
3250
  str_builtins::char_code_at(&Value::String(s_clone.clone()), idx)