@tishlang/tish 2.2.7 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) 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 +2 -1
  4. package/crates/tish/src/cli_help.rs +15 -0
  5. package/crates/tish/src/main.rs +139 -12
  6. package/crates/tish/tests/integration_test.rs +65 -0
  7. package/crates/tish_ast/src/ast.rs +6 -2
  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 +1 -1
  17. package/crates/tish_compile/src/codegen.rs +3133 -582
  18. package/crates/tish_compile/src/infer.rs +1950 -56
  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 +17 -0
  22. package/crates/tish_compile_js/Cargo.toml +3 -0
  23. package/crates/tish_compile_js/src/codegen.rs +365 -9
  24. package/crates/tish_compile_js/src/lib.rs +2 -1
  25. package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
  26. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  27. package/crates/tish_core/Cargo.toml +2 -0
  28. package/crates/tish_core/src/json.rs +135 -34
  29. package/crates/tish_core/src/lib.rs +23 -1
  30. package/crates/tish_core/src/value.rs +64 -11
  31. package/crates/tish_eval/src/eval.rs +144 -197
  32. package/crates/tish_eval/src/natives.rs +45 -45
  33. package/crates/tish_eval/src/regex.rs +35 -27
  34. package/crates/tish_eval/src/value.rs +8 -0
  35. package/crates/tish_fmt/src/lib.rs +45 -1
  36. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  37. package/crates/tish_lint/src/lib.rs +197 -21
  38. package/crates/tish_lsp/Cargo.toml +6 -0
  39. package/crates/tish_lsp/src/import_goto.rs +52 -7
  40. package/crates/tish_lsp/src/main.rs +856 -140
  41. package/crates/tish_opt/src/lib.rs +5 -3
  42. package/crates/tish_parser/src/lib.rs +23 -5
  43. package/crates/tish_parser/src/parser.rs +15 -26
  44. package/crates/tish_resolve/src/lib.rs +188 -18
  45. package/crates/tish_runtime/src/lib.rs +58 -18
  46. package/crates/tish_ui/src/jsx.rs +2 -2
  47. package/crates/tish_vm/src/jit.rs +212 -10
  48. package/crates/tish_vm/src/vm.rs +39 -18
  49. package/crates/tish_wasm/src/lib.rs +116 -22
  50. package/package.json +1 -1
  51. package/platform/darwin-arm64/tish +0 -0
  52. package/platform/darwin-x64/tish +0 -0
  53. package/platform/linux-arm64/tish +0 -0
  54. package/platform/linux-x64/tish +0 -0
  55. package/platform/win32-x64/tish.exe +0 -0
@@ -215,15 +215,39 @@ pub fn substr(s: &Value, start: &Value, length: &Value) -> Value {
215
215
  }
216
216
 
217
217
  pub fn split(s: &Value, sep: &Value) -> Value {
218
+ split_limit(s, sep, None)
219
+ }
220
+
221
+ /// `String.prototype.split(sep, limit)` for a string separator. JS semantics: the string is split
222
+ /// completely on `sep`, then the result is truncated to `limit` elements (it does NOT keep the
223
+ /// unsplit remainder in the last slot, which is what Rust's `splitn` would do). `limit == 0` yields
224
+ /// an empty array. This is the single source of truth shared by the VM and rust/cranelift/wasi
225
+ /// backends; the interpreter mirrors it in `tish_eval::regex::string_split`.
226
+ pub fn split_limit(s: &Value, sep: &Value, limit: Option<usize>) -> Value {
218
227
  if let Value::String(s) = s {
219
228
  let separator = match sep {
220
229
  Value::String(ss) => ss.as_str(),
221
230
  _ => return Value::Array(VmRef::new(vec![Value::String(s.clone())])),
222
231
  };
223
- let parts: Vec<Value> = s
224
- .split(separator)
225
- .map(|p| Value::String(p.into()))
226
- .collect();
232
+ if limit == Some(0) {
233
+ return Value::Array(VmRef::new(Vec::new()));
234
+ }
235
+ // JS `split("")` is special: it yields the string's characters with NO surrounding empties
236
+ // (`"xyz".split("")` → `["x","y","z"]`, `"".split("")` → `[]`), unlike Rust's `str::split("")`
237
+ // which emits leading/trailing `""`. (tish splits on `char`s; lone-surrogate behavior for
238
+ // astral code points is out of scope.) #247
239
+ if separator.is_empty() {
240
+ let mut parts: Vec<Value> =
241
+ s.chars().map(|c| Value::String(c.to_string().into())).collect();
242
+ if let Some(max) = limit {
243
+ parts.truncate(max);
244
+ }
245
+ return Value::Array(VmRef::new(parts));
246
+ }
247
+ let mut parts: Vec<Value> = s.split(separator).map(|p| Value::String(p.into())).collect();
248
+ if let Some(max) = limit {
249
+ parts.truncate(max);
250
+ }
227
251
  Value::Array(VmRef::new(parts))
228
252
  } else {
229
253
  Value::Null
@@ -364,6 +388,24 @@ pub fn char_at(s: &Value, idx: &Value) -> Value {
364
388
  }
365
389
  }
366
390
 
391
+ /// `String.prototype.at(index)` — like `charAt` but negative `index` counts from the end and an
392
+ /// out-of-range index yields `null` (JS `undefined`), not `""`. #247
393
+ pub fn at(s: &Value, index: &Value) -> Value {
394
+ if let Value::String(s) = s {
395
+ let i = match index {
396
+ Value::Number(n) => *n as i64,
397
+ _ => 0,
398
+ };
399
+ let chars: Vec<char> = s.chars().collect();
400
+ let len = chars.len() as i64;
401
+ let idx = if i < 0 { len + i } else { i };
402
+ if idx >= 0 && idx < len {
403
+ return Value::String(chars[idx as usize].to_string().into());
404
+ }
405
+ }
406
+ Value::Null
407
+ }
408
+
367
409
  pub fn char_code_at(s: &Value, idx: &Value) -> Value {
368
410
  if let Value::String(s) = s {
369
411
  let idx = match idx {
@@ -541,6 +583,28 @@ mod tests {
541
583
  assert_same!(trim(&n(1.0)), Value::Null);
542
584
  }
543
585
 
586
+ #[test]
587
+ fn split_limit_js_semantics() {
588
+ let parts = |v: &Value| -> Vec<String> {
589
+ let Value::Array(a) = v else { panic!("not array") };
590
+ a.borrow()
591
+ .iter()
592
+ .map(|x| match x {
593
+ Value::String(s) => s.to_string(),
594
+ _ => panic!("not string"),
595
+ })
596
+ .collect()
597
+ };
598
+ // limit truncates to the first N pieces (does NOT keep the remainder, unlike `splitn`)
599
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), Some(2))), ["a", "b"]);
600
+ // limit 0 -> empty; limit beyond piece count -> full split; no limit -> full split
601
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), Some(0))).len(), 0);
602
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), Some(10))), ["a", "b", "c", "d"]);
603
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), None)), ["a", "b", "c", "d"]);
604
+ // split() delegates with no limit
605
+ assert_eq!(parts(&split(&s("one two"), &s(" "))), ["one", "two"]);
606
+ }
607
+
544
608
  #[test]
545
609
  fn case_and_prefix_suffix() {
546
610
  assert_same!(to_upper_case(&s("aB")), s("AB"));
@@ -136,7 +136,14 @@ fn construct(kind: Kind, args: &[Value]) -> Value {
136
136
  /// construction-only-coercion gap for this one view). Any op without a packed fast path materialises
137
137
  /// it back to a boxed array, so every array method keeps working.
138
138
  pub fn float64_array_packed(args: &[Value]) -> Value {
139
- if !Value::packed_arrays_enabled() {
139
+ float64_array_with(Value::packed_arrays_enabled(), args)
140
+ }
141
+
142
+ /// `float64_array_packed` with the packed/boxed choice made explicit, so tests exercise both paths
143
+ /// without toggling the now-cached process env flag (#166). `packed = false` is byte-identical to
144
+ /// the generic boxed `Value::Array` constructor.
145
+ fn float64_array_with(packed: bool, args: &[Value]) -> Value {
146
+ if !packed {
140
147
  // Byte-identical fallback to the generic boxed `Value::Array` backing.
141
148
  return construct(Kind::F64, args);
142
149
  }
@@ -261,25 +268,26 @@ mod tests {
261
268
  assert_eq!(nums(&v), vec![0.0, 1.0]);
262
269
  }
263
270
 
264
- // `float64_array_packed` toggles on a process-global env var. No other test in this crate reads
265
- // `packed_arrays_enabled`, so the set/remove here can't perturb a concurrent test; we restore the
266
- // default (off) on exit regardless.
271
+ // The packed/boxed selection is passed explicitly via `float64_array_with`, so this exercises
272
+ // both paths without touching the now-cached process env flag (#166) no env mutation, no
273
+ // mid-test toggle race, parallel-safe.
267
274
  #[test]
268
275
  fn float64_packed_respects_flag() {
269
- // Flag off (default): byte-identical boxed `Value::Array` fallback, no packed value produced.
270
- std::env::remove_var("TISH_PACKED_ARRAYS");
271
- let boxed = float64_array_packed(&[Value::Number(3.0)]);
276
+ // Packed off (the default): byte-identical boxed `Value::Array` fallback.
277
+ let boxed = float64_array_with(false, &[Value::Number(3.0)]);
272
278
  assert!(matches!(boxed, Value::Array(_)), "packed-off must return boxed Array");
273
279
  assert_eq!(nums(&boxed), vec![0.0, 0.0, 0.0]);
274
280
 
275
- // Flag on: packed `Value::NumberArray`. F64 needs no coercion (exact), non-numeric → NaN
281
+ // Packed on: `Value::NumberArray`. F64 needs no coercion (exact), non-numeric → NaN
276
282
  // (matching the boxed `from_values(F64, …)`), and the length form zero-fills.
277
- std::env::set_var("TISH_PACKED_ARRAYS", "1");
278
- let packed = float64_array_packed(&[Value::Array(VmRef::new(vec![
279
- Value::Number(1.1),
280
- Value::Number(2.2),
281
- Value::Null,
282
- ]))]);
283
+ let packed = float64_array_with(
284
+ true,
285
+ &[Value::Array(VmRef::new(vec![
286
+ Value::Number(1.1),
287
+ Value::Number(2.2),
288
+ Value::Null,
289
+ ]))],
290
+ );
283
291
  match &packed {
284
292
  Value::NumberArray(a) => {
285
293
  let v = a.borrow();
@@ -290,9 +298,8 @@ mod tests {
290
298
  _ => panic!("packed-on must return NumberArray"),
291
299
  }
292
300
  assert!(matches!(
293
- float64_array_packed(&[Value::Number(2.0)]),
301
+ float64_array_with(true, &[Value::Number(2.0)]),
294
302
  Value::NumberArray(_)
295
303
  ));
296
- std::env::remove_var("TISH_PACKED_ARRAYS");
297
304
  }
298
305
  }
@@ -157,7 +157,7 @@ fn expr_is_param_only(e: &Expr, params: &HashSet<&str>) -> bool {
157
157
  ArrayElement::Spread(_) => false,
158
158
  }),
159
159
  Expr::Object { props, .. } => props.iter().all(|p| match p {
160
- ObjectProp::KeyValue(_, x) => expr_is_param_only(x, params),
160
+ ObjectProp::KeyValue(_, x, _) => expr_is_param_only(x, params),
161
161
  ObjectProp::Spread(_) => false,
162
162
  }),
163
163
  Expr::TemplateLiteral { exprs, .. } => {
@@ -332,7 +332,7 @@ fn expr_rebinds(e: &Expr, name: &str) -> bool {
332
332
  ArrayElement::Expr(e) | ArrayElement::Spread(e) => expr_rebinds(e, name),
333
333
  }),
334
334
  Expr::Object { props, .. } => props.iter().any(|p| match p {
335
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => expr_rebinds(e, name),
335
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => expr_rebinds(e, name),
336
336
  }),
337
337
  Expr::MemberAssign { object, value, .. } => expr_rebinds(object, name) || expr_rebinds(value, name),
338
338
  Expr::IndexAssign { object, index, value, .. } => {
@@ -452,7 +452,7 @@ impl SlotScan {
452
452
  ArrayElement::Expr(e) | ArrayElement::Spread(e) => self.expr(e, in_closure),
453
453
  }),
454
454
  Expr::Object { props, .. } => props.iter().all(|p| match p {
455
- ObjectProp::KeyValue(_, e) | ObjectProp::Spread(e) => self.expr(e, in_closure),
455
+ ObjectProp::KeyValue(_, e, _) | ObjectProp::Spread(e) => self.expr(e, in_closure),
456
456
  }),
457
457
  Expr::Assign { name, value, .. }
458
458
  | Expr::CompoundAssign { name, value, .. }
@@ -836,6 +836,53 @@ impl<'a> Compiler<'a> {
836
836
  self.chunk.code[patch_pos + 1] = bytes[1];
837
837
  }
838
838
 
839
+ /// Compile `cond` in CONDITION position (if / while / for / do-while). The operand stack is EMPTY
840
+ /// at every emitted `JumpIfFalse` — `&&` / `||` lower to pure control flow rather than the
841
+ /// value-producing `Dup` + `BinOp` form, so a hot numeric loop whose only disqualifier was the
842
+ /// condition shape stays eligible for the cranelift JIT (#167). Returns the `JumpIfFalse` patch
843
+ /// sites the caller must point at its "condition is false" target. The condition value itself is
844
+ /// never observed here, so truthiness-only lowering is exact; short-circuit order is preserved.
845
+ fn compile_condition_jump_if_false(
846
+ &mut self,
847
+ cond: &Expr,
848
+ ) -> Result<Vec<usize>, CompileError> {
849
+ match cond {
850
+ // `a && b` is false if EITHER operand is false — test each in order; both exit to the
851
+ // same false-target, so concatenate their patch sites.
852
+ Expr::Binary {
853
+ left,
854
+ op: BinOp::And,
855
+ right,
856
+ ..
857
+ } => {
858
+ let mut patches = self.compile_condition_jump_if_false(left)?;
859
+ patches.extend(self.compile_condition_jump_if_false(right)?);
860
+ Ok(patches)
861
+ }
862
+ // `a || b`: a truthy left makes the condition hold (skip the right); only the right's
863
+ // falsiness exits.
864
+ Expr::Binary {
865
+ left,
866
+ op: BinOp::Or,
867
+ right,
868
+ ..
869
+ } => {
870
+ self.compile_expr(left)?;
871
+ let take_right = self.emit_jump(Opcode::JumpIfFalse); // left falsy → evaluate right
872
+ let done = self.emit_jump(Opcode::Jump); // left truthy → condition holds
873
+ self.patch_jump(take_right, self.chunk.code.len());
874
+ let patches = self.compile_condition_jump_if_false(right)?;
875
+ self.patch_jump(done, self.chunk.code.len());
876
+ Ok(patches)
877
+ }
878
+ // Any other expression: evaluate it; one JumpIfFalse consumes the value.
879
+ _ => {
880
+ self.compile_expr(cond)?;
881
+ Ok(vec![self.emit_jump(Opcode::JumpIfFalse)])
882
+ }
883
+ }
884
+ }
885
+
839
886
  /// Patch a JumpBack operand: distance from the IP after this insn back to `target`.
840
887
  /// `patch_pos` is the first byte of the u16 operand (same as [`Self::emit_jump_back`]'s return value).
841
888
  fn patch_jump_back(&mut self, patch_pos: usize, target: usize) {
@@ -1194,11 +1241,13 @@ impl<'a> Compiler<'a> {
1194
1241
  else_branch,
1195
1242
  ..
1196
1243
  } => {
1197
- self.compile_expr(cond)?;
1198
- let jump_else = self.emit_jump(Opcode::JumpIfFalse);
1244
+ let jump_else_sites = self.compile_condition_jump_if_false(cond)?;
1199
1245
  self.compile_statement(then_branch)?;
1200
1246
  let jump_end = self.emit_jump(Opcode::Jump);
1201
- self.patch_jump(jump_else, self.chunk.code.len());
1247
+ let else_target = self.chunk.code.len();
1248
+ for site in &jump_else_sites {
1249
+ self.patch_jump(*site, else_target);
1250
+ }
1202
1251
  if let Some(else_s) = else_branch {
1203
1252
  self.compile_statement(else_s)?;
1204
1253
  }
@@ -1222,14 +1271,16 @@ impl<'a> Compiler<'a> {
1222
1271
  self.breakable_stack.push(Breakable::Loop {
1223
1272
  unwind_depth: self.block_depth,
1224
1273
  });
1225
- self.compile_expr(cond)?;
1226
- let jump_out = self.emit_jump(Opcode::JumpIfFalse);
1227
- // JumpIfFalse already pops condition when taking body
1274
+ // Condition-position lowering: empty stack at each JumpIfFalse keeps the loop
1275
+ // JIT-eligible (#167). Each returned site exits to `end`.
1276
+ let jump_out_sites = self.compile_condition_jump_if_false(cond)?;
1228
1277
  self.compile_statement(body)?;
1229
1278
  let jump_back_dist = (self.chunk.code.len() + 3).saturating_sub(start);
1230
1279
  self.emit_u16(Opcode::JumpBack, jump_back_dist as u16);
1231
1280
  let end = self.chunk.code.len();
1232
- self.patch_jump(jump_out, end);
1281
+ for site in &jump_out_sites {
1282
+ self.patch_jump(*site, end);
1283
+ }
1233
1284
  let info = self.loop_stack.pop().unwrap();
1234
1285
  self.breakable_stack.pop();
1235
1286
  for p in info.continue_patches {
@@ -1266,14 +1317,16 @@ impl<'a> Compiler<'a> {
1266
1317
  self.emit_u16(Opcode::LoopVarsBegin, idx);
1267
1318
  }
1268
1319
  let cond_start = self.chunk.code.len();
1269
- if let Some(c) = cond {
1270
- self.compile_expr(c)?;
1320
+ // Condition-position lowering keeps the loop JIT-eligible (#167). The absent-condition
1321
+ // `for (;;)` keeps its `true` constant + single exit jump.
1322
+ let jump_out_sites = if let Some(c) = cond {
1323
+ self.compile_condition_jump_if_false(c)?
1271
1324
  } else {
1272
1325
  let idx = self.constant_idx(Constant::Bool(true));
1273
1326
  self.emit(Opcode::LoadConst);
1274
1327
  self.chunk.write_u16(idx);
1275
- }
1276
- let jump_out = self.emit_jump(Opcode::JumpIfFalse);
1328
+ vec![self.emit_jump(Opcode::JumpIfFalse)]
1329
+ };
1277
1330
  self.loop_stack.push(LoopInfo {
1278
1331
  break_patches: Vec::new(),
1279
1332
  continue_patches: Vec::new(),
@@ -1295,7 +1348,9 @@ impl<'a> Compiler<'a> {
1295
1348
  let jump_back_dist = (self.chunk.code.len() + 3).saturating_sub(cond_start);
1296
1349
  self.emit_u16(Opcode::JumpBack, jump_back_dist as u16);
1297
1350
  let end = self.chunk.code.len();
1298
- self.patch_jump(jump_out, end);
1351
+ for site in &jump_out_sites {
1352
+ self.patch_jump(*site, end);
1353
+ }
1299
1354
  for p in info.break_patches {
1300
1355
  self.patch_jump(p, end);
1301
1356
  }
@@ -1561,23 +1616,32 @@ impl<'a> Compiler<'a> {
1561
1616
  self.loop_stack.push(LoopInfo {
1562
1617
  break_patches: Vec::new(),
1563
1618
  continue_patches: Vec::new(),
1564
- continue_is_forward_jump: false,
1619
+ // `continue` jumps to the condition test, which is emitted AFTER the body — a
1620
+ // FORWARD jump. (A backward JumpBack would patch to dist = 0 via saturating_sub
1621
+ // of a forward target, becoming a no-op; execution then fell through and re-ran
1622
+ // the body's already-unwound ExitBlock on an empty block stack — the
1623
+ // "ExitBlock without matching EnterBlock" crash.)
1624
+ continue_is_forward_jump: true,
1565
1625
  });
1566
1626
  self.breakable_stack.push(Breakable::Loop {
1567
1627
  unwind_depth: self.block_depth,
1568
1628
  });
1569
1629
  self.compile_statement(body)?;
1570
1630
  let cond_start = self.chunk.code.len();
1571
- self.compile_expr(cond)?;
1572
- let jump_back = self.emit_jump(Opcode::JumpIfFalse);
1631
+ // Condition-position lowering: a false condition exits to `end`, a true one falls
1632
+ // through to the JumpBack — JIT-eligible, no value left on the stack (#167).
1633
+ let exit_sites = self.compile_condition_jump_if_false(cond)?;
1573
1634
  let jump_back_dist = (self.chunk.code.len() + 3).saturating_sub(start);
1574
1635
  self.emit_u16(Opcode::JumpBack, jump_back_dist as u16);
1575
1636
  let end = self.chunk.code.len();
1576
- self.patch_jump(jump_back, end);
1637
+ for site in &exit_sites {
1638
+ self.patch_jump(*site, end);
1639
+ }
1577
1640
  let info = self.loop_stack.pop().unwrap();
1578
1641
  self.breakable_stack.pop();
1579
1642
  for p in info.continue_patches {
1580
- self.patch_jump_back(p, cond_start);
1643
+ // Forward jump to the condition (see continue_is_forward_jump above).
1644
+ self.patch_jump(p, cond_start);
1581
1645
  }
1582
1646
  for p in info.break_patches {
1583
1647
  self.patch_jump(p, end);
@@ -1859,14 +1923,16 @@ impl<'a> Compiler<'a> {
1859
1923
  } => {
1860
1924
  match op {
1861
1925
  BinOp::And => {
1862
- // Short-circuit: a && b => if !a then a else b
1926
+ // Short-circuit + value-returning (JS): `a && b` is `a` when `a` is falsy,
1927
+ // else `b` — NOT a coerced boolean (#240). Mirror the `||` lowering below:
1928
+ // keep the left operand on a falsy short-circuit, discard it and evaluate the
1929
+ // right otherwise. (The old `BinOp(And)` here coerced a truthy-left result to
1930
+ // a Bool, so `five() && 7` yielded `true` instead of `7`.)
1863
1931
  self.compile_expr(left)?;
1864
1932
  self.emit(Opcode::Dup);
1865
- let jump_shortcut = self.emit_jump(Opcode::JumpIfFalse);
1866
- self.compile_expr(right)?; // left still on stack from Dup
1867
- self.emit_u8(Opcode::BinOp, binop_to_u8(BinOp::And));
1868
- let jump_end = self.emit_jump(Opcode::Jump);
1869
- self.patch_jump(jump_shortcut, self.chunk.code.len());
1933
+ let jump_end = self.emit_jump(Opcode::JumpIfFalse); // a falsy → keep a
1934
+ self.emit(Opcode::Pop); // a truthy discard a
1935
+ self.compile_expr(right)?; // … and yield b
1870
1936
  self.patch_jump(jump_end, self.chunk.code.len());
1871
1937
  }
1872
1938
  BinOp::Or => {
@@ -2123,7 +2189,7 @@ impl<'a> Compiler<'a> {
2123
2189
  self.emit_u16(Opcode::NewObject, 0); // start with {}
2124
2190
  for prop in props {
2125
2191
  match prop {
2126
- ObjectProp::KeyValue(k, v) => {
2192
+ ObjectProp::KeyValue(k, v, _) => {
2127
2193
  let idx = self.constant_idx(Constant::String(Arc::clone(k)));
2128
2194
  self.emit(Opcode::LoadConst);
2129
2195
  self.chunk.write_u16(idx);
@@ -2139,7 +2205,7 @@ impl<'a> Compiler<'a> {
2139
2205
  }
2140
2206
  } else {
2141
2207
  for prop in props {
2142
- if let ObjectProp::KeyValue(k, v) = prop {
2208
+ if let ObjectProp::KeyValue(k, v, _) = prop {
2143
2209
  let idx = self.constant_idx(Constant::String(Arc::clone(k)));
2144
2210
  self.emit(Opcode::LoadConst);
2145
2211
  self.chunk.write_u16(idx);
@@ -42,3 +42,22 @@ fn mutation_vm_ast_opt_with_peephole() {
42
42
  let chunk = compile(&prog).expect("compile");
43
43
  run(&chunk).expect("VM");
44
44
  }
45
+
46
+ // Regression: `continue` inside a `do { } while (cond)` targets the condition test, which is emitted
47
+ // AFTER the body — a FORWARD jump. It used to be lowered as a backward `JumpBack`, which `patch_jump_back`
48
+ // resolved to distance 0 (saturating_sub of a forward target) — a no-op. Execution then fell through into
49
+ // the body block's already-unwound `ExitBlock`, crashing the VM with "ExitBlock without matching
50
+ // EnterBlock". The `continue` here unwinds the body block AND the `if` then-block, so it exercises the
51
+ // multi-ExitBlock unwind path specifically.
52
+ #[test]
53
+ fn do_while_continue_does_not_crash_vm() {
54
+ let src = "let d = 0\n\
55
+ do {\n\
56
+ d = d + 1\n\
57
+ if (d === 2) { continue }\n\
58
+ } while (d < 5)\n";
59
+ let prog = tishlang_parser::parse(src).expect("parse");
60
+ // Both compile paths must run clean (the crash happened regardless of peephole optimization).
61
+ run(&compile_unoptimized(&prog).expect("compile (unopt)")).expect("VM run unoptimized");
62
+ run(&compile(&prog).expect("compile (opt)")).expect("VM run optimized");
63
+ }
@@ -590,7 +590,7 @@ impl CheckCtx {
590
590
  let mut fields = Vec::new();
591
591
  for p in props {
592
592
  match p {
593
- ObjectProp::KeyValue(k, v) => {
593
+ ObjectProp::KeyValue(k, v, _) => {
594
594
  let t = self.synth(v)?;
595
595
  fields.push((k.clone(), t));
596
596
  }