@tishlang/tish-lsp 2.39.0 → 3.0.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 (89) hide show
  1. package/Cargo.toml +4 -1
  2. package/bin/tish-lsp +0 -0
  3. package/crates/tish/src/cli_help.rs +28 -0
  4. package/crates/tish/src/main.rs +41 -5
  5. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  6. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  7. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  8. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  9. package/crates/tish/tests/scope_merge.rs +79 -0
  10. package/crates/tish_build_utils/src/lib.rs +27 -1
  11. package/crates/tish_builtins/Cargo.toml +14 -6
  12. package/crates/tish_builtins/src/array.rs +130 -54
  13. package/crates/tish_builtins/src/collections.rs +9 -5
  14. package/crates/tish_builtins/src/construct.rs +7 -1
  15. package/crates/tish_builtins/src/date.rs +15 -1
  16. package/crates/tish_builtins/src/globals.rs +65 -3
  17. package/crates/tish_builtins/src/helpers.rs +7 -3
  18. package/crates/tish_builtins/src/iterator.rs +5 -1
  19. package/crates/tish_builtins/src/lib.rs +3 -0
  20. package/crates/tish_builtins/src/math.rs +14 -0
  21. package/crates/tish_builtins/src/number.rs +72 -6
  22. package/crates/tish_builtins/src/object.rs +6 -1
  23. package/crates/tish_builtins/src/string.rs +13 -2
  24. package/crates/tish_builtins/src/symbol.rs +17 -17
  25. package/crates/tish_builtins/src/typedarrays.rs +9 -2
  26. package/crates/tish_bytecode/src/compiler.rs +76 -2
  27. package/crates/tish_bytecode/src/lib.rs +1 -1
  28. package/crates/tish_bytecode/src/opcode.rs +77 -2
  29. package/crates/tish_compile/src/codegen.rs +2154 -225
  30. package/crates/tish_compile/src/infer.rs +37 -41
  31. package/crates/tish_compile/src/lib.rs +13 -1
  32. package/crates/tish_compile/src/platform_resolve.rs +361 -0
  33. package/crates/tish_compile/src/resolve.rs +195 -11
  34. package/crates/tish_compile/src/schemes.rs +333 -0
  35. package/crates/tish_compile/src/types.rs +205 -34
  36. package/crates/tish_compile/tests/perf_codegen_320.rs +2 -2
  37. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +3 -1
  38. package/crates/tish_compile/tests/platform_resolve_cli.rs +164 -0
  39. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  40. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  41. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  42. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  43. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  44. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  45. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  46. package/crates/tish_compile/tests/regr_gba_numerics_gated.rs +58 -0
  47. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  48. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  49. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  50. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  51. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  52. package/crates/tish_compiler_wasm/Cargo.toml +1 -0
  53. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +56 -6
  54. package/crates/tish_core/Cargo.toml +39 -4
  55. package/crates/tish_core/src/compat.rs +506 -0
  56. package/crates/tish_core/src/console_style.rs +23 -1
  57. package/crates/tish_core/src/json.rs +42 -10
  58. package/crates/tish_core/src/lib.rs +121 -25
  59. package/crates/tish_core/src/macros.rs +1 -1
  60. package/crates/tish_core/src/shape.rs +4 -2
  61. package/crates/tish_core/src/uri.rs +8 -1
  62. package/crates/tish_core/src/value.rs +305 -54
  63. package/crates/tish_core/src/vmref.rs +17 -5
  64. package/crates/tish_eval/Cargo.toml +2 -0
  65. package/crates/tish_eval/src/eval.rs +90 -3
  66. package/crates/tish_eval/src/value.rs +8 -0
  67. package/crates/tish_eval/src/value_convert.rs +316 -21
  68. package/crates/tish_lsp/Cargo.toml +3 -1
  69. package/crates/tish_lsp/src/import_goto.rs +41 -14
  70. package/crates/tish_lsp/src/main.rs +18 -0
  71. package/crates/tish_native/src/build.rs +246 -0
  72. package/crates/tish_native/src/config.rs +20 -0
  73. package/crates/tish_native/src/lib.rs +16 -6
  74. package/crates/tish_parser/src/lib.rs +28 -0
  75. package/crates/tish_parser/src/parser.rs +25 -4
  76. package/crates/tish_runtime/src/lib.rs +55 -9
  77. package/crates/tish_runtime_gba/Cargo.lock +837 -0
  78. package/crates/tish_runtime_gba/Cargo.toml +16 -0
  79. package/crates/tish_runtime_gba/src/gba.rs +211 -0
  80. package/crates/tish_runtime_gba/src/lib.rs +804 -0
  81. package/crates/tish_vm/src/jit.rs +1446 -164
  82. package/crates/tish_vm/src/vm.rs +583 -173
  83. package/justfile +9 -0
  84. package/package.json +1 -1
  85. package/platform/darwin-arm64/tish-lsp +0 -0
  86. package/platform/darwin-x64/tish-lsp +0 -0
  87. package/platform/linux-arm64/tish-lsp +0 -0
  88. package/platform/linux-x64/tish-lsp +0 -0
  89. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -5,6 +5,12 @@
5
5
  //! and the tree-walk interpreter all route through here so every backend produces
6
6
  //! byte-identical output — see `tish/docs/full-backend-parity-plan.md` (Workstream A).
7
7
 
8
+ #[cfg(feature = "portable")]
9
+ #[allow(unused_imports)]
10
+ use alloc::{borrow::ToOwned, boxed::Box, format, string::{String, ToString}, vec, vec::Vec};
11
+ #[cfg(feature = "portable")]
12
+ use tishlang_core::FloatExt;
13
+
8
14
  use tishlang_core::Value;
9
15
 
10
16
  /// `Number.prototype.toFixed(digits)` — ECMA-262 §21.1.3.3.
@@ -25,8 +31,16 @@ pub fn to_fixed(n: &Value, digits: &Value) -> Value {
25
31
  }
26
32
 
27
33
  /// f64-domain `Number.prototype.toFixed` so the tree-walk interpreter (distinct `Value`) shares the
28
- /// exact behavior. Rust's `{:.*}` rounds half-to-even (`(2.5).toFixed(0)` → "2"); JS rounds half away
29
- /// from zero (→ "3"), so pre-round the scaled value with `.round()` (half-away) before formatting. #247
34
+ /// exact behavior. tish's rule (#438): round the number's *true* decimal value to `digits` places,
35
+ /// ties away from zero — matching V8/node's observable `toFixed`.
36
+ ///
37
+ /// The obvious `(num * 10^digits).round()` is wrong: float scaling nudges values that are actually
38
+ /// just below a tie up over it, so `(2.675).toFixed(2)` came out `2.68` where node gives `2.67`
39
+ /// (2.675 is stored as 2.67499999…). Instead we render the double's exact decimal expansion to well
40
+ /// beyond `digits` — f64 fractions terminate, so a generous precision captures the real digits — then
41
+ /// round that digit string: if the first dropped digit is ≥ 5, increment with decimal carry; else
42
+ /// truncate. On the magnitude, "first dropped ≥ 5" is round-half-up = round-half-away-from-zero, and
43
+ /// because the expansion is exact, non-ties round the way the true value dictates. #438
30
44
  pub fn to_fixed_str(num: f64, digits: usize) -> String {
31
45
  if num.is_nan() {
32
46
  return "NaN".to_string();
@@ -38,9 +52,61 @@ pub fn to_fixed_str(num: f64, digits: usize) -> String {
38
52
  if num.abs() >= 1e21 {
39
53
  return format!("{}", num);
40
54
  }
41
- let factor = 10f64.powi(digits as i32);
42
- let rounded = (num * factor).round() / factor;
43
- format!("{:.*}", digits, rounded)
55
+
56
+ let negative = num.is_sign_negative() && num != 0.0;
57
+ let magnitude = num.abs();
58
+
59
+ // Exact-enough decimal expansion. The digit just past `digits` then reflects the double's TRUE
60
+ // value; +25 guard digits sit far beyond any position we inspect, so the half-to-even rounding
61
+ // Rust applies at the cutoff can't perturb our decision (it would take 25 consecutive 9s to carry
62
+ // back that far, and such a value already rounds up under the exact rule anyway).
63
+ let guard = digits + 25;
64
+ let rendered = format!("{:.*}", guard, magnitude);
65
+ let dot = rendered.find('.').expect("fixed formatting always has a decimal point");
66
+ let int_str = &rendered[..dot];
67
+ let frac = rendered[dot + 1..].as_bytes();
68
+
69
+ // Kept digits: the whole integer part, then `digits` fractional digits.
70
+ let mut ds: Vec<u8> = int_str
71
+ .bytes()
72
+ .chain(frac[..digits].iter().copied())
73
+ .map(|b| b - b'0')
74
+ .collect();
75
+
76
+ // Round half-away-from-zero on the magnitude: increment iff the first dropped digit is >= 5.
77
+ if frac[digits] - b'0' >= 5 {
78
+ let mut i = ds.len();
79
+ loop {
80
+ if i == 0 {
81
+ ds.insert(0, 1);
82
+ break;
83
+ }
84
+ i -= 1;
85
+ if ds[i] == 9 {
86
+ ds[i] = 0;
87
+ } else {
88
+ ds[i] += 1;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+
94
+ // Split back into integer (all but the last `digits`) and fractional digits.
95
+ let int_len = ds.len() - digits;
96
+ let mut out = String::with_capacity(ds.len() + 2);
97
+ if negative {
98
+ out.push('-');
99
+ }
100
+ for &d in &ds[..int_len] {
101
+ out.push((d + b'0') as char);
102
+ }
103
+ if digits > 0 {
104
+ out.push('.');
105
+ for &d in &ds[int_len..] {
106
+ out.push((d + b'0') as char);
107
+ }
108
+ }
109
+ out
44
110
  }
45
111
 
46
112
  /// `Number.prototype.toExponential(fractionDigits?)` — ECMA-262 §21.1.3.2. Exponential notation with
@@ -270,7 +336,7 @@ pub fn number_to_string_radix(num: f64, radix: i64) -> Option<String> {
270
336
  if negative {
271
337
  out.push('-');
272
338
  }
273
- out.push_str(std::str::from_utf8(&int_digits).unwrap());
339
+ out.push_str(core::str::from_utf8(&int_digits).unwrap());
274
340
 
275
341
  // Fractional part: multiply-by-radix, emitting the integer overflow each step.
276
342
  if frac > 0.0 {
@@ -3,7 +3,11 @@
3
3
  //! This module will contain shared object method implementations.
4
4
  //! Functions will be migrated here from tishlang_runtime and tishlang_eval.
5
5
 
6
- use std::sync::Arc;
6
+ #[cfg(feature = "portable")]
7
+ #[allow(unused_imports)]
8
+ use alloc::{borrow::ToOwned, boxed::Box, format, string::{String, ToString}, vec, vec::Vec};
9
+
10
+ use tishlang_core::Arc;
7
11
  use tishlang_core::{ObjectData, PropMap, Value, VmRef};
8
12
 
9
13
  /// Create a new empty object Value.
@@ -16,6 +20,7 @@ pub fn with_capacity(capacity: usize) -> Value {
16
20
  Value::Object(VmRef::new(ObjectData {
17
21
  strings: PropMap::with_capacity(capacity),
18
22
  symbols: None,
23
+ frozen: false,
19
24
  }))
20
25
  }
21
26
 
@@ -3,11 +3,17 @@
3
3
  //! All indices use character (Unicode scalar) positions for consistency with
4
4
  //! JavaScript, matching .length and .charAt(). Byte offsets are never exposed.
5
5
 
6
+ #[cfg(feature = "portable")]
7
+ #[allow(unused_imports)]
8
+ use alloc::{borrow::ToOwned, boxed::Box, format, string::{String, ToString}, vec, vec::Vec};
9
+
6
10
  use crate::helpers::normalize_index;
7
- use std::cell::RefCell;
11
+ use core::cell::RefCell;
8
12
  use tishlang_core::ArcStr;
9
13
  use tishlang_core::Value;
10
14
  use tishlang_core::VmRef;
15
+ #[cfg(feature = "portable")]
16
+ use tishlang_core::FloatExt;
11
17
 
12
18
  // #203: a per-thread cursor cache that makes repeated character indexing (`charCodeAt(i)`, `s[i]`,
13
19
  // `charAt(i)`) O(1)/near-O(1) instead of O(i). tish strings are UTF-8, so `chars().nth(i)` scans from
@@ -29,9 +35,14 @@ struct CharCursor {
29
35
  byte_off: usize,
30
36
  }
31
37
 
38
+ #[cfg(not(feature = "portable"))]
32
39
  thread_local! {
33
40
  static INDEX_CURSOR: RefCell<Option<CharCursor>> = const { RefCell::new(None) };
34
41
  }
42
+ // Single-core: one shared cursor cache with the same `.with()` API.
43
+ #[cfg(feature = "portable")]
44
+ static INDEX_CURSOR: tishlang_core::SingleCore<RefCell<Option<CharCursor>>> =
45
+ tishlang_core::SingleCore::new(RefCell::new(None));
35
46
 
36
47
  /// Borrow the cursor entry for `s`, reseeding (compute ASCII flag + character count) if it currently
37
48
  /// caches a different backing allocation, then run `f` against it. Centralises the pointer-keyed
@@ -40,7 +51,7 @@ fn with_cursor<R>(s: &ArcStr, f: impl FnOnce(&mut CharCursor, &ArcStr) -> R) ->
40
51
  INDEX_CURSOR.with(|cell| {
41
52
  let mut slot = cell.borrow_mut();
42
53
  let hit = matches!(slot.as_ref(), Some(c)
43
- if std::ptr::eq(c.s.as_bytes().as_ptr(), s.as_bytes().as_ptr()) && c.s.len() == s.len());
54
+ if core::ptr::eq(c.s.as_bytes().as_ptr(), s.as_bytes().as_ptr()) && c.s.len() == s.len());
44
55
  if !hit {
45
56
  let ascii = s.as_bytes().is_ascii();
46
57
  // ASCII → character count equals byte length (free); otherwise count once.
@@ -1,30 +1,30 @@
1
1
  //! ECMAScript-style `Symbol`, `Symbol.for`, `Symbol.keyFor`.
2
2
 
3
- use std::collections::hash_map::Entry;
4
- use std::sync::{Arc, Mutex, OnceLock};
3
+ #[cfg(feature = "portable")]
4
+ #[allow(unused_imports)]
5
+ use alloc::{borrow::ToOwned, boxed::Box, format, string::{String, ToString}, vec, vec::Vec};
5
6
 
6
- use std::collections::HashMap;
7
+ use tishlang_core::sync::{Mutex, OnceLock};
8
+ use tishlang_core::{alloc_symbol_id, AHashMap, Arc, ObjectMap, TishSymbol, Value};
7
9
 
8
- use tishlang_core::{alloc_symbol_id, ObjectMap, TishSymbol, Value};
10
+ static SYMBOL_FOR_REGISTRY: OnceLock<Mutex<AHashMap<Arc<str>, Arc<TishSymbol>>>> = OnceLock::new();
9
11
 
10
- static SYMBOL_FOR_REGISTRY: OnceLock<Mutex<HashMap<Arc<str>, Arc<TishSymbol>>>> = OnceLock::new();
11
-
12
- fn symbol_registry() -> &'static Mutex<HashMap<Arc<str>, Arc<TishSymbol>>> {
13
- SYMBOL_FOR_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
12
+ fn symbol_registry() -> &'static Mutex<AHashMap<Arc<str>, Arc<TishSymbol>>> {
13
+ SYMBOL_FOR_REGISTRY.get_or_init(|| Mutex::new(AHashMap::default()))
14
14
  }
15
15
 
16
16
  fn symbol_for_impl(key: &str) -> Value {
17
17
  let k: Arc<str> = key.into();
18
18
  let mut reg = symbol_registry().lock().unwrap();
19
- let sym = match reg.entry(Arc::clone(&k)) {
20
- Entry::Occupied(e) => e.get().clone(),
21
- Entry::Vacant(v) => {
22
- let id = alloc_symbol_id();
23
- let sym = TishSymbol::new_registry(id, Arc::clone(&k), None);
24
- v.insert(Arc::clone(&sym));
25
- sym
26
- }
27
- };
19
+ // get-then-insert (rather than the `Entry` API) so this compiles against both
20
+ // std `HashMap` and the portable `hashbrown` map without a hasher-specific
21
+ // `Entry` import. `Symbol.for` is not a hot path.
22
+ if let Some(existing) = reg.get(&k) {
23
+ return Value::Symbol(existing.clone());
24
+ }
25
+ let id = alloc_symbol_id();
26
+ let sym = TishSymbol::new_registry(id, Arc::clone(&k), None);
27
+ reg.insert(k, Arc::clone(&sym));
28
28
  Value::Symbol(sym)
29
29
  }
30
30
 
@@ -17,7 +17,13 @@
17
17
  //! - A typed array is a regular array at runtime, so `Array.isArray(ta)` is `true` and there is no
18
18
  //! `instanceof` distinction.
19
19
 
20
- use std::sync::Arc;
20
+ #[cfg(feature = "portable")]
21
+ #[allow(unused_imports)]
22
+ use alloc::{borrow::ToOwned, boxed::Box, format, string::{String, ToString}, vec, vec::Vec};
23
+ #[cfg(feature = "portable")]
24
+ use tishlang_core::FloatExt;
25
+
26
+ use tishlang_core::Arc;
21
27
  use tishlang_core::{ObjectMap, Value, VmRef};
22
28
 
23
29
  const CONSTRUCT: &str = "__construct";
@@ -93,7 +99,7 @@ fn bytes_per_element(kind: Kind) -> f64 {
93
99
  fn elements(value: &Value) -> Vec<Value> {
94
100
  match value {
95
101
  Value::Array(a) => a.borrow().clone(),
96
- Value::NumberArray(a) => a.borrow().iter().map(|n| Value::Number(*n)).collect(),
102
+ Value::NumberArray(a) => a.borrow().to_values(),
97
103
  _ => Vec::new(),
98
104
  }
99
105
  }
@@ -291,6 +297,7 @@ mod tests {
291
297
  match &packed {
292
298
  Value::NumberArray(a) => {
293
299
  let v = a.borrow();
300
+ let v = v.as_packed().expect("packed backing");
294
301
  assert_eq!(v[0], 1.1);
295
302
  assert_eq!(v[1], 2.2);
296
303
  assert!(v[2].is_nan());
@@ -11,7 +11,7 @@ use tishlang_ast::{
11
11
 
12
12
  use crate::chunk::{Chunk, Constant};
13
13
  use crate::encoding::{binop_to_u8, compound_op_to_u8, unaryop_to_u8};
14
- use crate::opcode::{MathUnaryFn, Opcode};
14
+ use crate::opcode::{MathBinaryFn, MathUnaryFn, Opcode};
15
15
 
16
16
  enum SimpleMapResult {
17
17
  Identity,
@@ -452,6 +452,23 @@ fn expr_rebinds(e: &Expr, name: &str) -> bool {
452
452
  /// so it can't run program-wide), this has an explicit `FunDecl` arm: a `function name` is itself a
453
453
  /// binding, and every function body is recursed into. `true` (or any node it can't analyze) ⇒ NOT
454
454
  /// stable — which only forgoes the cross-function-call optimization, never risks a miscompile.
455
+ /// #203: the f64 value of a `Math.<name>` constant, or `None` if `name` isn't one. The values are the
456
+ /// IEEE doubles ECMAScript specifies (identical to Rust's `std::f64::consts` and to the `Math` object
457
+ /// built on every backend, #539), so emitting them as a `LoadConst` is byte-identical to `Math.PI` etc.
458
+ fn math_constant(name: &str) -> Option<f64> {
459
+ Some(match name {
460
+ "PI" => std::f64::consts::PI,
461
+ "E" => std::f64::consts::E,
462
+ "LN2" => std::f64::consts::LN_2,
463
+ "LN10" => std::f64::consts::LN_10,
464
+ "LOG2E" => std::f64::consts::LOG2_E,
465
+ "LOG10E" => std::f64::consts::LOG10_E,
466
+ "SQRT2" => std::f64::consts::SQRT_2,
467
+ "SQRT1_2" => std::f64::consts::FRAC_1_SQRT_2,
468
+ _ => return None,
469
+ })
470
+ }
471
+
455
472
  fn name_rebinds_in_stmt(s: &Statement, name: &str) -> bool {
456
473
  match s {
457
474
  Statement::FunDecl {
@@ -1968,6 +1985,9 @@ impl<'a> Compiler<'a> {
1968
1985
  }
1969
1986
  let mut inner_comp = Compiler::new(&mut inner, false);
1970
1987
  inner_comp.stable_globals = Arc::clone(&self.stable_globals);
1988
+ // #203: `Math` unshadowed is a whole-program property; thread it so `Math.<fn>` in this
1989
+ // function body lowers to the MathUnary intrinsic (else the numeric JIT bails).
1990
+ inner_comp.math_is_global = self.math_is_global;
1971
1991
  // Recursion-JIT enabler: if `name`'s binding is provably stable in the body (no
1972
1992
  // param shadows it, no reassignment/redeclaration), direct `name(args)` calls inside
1973
1993
  // compile to `SelfCall` — no name lookup, and the numeric JIT lowers it to a native
@@ -2399,6 +2419,31 @@ impl<'a> Compiler<'a> {
2399
2419
  }
2400
2420
  }
2401
2421
  }
2422
+ // #203: `Math.<binfn>(a, b)` → `<a>; <b>; MathBinary(id)` — the same unshadowed-global
2423
+ // gate, for exactly two positional args (variadic `Math.max(a,b,c)` falls through to a
2424
+ // generic call). Lets the numeric JIT lower clamp/pow kernels instead of bailing.
2425
+ if self.math_is_global && args.len() == 2 {
2426
+ if let Expr::Member {
2427
+ object,
2428
+ prop: MemberProp::Name { name: fname, .. },
2429
+ optional: false,
2430
+ ..
2431
+ } = callee.as_ref()
2432
+ {
2433
+ if let (Expr::Ident { name: obj, .. }, CallArg::Expr(a0), CallArg::Expr(a1)) =
2434
+ (object.as_ref(), &args[0], &args[1])
2435
+ {
2436
+ if obj.as_ref() == "Math" && self.resolve_slot("Math").is_none() {
2437
+ if let Some(bfn) = MathBinaryFn::from_name(fname.as_ref()) {
2438
+ self.compile_expr(a0)?;
2439
+ self.compile_expr(a1)?;
2440
+ self.emit_u16(Opcode::MathBinary, bfn as u16);
2441
+ return Ok(());
2442
+ }
2443
+ }
2444
+ }
2445
+ }
2446
+ }
2402
2447
  // Fast path: arr.sort((a,b)=>a-b) or arr.sort((a,b)=>b-a) -> ArraySortNumeric
2403
2448
  if !args.iter().any(|a| matches!(a, CallArg::Spread(_)))
2404
2449
  && args.len() == 1
@@ -2520,6 +2565,26 @@ impl<'a> Compiler<'a> {
2520
2565
  optional,
2521
2566
  ..
2522
2567
  } => {
2568
+ // #203: `Math.<CONST>` (PI / E / LN2 / …) when `Math` is the provably-unshadowed
2569
+ // global → a plain `LoadConst`, so the numeric JIT sees a constant instead of bailing
2570
+ // on a `GetMember`. The values match the `Math` object's on every backend (both use
2571
+ // `std::f64::consts`, #539), so this is byte-identical. Same gate as the intrinsics.
2572
+ if !*optional && self.math_is_global {
2573
+ if let (
2574
+ Expr::Ident { name: obj, .. },
2575
+ MemberProp::Name { name: p, .. },
2576
+ ) = (object.as_ref(), prop)
2577
+ {
2578
+ if obj.as_ref() == "Math" && self.resolve_slot("Math").is_none() {
2579
+ if let Some(v) = math_constant(p.as_ref()) {
2580
+ let idx = self.constant_idx(Constant::Number(v));
2581
+ self.emit(Opcode::LoadConst);
2582
+ self.chunk.write_u16(idx);
2583
+ return Ok(());
2584
+ }
2585
+ }
2586
+ }
2587
+ }
2523
2588
  self.compile_expr(object)?;
2524
2589
  if *optional {
2525
2590
  self.emit(Opcode::Dup);
@@ -2686,6 +2751,7 @@ impl<'a> Compiler<'a> {
2686
2751
  }
2687
2752
  let mut inner_comp = Compiler::new(&mut inner, false);
2688
2753
  inner_comp.stable_globals = Arc::clone(&self.stable_globals); // #187
2754
+ inner_comp.math_is_global = self.math_is_global; // #203 (see other call site)
2689
2755
  if let Some(map) = simple_slots {
2690
2756
  inner_comp.slot_ctx = Some(map);
2691
2757
  } else {
@@ -3116,7 +3182,15 @@ fn compile_internal(
3116
3182
  // #186 — `Math` intrinsics are sound only if `Math` is never rebound anywhere in the program.
3117
3183
  // `stmt_rebinds` is conservative (any rebind, destructure, FunDecl, or unknown node → true), so a
3118
3184
  // `false` here only forgoes the optimization, never risks a miscompile.
3119
- compiler.math_is_global = !program.statements.iter().any(|s| stmt_rebinds(s, "Math"));
3185
+ // #203: whole-program scan (recurses into function bodies/params, unlike `stmt_rebinds` which is
3186
+ // conservatively `true` on any FunDecl) — so `Math` is provably-global even when the program
3187
+ // defines functions, letting `Math.<fn>` inside a function lower to the MathUnary intrinsic (the
3188
+ // numeric/array JIT then compiles the kernel instead of bailing on a generic call). Errs toward
3189
+ // "rebound" ⇒ never falsely global ⇒ no miscompile. Threaded into nested compilers below.
3190
+ compiler.math_is_global = !program
3191
+ .statements
3192
+ .iter()
3193
+ .any(|s| name_rebinds_in_stmt(s, "Math"));
3120
3194
  // #187 — the set of top-level functions safe to call directly from JIT'd code (never reassigned/
3121
3195
  // shadowed/redeclared anywhere). Conservative: a name absent here only forgoes the optimization.
3122
3196
  compiler.stable_globals = Arc::new(compute_stable_globals(program));
@@ -16,5 +16,5 @@ pub use compiler::{
16
16
  compile_with_source, CompileError,
17
17
  };
18
18
  pub use encoding::{binop_to_u8, compound_op_to_u8, u8_to_binop, u8_to_unaryop, unaryop_to_u8};
19
- pub use opcode::{MathUnaryFn, Opcode};
19
+ pub use opcode::{MathBinaryFn, MathUnaryFn, Opcode};
20
20
  pub use serialize::{deserialize, serialize};
@@ -145,6 +145,11 @@ pub enum Opcode {
145
145
  /// lower it to a native op / libcall without a runtime shape guard. Behaviour is identical to
146
146
  /// `LoadVar Math; GetMember fn; <arg>; Call 1` on a number.
147
147
  MathUnary = 55,
148
+ /// #203 — apply a BINARY `Math.<fn>` intrinsic (max / min / pow / atan2 / hypot) to the top TWO
149
+ /// numbers: pop `b`, pop `a`, push `Math.fn(a, b)`. The u16 operand is the [`MathBinaryFn`] id.
150
+ /// Same emission gate as [`Opcode::MathUnary`] (`Math` provably the unshadowed global) but for
151
+ /// exactly two args. Behaviour identical to `LoadVar Math; GetMember fn; <a>; <b>; Call 2`.
152
+ MathBinary = 56,
148
153
  }
149
154
 
150
155
  /// The unary `Math` functions the [`Opcode::MathUnary`] fast path recognizes (#186). f64→f64;
@@ -289,11 +294,81 @@ impl MathUnaryFn {
289
294
  }
290
295
  }
291
296
 
297
+ /// The binary `Math` functions the [`Opcode::MathBinary`] fast path recognizes (#203). `(f64, f64) →
298
+ /// f64`; the discriminant is the opcode operand. Kept in sync with the VM handler and JIT lowering.
299
+ /// (`hypot` is intentionally NOT here — it stays a correct generic call to avoid replicating its
300
+ /// scaled-sum-of-squares in this below-builtins crate.)
301
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
302
+ #[repr(u16)]
303
+ pub enum MathBinaryFn {
304
+ Max = 0,
305
+ Min = 1,
306
+ Pow = 2,
307
+ Atan2 = 3,
308
+ }
309
+
310
+ impl MathBinaryFn {
311
+ /// Map a `Math.<name>` member to its id, or `None` if it isn't a supported 2-arg intrinsic.
312
+ pub fn from_name(name: &str) -> Option<MathBinaryFn> {
313
+ Some(match name {
314
+ "max" => MathBinaryFn::Max,
315
+ "min" => MathBinaryFn::Min,
316
+ "pow" => MathBinaryFn::Pow,
317
+ "atan2" => MathBinaryFn::Atan2,
318
+ _ => return None,
319
+ })
320
+ }
321
+
322
+ /// Decode an opcode operand to the fn id (safe match — no transmute).
323
+ pub fn from_u16(v: u16) -> Option<MathBinaryFn> {
324
+ use MathBinaryFn::*;
325
+ Some(match v {
326
+ 0 => Max,
327
+ 1 => Min,
328
+ 2 => Pow,
329
+ 3 => Atan2,
330
+ _ => return None,
331
+ })
332
+ }
333
+
334
+ /// Apply the intrinsic — the single source of truth for VM + JIT result parity. Replicates the JS
335
+ /// semantics of `tishlang_builtins::math` (this crate sits below builtins), so VM/JIT == interp.
336
+ #[inline]
337
+ pub fn apply(self, a: f64, b: f64) -> f64 {
338
+ match self {
339
+ // Math.max(a,b): NaN if either is NaN; else the larger, with +0 preferred over -0. Exactly
340
+ // `max_f64(&[a, b])`.
341
+ MathBinaryFn::Max => {
342
+ if a.is_nan() || b.is_nan() {
343
+ f64::NAN
344
+ } else if a > b || (a == 0.0 && b == 0.0 && a.is_sign_positive()) {
345
+ a
346
+ } else {
347
+ b
348
+ }
349
+ }
350
+ // Math.min(a,b): NaN if either is NaN; else the smaller, with -0 preferred. Exactly
351
+ // `min_f64(&[a, b])`.
352
+ MathBinaryFn::Min => {
353
+ if a.is_nan() || b.is_nan() {
354
+ f64::NAN
355
+ } else if a < b || (a == 0.0 && b == 0.0 && a.is_sign_negative()) {
356
+ a
357
+ } else {
358
+ b
359
+ }
360
+ }
361
+ MathBinaryFn::Pow => a.powf(b),
362
+ MathBinaryFn::Atan2 => a.atan2(b),
363
+ }
364
+ }
365
+ }
366
+
292
367
  impl Opcode {
293
- /// Decode byte to opcode. Safe for b in 0..=55 (matches #[repr(u8)] discriminants).
368
+ /// Decode byte to opcode. Safe for b in 0..=56 (matches #[repr(u8)] discriminants).
294
369
  #[inline]
295
370
  pub fn from_u8(b: u8) -> Option<Opcode> {
296
- if b <= 55 {
371
+ if b <= 56 {
297
372
  Some(unsafe { std::mem::transmute::<u8, Opcode>(b) })
298
373
  } else {
299
374
  None