@tishlang/tish 2.2.5 → 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 (53) 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 +12 -5
  7. package/crates/tish_build_utils/src/lib.rs +37 -0
  8. package/crates/tish_builtins/src/array.rs +82 -1
  9. package/crates/tish_builtins/src/globals.rs +50 -16
  10. package/crates/tish_builtins/src/math.rs +63 -9
  11. package/crates/tish_builtins/src/number.rs +20 -1
  12. package/crates/tish_builtins/src/string.rs +68 -4
  13. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  14. package/crates/tish_bytecode/src/compiler.rs +94 -28
  15. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  16. package/crates/tish_compile/src/check.rs +11 -10
  17. package/crates/tish_compile/src/codegen.rs +1386 -42
  18. package/crates/tish_compile/src/infer.rs +16 -16
  19. package/crates/tish_compile/src/lib.rs +38 -0
  20. package/crates/tish_compile/src/resolve.rs +3 -2
  21. package/crates/tish_compile/src/types.rs +26 -9
  22. package/crates/tish_compile_js/src/codegen.rs +55 -4
  23. package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
  24. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  25. package/crates/tish_core/Cargo.toml +2 -0
  26. package/crates/tish_core/src/json.rs +135 -34
  27. package/crates/tish_core/src/lib.rs +23 -1
  28. package/crates/tish_core/src/value.rs +64 -11
  29. package/crates/tish_eval/src/eval.rs +144 -197
  30. package/crates/tish_eval/src/natives.rs +45 -45
  31. package/crates/tish_eval/src/regex.rs +35 -27
  32. package/crates/tish_eval/src/value.rs +8 -0
  33. package/crates/tish_fmt/src/lib.rs +46 -2
  34. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  35. package/crates/tish_lint/src/lib.rs +197 -21
  36. package/crates/tish_lsp/Cargo.toml +7 -1
  37. package/crates/tish_lsp/src/import_goto.rs +52 -7
  38. package/crates/tish_lsp/src/main.rs +913 -145
  39. package/crates/tish_opt/src/lib.rs +5 -3
  40. package/crates/tish_parser/src/lib.rs +23 -5
  41. package/crates/tish_parser/src/parser.rs +35 -35
  42. package/crates/tish_resolve/src/lib.rs +567 -17
  43. package/crates/tish_runtime/src/lib.rs +58 -18
  44. package/crates/tish_ui/src/jsx.rs +2 -2
  45. package/crates/tish_vm/src/jit.rs +212 -10
  46. package/crates/tish_vm/src/vm.rs +39 -18
  47. package/crates/tish_wasm/src/lib.rs +116 -22
  48. package/package.json +1 -1
  49. package/platform/darwin-arm64/tish +0 -0
  50. package/platform/darwin-x64/tish +0 -0
  51. package/platform/linux-arm64/tish +0 -0
  52. package/platform/linux-x64/tish +0 -0
  53. package/platform/win32-x64/tish.exe +0 -0
@@ -52,15 +52,15 @@ impl InferCtx {
52
52
  }
53
53
 
54
54
  fn is_number(ann: &TypeAnnotation) -> bool {
55
- matches!(ann, TypeAnnotation::Simple(s) if s.as_ref() == "number")
55
+ matches!(ann, TypeAnnotation::Simple(s, _) if s.as_ref() == "number")
56
56
  }
57
57
 
58
58
  fn is_string(ann: &TypeAnnotation) -> bool {
59
- matches!(ann, TypeAnnotation::Simple(s) if s.as_ref() == "string")
59
+ matches!(ann, TypeAnnotation::Simple(s, _) if s.as_ref() == "string")
60
60
  }
61
61
 
62
62
  fn is_bool(ann: &TypeAnnotation) -> bool {
63
- matches!(ann, TypeAnnotation::Simple(s) if s.as_ref() == "boolean")
63
+ matches!(ann, TypeAnnotation::Simple(s, _) if s.as_ref() == "boolean")
64
64
  }
65
65
 
66
66
  /// Element type of an array literal of uniform native scalars (number/string/boolean), for
@@ -91,15 +91,15 @@ fn infer_array_elem(elements: &[tishlang_ast::ArrayElement], ctx: &InferCtx) ->
91
91
  }
92
92
 
93
93
  fn number_ann() -> TypeAnnotation {
94
- TypeAnnotation::Simple("number".into())
94
+ TypeAnnotation::Simple("number".into(), tishlang_ast::Span::default())
95
95
  }
96
96
 
97
97
  fn string_ann() -> TypeAnnotation {
98
- TypeAnnotation::Simple("string".into())
98
+ TypeAnnotation::Simple("string".into(), tishlang_ast::Span::default())
99
99
  }
100
100
 
101
101
  fn bool_ann() -> TypeAnnotation {
102
- TypeAnnotation::Simple("boolean".into())
102
+ TypeAnnotation::Simple("boolean".into(), tishlang_ast::Span::default())
103
103
  }
104
104
 
105
105
  /// Infer the `TypeAnnotation` for an expression, if unambiguous.
@@ -237,7 +237,7 @@ fn pi_stmt(s: Statement) -> Statement {
237
237
  && tp.default.is_none()
238
238
  && nus_stmt(&body, tp.name.as_ref(), &nums)
239
239
  {
240
- tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number")));
240
+ tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number"), tishlang_ast::Span::default()));
241
241
  }
242
242
  FunParam::Simple(tp)
243
243
  }
@@ -558,7 +558,7 @@ pub(crate) fn pi_mentions(e: &Expr, name: &str) -> bool {
558
558
  }
559
559
  }),
560
560
  Object { props, .. } => props.iter().any(|p| match p {
561
- tishlang_ast::ObjectProp::KeyValue(_, v) => pi_mentions(v, name),
561
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => pi_mentions(v, name),
562
562
  tishlang_ast::ObjectProp::Spread(x) => pi_mentions(x, name),
563
563
  }),
564
564
  TemplateLiteral { exprs, .. } => exprs.iter().any(|x| pi_mentions(x, name)),
@@ -612,7 +612,7 @@ impl StructRegistry {
612
612
 
613
613
  fn type_canon(t: &TypeAnnotation) -> String {
614
614
  match t {
615
- TypeAnnotation::Simple(s) => s.to_string(),
615
+ TypeAnnotation::Simple(s, _) => s.to_string(),
616
616
  TypeAnnotation::Object(fields) => format!(
617
617
  "{{{}}}",
618
618
  fields
@@ -634,10 +634,10 @@ fn infer_object_shape(
634
634
  let mut fields = Vec::with_capacity(props.len());
635
635
  for p in props {
636
636
  match p {
637
- tishlang_ast::ObjectProp::KeyValue(k, v) => {
637
+ tishlang_ast::ObjectProp::KeyValue(k, v, _) => {
638
638
  let ty = infer_expr_type(v, ctx)?;
639
639
  // Only primitive field types in this conservative version.
640
- if !matches!(&ty, TypeAnnotation::Simple(s)
640
+ if !matches!(&ty, TypeAnnotation::Simple(s, _)
641
641
  if matches!(s.as_ref(), "number" | "string" | "boolean"))
642
642
  {
643
643
  return None;
@@ -776,12 +776,12 @@ fn si_block(stmts: Vec<Statement>, reg: &mut StructRegistry, ctx: &mut InferCtx)
776
776
  // Sound: every later use in this block must be a literal-key read.
777
777
  if uses_are_struct_safe(name.as_ref(), &keys, &stmts[i + 1..]) {
778
778
  let alias = reg.intern(&fields);
779
- ctx.define(name.as_ref(), TypeAnnotation::Simple(alias.as_str().into()));
779
+ ctx.define(name.as_ref(), TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default()));
780
780
  out.push(Statement::VarDecl {
781
781
  name: name.clone(),
782
782
  name_span: *name_span,
783
783
  mutable: *mutable,
784
- type_ann: Some(TypeAnnotation::Simple(alias.as_str().into())),
784
+ type_ann: Some(TypeAnnotation::Simple(alias.as_str().into(), tishlang_ast::Span::default())),
785
785
  init: stmt_init_clone(stmt),
786
786
  span: *span,
787
787
  });
@@ -1303,7 +1303,7 @@ fn arr_expr_safe(e: &Expr, name: &str) -> bool {
1303
1303
  }
1304
1304
  }),
1305
1305
  Object { props, .. } => props.iter().all(|p| match p {
1306
- tishlang_ast::ObjectProp::KeyValue(_, v) => arr_expr_safe(v, name),
1306
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => arr_expr_safe(v, name),
1307
1307
  tishlang_ast::ObjectProp::Spread(v) => arr_expr_safe(v, name),
1308
1308
  }),
1309
1309
  // Anything else that mentions `name`: be safe, bail.
@@ -1423,7 +1423,7 @@ fn expr_name_safe(e: &Expr, name: &str, keys: &std::collections::HashSet<&str>)
1423
1423
  }
1424
1424
  }),
1425
1425
  Object { props, .. } => props.iter().all(|p| match p {
1426
- tishlang_ast::ObjectProp::KeyValue(_, v) => expr_name_safe(v, name, keys),
1426
+ tishlang_ast::ObjectProp::KeyValue(_, v, _) => expr_name_safe(v, name, keys),
1427
1427
  tishlang_ast::ObjectProp::Spread(e) => expr_name_safe(e, name, keys),
1428
1428
  }),
1429
1429
  TemplateLiteral { exprs, .. } => exprs.iter().all(|e| expr_name_safe(e, name, keys)),
@@ -1642,7 +1642,7 @@ mod param_infer_tests {
1642
1642
  if let FunParam::Simple(tp) = p {
1643
1643
  if tp.name.as_ref() == param {
1644
1644
  return tp.type_ann.as_ref().map(|a| match a {
1645
- TypeAnnotation::Simple(s) => s.to_string(),
1645
+ TypeAnnotation::Simple(s, _) => s.to_string(),
1646
1646
  _ => "<complex>".to_string(),
1647
1647
  });
1648
1648
  }
@@ -186,6 +186,44 @@ fn factory() {
186
186
  "expected outerVar to be inferred as f64 (Copy, no clone needed)"
187
187
  );
188
188
  }
189
+
190
+ /// i32-loop-var lowering: an FNV-style integer/bitwise hash accumulator declared before a `for`
191
+ /// and reassigned only by `>>> 0`/bitwise ops lives in an `i32` register across the loop — no
192
+ /// per-op `to_int32(h)`↔`f64` round-trip — with a single `f64` excursion for the `h * C`
193
+ /// multiply (which exceeds 2^53, so it must round in f64 before `>>> 0`, exactly as V8 does).
194
+ #[test]
195
+ fn fnv_accumulator_lowers_to_i32_register() {
196
+ let src = r#"
197
+ let h = 2166136261
198
+ for (let i = 0; i < 100; i++) {
199
+ h = h ^ (i & 255)
200
+ h = (h * 16777619) >>> 0
201
+ h = ((h << 13) | (h >>> 19)) >>> 0
202
+ }
203
+ let check = h >>> 0
204
+ console.log(check)
205
+ "#;
206
+ let rust = compile(&parse(src).unwrap()).unwrap();
207
+ // The accumulator is an i32 register, initialized via the u32 reinterpretation so the
208
+ // > i32::MAX seed keeps its JS ToInt32 bit-pattern.
209
+ assert!(
210
+ rust.contains("let mut h: i32 = (2166136261u32) as i32;"),
211
+ "expected `h` to be an i32 register seeded via u32 reinterpretation; got:\n{}",
212
+ rust.lines().filter(|l| l.contains("h")).take(8).collect::<Vec<_>>().join("\n")
213
+ );
214
+ // The per-iteration `to_int32(h)` round-trips are gone: `h` is read straight from the
215
+ // register inside the bitwise chain (no `to_int32(h)` substring referencing the accumulator).
216
+ assert!(
217
+ !rust.contains("to_int32(h)"),
218
+ "expected NO per-op `to_int32(h)` round-trip on the i32 accumulator"
219
+ );
220
+ // The only f64 excursion is the multiply, lowered as an unchecked truncation of the
221
+ // provably-finite product (`h as f64 * 16777619`).
222
+ assert!(
223
+ rust.contains(".to_int_unchecked::<i64>()") && rust.contains("16777619"),
224
+ "expected the `h * 16777619` excursion to lower to an unchecked f64 truncation"
225
+ );
226
+ }
189
227
  }
190
228
 
191
229
  #[cfg(test)]
@@ -173,7 +173,7 @@ pub fn program_uses_document(program: &Program) -> bool {
173
173
  ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_uses_document(e),
174
174
  }),
175
175
  Expr::Object { props, .. } => props.iter().any(|p| match p {
176
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_uses_document(e),
176
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => expr_uses_document(e),
177
177
  }),
178
178
  Expr::Assign { value, .. }
179
179
  | Expr::CompoundAssign { value, .. }
@@ -1601,7 +1601,7 @@ fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1601
1601
  Expr::Object { props, .. } => {
1602
1602
  for p in props {
1603
1603
  match p {
1604
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => {
1604
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => {
1605
1605
  rewrite_expr_scope(e, active) // key is a property name; value recurses
1606
1606
  }
1607
1607
  }
@@ -1819,6 +1819,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1819
1819
  name: Arc::from(v.clone()),
1820
1820
  span: *span,
1821
1821
  },
1822
+ *name_span,
1822
1823
  ));
1823
1824
  }
1824
1825
  merge_push(
@@ -13,6 +13,12 @@ pub enum RustType {
13
13
  Value,
14
14
  /// f64 (for number)
15
15
  F64,
16
+ /// i32 — a `number` local PROVEN to always hold an integer reinterpretable as a JS ToInt32
17
+ /// bit-pattern, kept in an integer register across a bitwise/hash hot loop (bun/JSC-style)
18
+ /// instead of round-tripping `f64`↔`i32` on every op. The value is the signed int32
19
+ /// (= JS `ToInt32`) view; `>>> 0` results are uint32 reinterpreted into this i32. Only the
20
+ /// codegen's i32-loop-var lowering produces this type — never `from_annotation`.
21
+ I32,
16
22
  /// String (for string)
17
23
  String,
18
24
  /// bool (for boolean)
@@ -62,7 +68,7 @@ impl RustType {
62
68
  aliases: &HashMap<String, RustType>,
63
69
  ) -> Self {
64
70
  match ann {
65
- TypeAnnotation::Simple(name) => match name.as_ref() {
71
+ TypeAnnotation::Simple(name, _) => match name.as_ref() {
66
72
  "number" => RustType::F64,
67
73
  "string" => RustType::String,
68
74
  "boolean" | "bool" => RustType::Bool,
@@ -111,10 +117,10 @@ impl RustType {
111
117
  if types.len() == 2 {
112
118
  let has_null = types
113
119
  .iter()
114
- .any(|t| matches!(t, TypeAnnotation::Simple(s) if s.as_ref() == "null"));
120
+ .any(|t| matches!(t, TypeAnnotation::Simple(s, _) if s.as_ref() == "null"));
115
121
  if has_null {
116
122
  let non_null = types.iter().find(
117
- |t| !matches!(t, TypeAnnotation::Simple(s) if s.as_ref() == "null"),
123
+ |t| !matches!(t, TypeAnnotation::Simple(s, _) if s.as_ref() == "null"),
118
124
  );
119
125
  if let Some(inner) = non_null {
120
126
  return RustType::Option(Box::new(Self::from_annotation_with_aliases(
@@ -222,6 +228,7 @@ impl RustType {
222
228
  match self {
223
229
  RustType::Value => "Value".to_string(),
224
230
  RustType::F64 => "f64".to_string(),
231
+ RustType::I32 => "i32".to_string(),
225
232
  RustType::String => "String".to_string(),
226
233
  RustType::Bool => "bool".to_string(),
227
234
  RustType::Unit => "()".to_string(),
@@ -250,6 +257,7 @@ impl RustType {
250
257
  match self {
251
258
  RustType::Value => "Value::Null".to_string(),
252
259
  RustType::F64 => "0.0".to_string(),
260
+ RustType::I32 => "0i32".to_string(),
253
261
  RustType::String => "String::new()".to_string(),
254
262
  RustType::Bool => "false".to_string(),
255
263
  RustType::Unit => "()".to_string(),
@@ -306,6 +314,12 @@ impl RustType {
306
314
  "match &{} {{ Value::Number(n) => *n, _ => panic!(\"expected number\") }}",
307
315
  value_expr
308
316
  ),
317
+ // A `Value::Number` narrowed to its JS ToInt32 bit-pattern (NaN/±Inf → 0, exactly as
318
+ // the bitwise/shift path coerces). Only reached for an `I32`-typed binding boundary.
319
+ RustType::I32 => format!(
320
+ "match &{} {{ Value::Number(n) => tishlang_runtime::to_int32(*n), _ => panic!(\"expected number\") }}",
321
+ value_expr
322
+ ),
309
323
  RustType::String => format!(
310
324
  "match &{} {{ Value::String(s) => s.to_string(), _ => panic!(\"expected string\") }}",
311
325
  value_expr
@@ -363,6 +377,9 @@ impl RustType {
363
377
  }
364
378
  RustType::Value => native_expr.to_string(),
365
379
  RustType::F64 => format!("Value::Number({})", native_expr),
380
+ // The signed int32 view boxes as a JS Number (every i32 is exactly representable in
381
+ // f64). The uint32 `>>> 0` reinterpretation is applied at the boxing site, not here.
382
+ RustType::I32 => format!("Value::Number(({}) as f64)", native_expr),
366
383
  RustType::String => format!("Value::String({}.clone().into())", native_expr),
367
384
  RustType::Bool => format!("Value::Bool({})", native_expr),
368
385
  RustType::Unit => "Value::Null".to_string(),
@@ -532,22 +549,22 @@ mod tests {
532
549
  #[test]
533
550
  fn test_simple_types() {
534
551
  assert_eq!(
535
- RustType::from_annotation(&TypeAnnotation::Simple("number".into())),
552
+ RustType::from_annotation(&TypeAnnotation::Simple("number".into(), tishlang_ast::Span::default())),
536
553
  RustType::F64
537
554
  );
538
555
  assert_eq!(
539
- RustType::from_annotation(&TypeAnnotation::Simple("string".into())),
556
+ RustType::from_annotation(&TypeAnnotation::Simple("string".into(), tishlang_ast::Span::default())),
540
557
  RustType::String
541
558
  );
542
559
  assert_eq!(
543
- RustType::from_annotation(&TypeAnnotation::Simple("boolean".into())),
560
+ RustType::from_annotation(&TypeAnnotation::Simple("boolean".into(), tishlang_ast::Span::default())),
544
561
  RustType::Bool
545
562
  );
546
563
  }
547
564
 
548
565
  #[test]
549
566
  fn test_array_type() {
550
- let arr_type = TypeAnnotation::Array(Box::new(TypeAnnotation::Simple("number".into())));
567
+ let arr_type = TypeAnnotation::Array(Box::new(TypeAnnotation::Simple("number".into(), tishlang_ast::Span::default())));
551
568
  assert_eq!(
552
569
  RustType::from_annotation(&arr_type),
553
570
  RustType::Vec(Box::new(RustType::F64))
@@ -557,8 +574,8 @@ mod tests {
557
574
  #[test]
558
575
  fn test_nullable_type() {
559
576
  let nullable = TypeAnnotation::Union(vec![
560
- TypeAnnotation::Simple("string".into()),
561
- TypeAnnotation::Simple("null".into()),
577
+ TypeAnnotation::Simple("string".into(), tishlang_ast::Span::default()),
578
+ TypeAnnotation::Simple("null".into(), tishlang_ast::Span::default()),
562
579
  ]);
563
580
  assert_eq!(
564
581
  RustType::from_annotation(&nullable),
@@ -487,9 +487,29 @@ impl Codegen {
487
487
  }
488
488
  }
489
489
 
490
+ /// Is `expr` the `null` literal? Used to lower `x === null` / `x !== null` to JS `== null` /
491
+ /// `!= null` so the check catches `undefined` too (tish treats a missing/undefined value as
492
+ /// null on every other backend) — see the StrictEq/StrictNe arms below.
493
+ fn is_null_literal(expr: &Expr) -> bool {
494
+ matches!(
495
+ expr,
496
+ Expr::Literal {
497
+ value: Literal::Null,
498
+ ..
499
+ }
500
+ )
501
+ }
502
+
490
503
  fn emit_expr(&mut self, expr: &Expr) -> Result<String, CompileError> {
491
504
  Ok(match expr {
492
505
  Expr::Literal { value, .. } => match value {
506
+ // Rust's `{}` prints non-finite f64 as `inf` / `-inf` / `NaN`; only `NaN` is valid JS.
507
+ // Emit the JS spellings so a folded `1/0` / `-1/0` doesn't become an undefined `inf`
508
+ // identifier in the output.
509
+ Literal::Number(n) if n.is_nan() => "NaN".to_string(),
510
+ Literal::Number(n) if n.is_infinite() => {
511
+ if *n < 0.0 { "-Infinity".to_string() } else { "Infinity".to_string() }
512
+ }
493
513
  Literal::Number(n) => format!("{}", n),
494
514
  Literal::String(s) => format!("{:?}", s.as_ref()),
495
515
  Literal::Bool(b) => format!("{}", b),
@@ -510,8 +530,26 @@ impl Codegen {
510
530
  BinOp::Pow => "**",
511
531
  BinOp::Eq => "==",
512
532
  BinOp::Ne => "!=",
513
- BinOp::StrictEq => "===",
514
- BinOp::StrictNe => "!==",
533
+ // tish has no `undefined`: a missing/absent value reads back as `null`, so
534
+ // `x === null` means "is nullish" — exactly how interp/vm/native behave (a
535
+ // missing property is null there). In the JS runtime a missing property is
536
+ // `undefined`, so lower `=== null` / `!== null` to loose `== null` / `!= null`,
537
+ // which match BOTH null and undefined — keeping `=== null` mean the same thing on
538
+ // every target. Strict equality between non-null operands is unaffected.
539
+ BinOp::StrictEq => {
540
+ if Self::is_null_literal(left) || Self::is_null_literal(right) {
541
+ "=="
542
+ } else {
543
+ "==="
544
+ }
545
+ }
546
+ BinOp::StrictNe => {
547
+ if Self::is_null_literal(left) || Self::is_null_literal(right) {
548
+ "!="
549
+ } else {
550
+ "!=="
551
+ }
552
+ }
515
553
  BinOp::Lt => "<",
516
554
  BinOp::Le => "<=",
517
555
  BinOp::Gt => ">",
@@ -563,6 +601,15 @@ impl Codegen {
563
601
  ..
564
602
  } => {
565
603
  let obj = self.emit_expr(object)?;
604
+ // `255.toString()` is a JS syntax error — the lexer reads `255.` as a float and
605
+ // then chokes on the method name. Parenthesize a numeric-literal object so member
606
+ // access / method calls stay valid: `(255).toString()`. (Folded constants reach
607
+ // codegen as number literals too, so this covers e.g. `(100 * 2).toString()`.)
608
+ let obj = if matches!(&**object, Expr::Literal { value: Literal::Number(_), .. }) {
609
+ format!("({})", obj)
610
+ } else {
611
+ obj
612
+ };
566
613
  let expr = match prop {
567
614
  MemberProp::Name { name, .. } => {
568
615
  if name.parse::<u32>().is_ok()
@@ -629,7 +676,7 @@ impl Codegen {
629
676
  let parts: Result<Vec<_>, _> = props
630
677
  .iter()
631
678
  .map(|p| match p {
632
- ObjectProp::KeyValue(k, v) => {
679
+ ObjectProp::KeyValue(k, v, _) => {
633
680
  let key = k.as_ref();
634
681
  let val = self.emit_expr(v)?;
635
682
  Ok(if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
@@ -650,7 +697,11 @@ impl Codegen {
650
697
  }
651
698
  Expr::TypeOf { operand, .. } => {
652
699
  let o = self.emit_expr(operand)?;
653
- format!("(typeof {})", o)
700
+ // tish `typeof null` is "null" (interp/vm/native all agree — null is a first-class
701
+ // type, not JS's `typeof null === "object"` wart). tish has no `undefined`, so any
702
+ // nullish operand (incl. a JS-runtime `undefined`) maps to "null". Evaluate the
703
+ // operand once via the arrow arg so side effects don't run twice.
704
+ format!("((__v) => __v == null ? \"null\" : typeof __v)({})", o)
654
705
  }
655
706
  Expr::Delete { target, .. } => {
656
707
  // Emit the raw property *reference*, not a value: `emit_expr` wraps Index /
@@ -411,4 +411,48 @@ fn factory() {
411
411
  );
412
412
  }
413
413
  }
414
+
415
+ // tish `=== null` / `!== null` lower to JS `== null` / `!= null` so the nullish check catches the
416
+ // JS-runtime `undefined` (missing props / holes) too — matching interp/vm/native, which read a
417
+ // missing property back as null. Strict equality between non-null operands stays strict.
418
+ #[test]
419
+ fn strict_eq_null_lowers_to_loose_null() {
420
+ let program = parse("let x = 1\nconsole.log(x === null)\nconsole.log(x !== null)\n").unwrap();
421
+ let js = crate::compile(&program, false).unwrap();
422
+ assert!(!js.contains("=== null"), "`=== null` must lower to `== null`:\n{js}");
423
+ assert!(!js.contains("!== null"), "`!== null` must lower to `!= null`:\n{js}");
424
+ assert!(
425
+ js.contains("== null") && js.contains("!= null"),
426
+ "expected loose null checks:\n{js}"
427
+ );
428
+ }
429
+
430
+ #[test]
431
+ fn strict_eq_between_non_null_operands_stays_strict() {
432
+ let program = parse("let a = 1\nlet b = 2\nconsole.log(a === b)\nconsole.log(a !== b)\n").unwrap();
433
+ let js = crate::compile(&program, false).unwrap();
434
+ assert!(js.contains("==="), "non-null `===` must stay strict:\n{js}");
435
+ assert!(js.contains("!=="), "non-null `!==` must stay strict:\n{js}");
436
+ }
437
+
438
+ // `typeof null` is "null" in tish (interp/vm/native agree — null is a first-class type), not JS's
439
+ // `typeof null === "object"` wart. The JS backend must map a nullish operand to "null".
440
+ #[test]
441
+ fn typeof_null_emits_null_not_object() {
442
+ let program = parse("console.log(typeof null)\n").unwrap();
443
+ let js = crate::compile(&program, false).unwrap();
444
+ assert!(!js.contains("(typeof null)"), "must not emit raw `typeof null`:\n{js}");
445
+ assert!(js.contains("\"null\""), "typeof of a nullish value must yield \"null\":\n{js}");
446
+ }
447
+
448
+ // 1/0 and -1/0 fold to Infinity / -Infinity; emit the JS spellings, not Rust's `inf` / `-inf`
449
+ // (which would be undefined identifiers in the output).
450
+ #[test]
451
+ fn non_finite_number_literals_use_js_spellings() {
452
+ let pos = crate::compile(&parse("console.log(1 / 0)\n").unwrap(), true).unwrap();
453
+ assert!(pos.contains("Infinity"), "1/0 must emit Infinity:\n{pos}");
454
+ assert!(!pos.contains("inf"), "must not emit Rust's lowercase `inf`:\n{pos}");
455
+ let neg = crate::compile(&parse("console.log(-1 / 0)\n").unwrap(), true).unwrap();
456
+ assert!(neg.contains("-Infinity"), "-1/0 must emit -Infinity:\n{neg}");
457
+ }
414
458
  }
@@ -394,6 +394,7 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
394
394
  name: Arc::from(v.clone()),
395
395
  span: *span,
396
396
  },
397
+ *name_span,
397
398
  ));
398
399
  }
399
400
  statements.push(Statement::VarDecl {
@@ -23,6 +23,8 @@ ahash = "0.8.11"
23
23
  arcstr = "1"
24
24
  smallvec = "1"
25
25
  indexmap = "2"
26
+ # Fast integer→decimal for the JSON.stringify number fast-path (already in the workspace lock).
27
+ itoa = "1"
26
28
  fancy-regex = { version = "0.17.0", optional = true }
27
29
  # Only under `send-values` (the Arc<Mutex> path): parking_lot's uncontended lock is a
28
30
  # single atomic with no pthread syscall — profiled as a top cost on object/array access.