@tishlang/tish 2.12.0 → 2.16.13

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 (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.exe +0 -0
@@ -11,23 +11,77 @@ use tishlang_builtins::helpers::make_error_value;
11
11
 
12
12
  pub use tishlang_builtins::symbol::symbol_object;
13
13
  pub use tishlang_core::ObjectMap;
14
+ // `ObjectData`/`PropMap` are the concrete object representation. Re-exported so the
15
+ // native codegen can build an object literal's `PropMap` directly (pre-sized, single
16
+ // pass) instead of materializing an intermediate `AHashMap` and rebuilding from it.
17
+ pub use tishlang_core::{ObjectData, PropMap};
14
18
  pub use tishlang_core::Value;
15
19
  pub use tishlang_core::ArcStr;
16
20
  /// Used by native codegen for `f()` / `obj()` dispatch (`Value::Function` or `__call` on objects).
17
- pub use tishlang_core::value_call;
21
+ /// Wraps `tishlang_core::value_call` with a pending-throw suppression check (#381): while a thrown
22
+ /// value is unwinding toward its checkpoint, calling anything would run side effects JS semantics
23
+ /// say must not happen after a throw (e.g. `console.log(f(x))` printing the NaN sentinel a tripped
24
+ /// recursion guard unwound with). One thread-local read on a path already dominated by boxing.
25
+ pub fn value_call(callee: &Value, args: &[Value]) -> Value {
26
+ if has_pending_throw() {
27
+ return Value::Null;
28
+ }
29
+ tishlang_core::value_call(callee, args)
30
+ }
18
31
  /// JS ToInt32/ToUint32 for the emitted bitwise/shift code (modulo 2³², NaN/±Infinity → 0).
19
32
  pub use tishlang_core::{
20
33
  to_int32, to_int32_value, to_number_value, to_uint32, to_uint32_value,
21
34
  };
35
+ /// ECMAScript `Number::toString`, appended straight into a buffer — for in-place string building
36
+ /// (`s += n`) in emitted code, so a number append needs no throwaway `String`.
37
+ pub use tishlang_core::js_number_to_string_into;
38
+
39
+ /// Append `v`'s JS string-concatenation form directly to `buf` (no intermediate `String` for the
40
+ /// common scalar cases). Used by emitted `s += rhs` / `s = s + rhs` so a string-builder loop stays
41
+ /// allocation-light. Numbers go through `js_number_to_string_into` (JS-correct, ryu-backed), so a
42
+ /// concatenated float matches Node (`String(1e21)` → `"1e+21"`), which the old `n.to_string()`
43
+ /// Display path did not.
44
+ #[inline]
45
+ pub fn push_value_str(buf: &mut String, v: &Value) {
46
+ match v {
47
+ Value::String(s) => buf.push_str(s),
48
+ Value::Number(n) => js_number_to_string_into(buf, *n),
49
+ Value::Bool(b) => buf.push_str(if *b { "true" } else { "false" }),
50
+ Value::Null => buf.push_str("null"),
51
+ other => buf.push_str(&other.to_js_string()),
52
+ }
53
+ }
22
54
  // Re-export the shared-mutable wrapper so the Rust code emitted by
23
55
  // `tishlang_compile::codegen` can write `VmRef::new(...)` without needing
24
56
  // a direct dependency on `tishlang_core` from the generated crate.
25
57
  pub use tishlang_core::{VmReadGuard, VmRef, VmWriteGuard};
26
58
 
59
+ /// #218 — read a captured `VmRef` cell's value, releasing the lock guard BEFORE returning.
60
+ ///
61
+ /// Generated native code reads captured cells in *expression position* (e.g. `current.dim +
62
+ /// current.fg` lowers to two reads of `current` inside one `+`). The old inline form
63
+ /// `(*cell.borrow()).clone()` puts the `borrow()` guard in a *temporary*, whose lifetime extends to
64
+ /// the end of the enclosing statement — so two reads of the SAME cell in one expression hold two
65
+ /// guards at once. Under the `send-values` build a `VmRef` is an `Arc<Mutex<T>>` and `borrow()` is a
66
+ /// non-reentrant `Mutex::lock()`, so that second lock self-deadlocks (the process sleeps at 0% CPU).
67
+ /// Cloning inside this fn drops the guard at the `return`, so repeated reads of one cell lock
68
+ /// strictly sequentially. Behaviour is identical to `(*cell.borrow()).clone()` otherwise.
69
+ #[inline]
70
+ pub fn vm_read<T: Clone>(cell: &VmRef<T>) -> T {
71
+ (*cell.borrow()).clone()
72
+ }
73
+
27
74
  /// `for…of` iterable normalization for the native backend: a JS iterator object (one with a
28
75
  /// callable `next()` returning `{ value, done }`, e.g. a `Map`/`Set` `.values()` result) is
29
76
  /// drained into a `Value::Array`; arrays, strings, and everything else pass through unchanged.
30
77
  pub fn normalize_for_of(v: Value) -> Value {
78
+ // A packed `NumberArray` (e.g. a module-const f64 array) must box to a plain `Value::Array` so the
79
+ // single-variant `if let Value::Array` in the spread / for-of codegen binds — otherwise it spreads
80
+ // to zero elements.
81
+ if let Value::NumberArray(arr) = &v {
82
+ let items: Vec<Value> = arr.borrow().iter().map(|n| Value::Number(*n)).collect();
83
+ return Value::Array(VmRef::new(items));
84
+ }
31
85
  match tishlang_core::drain_iterator(&v) {
32
86
  Some(items) => Value::Array(VmRef::new(items)),
33
87
  None => v,
@@ -53,8 +107,8 @@ pub use tishlang_builtins::typedarrays::{
53
107
  };
54
108
  pub use tishlang_builtins::date::date_constructor_value as tish_date_constructor;
55
109
  pub use tishlang_builtins::collections::{
56
- collection_size, map_constructor_value as tish_map_constructor,
57
- set_constructor_value as tish_set_constructor,
110
+ collection_size, map_constructor_value as tish_map_constructor, map_get,
111
+ map_has, map_set, map_values, set_constructor_value as tish_set_constructor,
58
112
  };
59
113
 
60
114
  // Re-export array methods from tishlang_builtins
@@ -65,9 +119,10 @@ pub use tishlang_builtins::array::{
65
119
  for_each as array_for_each, includes as array_includes_impl, index_of as array_index_of_impl,
66
120
  join as array_join_impl, map as array_map, pop as array_pop, push as array_push_impl,
67
121
  fill as array_fill, reduce as array_reduce, reverse as array_reverse, shift as array_shift,
68
- shuffle as array_shuffle, slice as array_slice_impl, some as array_some,
69
- sort_default as array_sort_default, sort_numeric_asc as array_sort_numeric_asc,
70
- sort_numeric_desc as array_sort_numeric_desc,
122
+ shuffle as array_shuffle, slice as array_slice_impl, snapshot_values as array_snapshot_values,
123
+ as_f64_snapshot as array_as_f64_snapshot, some as array_some,
124
+ sort_by_keys as array_sort_by_keys, sort_default as array_sort_default,
125
+ sort_numeric_asc as array_sort_numeric_asc, sort_numeric_desc as array_sort_numeric_desc,
71
126
  sort_with_comparator as array_sort_with_comparator, splice as array_splice_impl,
72
127
  unshift as array_unshift_impl,
73
128
  };
@@ -195,6 +250,126 @@ pub fn value_at(recv: &Value, idx: &Value) -> Value {
195
250
  pub fn string_char_code_at(s: &Value, idx: &Value) -> Value {
196
251
  string_char_code_at_impl(s, idx)
197
252
  }
253
+
254
+ // ── #317: typed-`String` (RustType::String) receiver fast paths ───────────────────────────────
255
+ //
256
+ // When native codegen knows the receiver of `s.charCodeAt(i)` / `s.charAt(i)` / `s.at(i)` / `s[i]`
257
+ // / `s.length` is a Rust `String` local (the `TISH_NATIVE_OPT` default), it can BORROW the string
258
+ // (`s.as_str()`) instead of deep-cloning it into a fresh `Value::String(ArcStr)` on every call. The
259
+ // boxed path's `s.clone().into()` is an O(n) copy of the whole string per call — O(n²) in a strided
260
+ // scan loop. These `&str` entry points eliminate that per-call copy.
261
+ //
262
+ // Indexing is by Unicode SCALAR (code point), byte-identical to `s.chars().nth(i)` /
263
+ // `s.chars().count()` — the same semantics as the boxed builtins (`tishlang_builtins::string`).
264
+ // `chars().nth(idx)` is O(idx) per call (acceptable: the win is removing the O(n) copy), versus the
265
+ // boxed cursor cache's O(1)-for-ASCII; correctness is identical either way.
266
+
267
+ #[inline]
268
+ fn idx_as_usize(idx: &Value) -> usize {
269
+ match idx {
270
+ Value::Number(n) => *n as usize,
271
+ _ => 0,
272
+ }
273
+ }
274
+
275
+ /// `&str` charCodeAt — code point at `idx` as f64; OOB → NaN. Mirrors `string::char_code_at`.
276
+ #[inline]
277
+ pub fn str_char_code_at(s: &str, idx: &Value) -> Value {
278
+ match s.chars().nth(idx_as_usize(idx)) {
279
+ Some(c) => Value::Number(c as u32 as f64),
280
+ None => Value::Number(f64::NAN),
281
+ }
282
+ }
283
+
284
+ /// `&str` charAt — 1-char string at `idx`; OOB → `""`. Mirrors `string::char_at`.
285
+ #[inline]
286
+ pub fn str_char_at(s: &str, idx: &Value) -> Value {
287
+ match s.chars().nth(idx_as_usize(idx)) {
288
+ Some(c) => Value::String(c.to_string().into()),
289
+ None => Value::String("".into()),
290
+ }
291
+ }
292
+
293
+ /// `&str` String.prototype.at — negative `idx` counts from the end; OOB → null. Mirrors `string::at`.
294
+ #[inline]
295
+ pub fn str_at(s: &str, idx: &Value) -> Value {
296
+ let i = match idx {
297
+ Value::Number(n) => *n as i64,
298
+ _ => 0,
299
+ };
300
+ let resolved = if i < 0 {
301
+ s.chars().count() as i64 + i
302
+ } else {
303
+ i
304
+ };
305
+ if resolved >= 0 {
306
+ if let Some(c) = s.chars().nth(resolved as usize) {
307
+ return Value::String(c.to_string().into());
308
+ }
309
+ }
310
+ Value::Null
311
+ }
312
+
313
+ // ── O(1) char-slice forms: the loop-hoisted scan path ─────────────────────────────────────────
314
+ //
315
+ // `str_char_code_at` & friends index a `&str` via `chars().nth(i)` = O(i), so a strided scan
316
+ // (`for i in 0..s.length { s.charCodeAt(i) }`) is O(n²). When native codegen proves the String is
317
+ // loop-invariant it collects it ONCE into a `Vec<char>` and routes per-iteration accesses here, so
318
+ // each index is O(1) (scan → O(n)). Semantics are byte-identical to the `&str` forms: same
319
+ // `idx_as_usize`, `chars.len()` == `s.chars().count()`, `chars.get(i)` == `s.chars().nth(i)`.
320
+
321
+ /// O(1) char-slice `charCodeAt` — loop-hoisted [`str_char_code_at`]. OOB → NaN.
322
+ #[inline]
323
+ pub fn slice_char_code_at(chars: &[char], idx: &Value) -> Value {
324
+ match chars.get(idx_as_usize(idx)) {
325
+ Some(c) => Value::Number(*c as u32 as f64),
326
+ None => Value::Number(f64::NAN),
327
+ }
328
+ }
329
+
330
+ /// O(1) char-slice `charAt` — loop-hoisted [`str_char_at`]. OOB → `""`.
331
+ #[inline]
332
+ pub fn slice_char_at(chars: &[char], idx: &Value) -> Value {
333
+ match chars.get(idx_as_usize(idx)) {
334
+ Some(c) => Value::String(c.to_string().into()),
335
+ None => Value::String("".into()),
336
+ }
337
+ }
338
+
339
+ /// O(1) char-slice `at` (negative-index aware) — loop-hoisted [`str_at`]. OOB → null.
340
+ #[inline]
341
+ pub fn slice_at(chars: &[char], idx: &Value) -> Value {
342
+ let i = match idx {
343
+ Value::Number(n) => *n as i64,
344
+ _ => 0,
345
+ };
346
+ let resolved = if i < 0 { chars.len() as i64 + i } else { i };
347
+ if resolved >= 0 {
348
+ if let Some(c) = chars.get(resolved as usize) {
349
+ return Value::String(c.to_string().into());
350
+ }
351
+ }
352
+ Value::Null
353
+ }
354
+
355
+ /// `&str` `s[i]` — char at a non-negative integer `idx`; non-int / negative / OOB → null.
356
+ /// Mirrors the `Value::String` arm of [`get_index`].
357
+ #[inline]
358
+ pub fn str_index(s: &str, idx: &Value) -> Value {
359
+ match idx {
360
+ Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => match s.chars().nth(*n as usize) {
361
+ Some(c) => Value::String(c.to_string().into()),
362
+ None => Value::Null,
363
+ },
364
+ _ => Value::Null,
365
+ }
366
+ }
367
+
368
+ /// `&str` `.length` — Unicode scalar (code point) count as f64. Mirrors `string::char_count`.
369
+ #[inline]
370
+ pub fn str_char_count(s: &str) -> f64 {
371
+ s.chars().count() as f64
372
+ }
198
373
  pub fn string_repeat(s: &Value, count: &Value) -> Value {
199
374
  string_repeat_impl(s, count)
200
375
  }
@@ -233,69 +408,72 @@ pub mod ops {
233
408
  use tishlang_core::Value;
234
409
 
235
410
  #[inline]
236
- pub fn add(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
411
+ // Arithmetic ops return a bare `Value` (not `Result`): they NEVER error — a non-number
412
+ // operand coerces to NaN, matching the VM's `eval_binop`. The former `Result<Value, Box<dyn
413
+ // Error>>` was vestigial and cost ~4× on the boxed path (memory-return + caller `.unwrap_or`;
414
+ // #201 measurement). Callers use the value directly (no suffix — see `ops_result_suffix`).
415
+ pub fn add(left: &Value, right: &Value) -> Value {
237
416
  match (left, right) {
238
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a + b)),
417
+ (Value::Number(a), Value::Number(b)) => Value::Number(a + b),
239
418
  (Value::String(a), Value::String(b)) => {
240
419
  let mut s = String::with_capacity(a.len() + b.len());
241
420
  s.push_str(a);
242
421
  s.push_str(b);
243
- Ok(Value::String(s.into()))
422
+ Value::String(s.into())
244
423
  }
245
424
  (Value::String(a), b) => {
246
425
  let b_str = b.to_js_string();
247
426
  let mut s = String::with_capacity(a.len() + b_str.len());
248
427
  s.push_str(a);
249
428
  s.push_str(&b_str);
250
- Ok(Value::String(s.into()))
429
+ Value::String(s.into())
251
430
  }
252
431
  (a, Value::String(b)) => {
253
432
  let a_str = a.to_js_string();
254
433
  let mut s = String::with_capacity(a_str.len() + b.len());
255
434
  s.push_str(&a_str);
256
435
  s.push_str(b);
257
- Ok(Value::String(s.into()))
436
+ Value::String(s.into())
258
437
  }
259
- // Neither operand is a string here ⇒ numeric coercion, matching the VM's `eval_binop`
260
- // (`as_number().unwrap_or(NaN)`): a null/bool/object operand (e.g. an out-of-bounds array
261
- // read) coerces to NaN, so `number + null` is NaN NOT an error that the codegen's
262
- // `.unwrap_or(Value::Null)` would silently turn into `null` (the old rust-AOT divergence).
263
- (a, b) => Ok(Value::Number(
438
+ // Neither operand is a string ⇒ numeric coercion, matching the VM's `eval_binop`
439
+ // (`as_number().unwrap_or(NaN)`): a null/bool/object operand (e.g. an out-of-bounds
440
+ // array read) coerces to NaN, so `number + null` is NaN. Never an error.
441
+ (a, b) => Value::Number(
264
442
  a.as_number().unwrap_or(f64::NAN) + b.as_number().unwrap_or(f64::NAN),
265
- )),
443
+ ),
266
444
  }
267
445
  }
268
446
 
269
447
  #[inline]
270
- pub fn sub(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
448
+ pub fn sub(left: &Value, right: &Value) -> Value {
271
449
  match (left, right) {
272
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a - b)),
450
+ (Value::Number(a), Value::Number(b)) => Value::Number(a - b),
273
451
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
274
- (a, b) => Ok(Value::Number(
452
+ (a, b) => Value::Number(
275
453
  a.as_number().unwrap_or(f64::NAN) - b.as_number().unwrap_or(f64::NAN),
276
- )),
454
+ ),
277
455
  }
278
456
  }
279
457
 
280
458
  #[inline]
281
- pub fn mul(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
459
+ pub fn mul(left: &Value, right: &Value) -> Value {
282
460
  match (left, right) {
283
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a * b)),
461
+ (Value::Number(a), Value::Number(b)) => Value::Number(a * b),
284
462
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
285
- (a, b) => Ok(Value::Number(
463
+ (a, b) => Value::Number(
286
464
  a.as_number().unwrap_or(f64::NAN) * b.as_number().unwrap_or(f64::NAN),
287
- )),
465
+ ),
288
466
  }
289
467
  }
290
468
 
291
469
  #[inline]
292
- pub fn div(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
470
+ pub fn div(left: &Value, right: &Value) -> Value {
293
471
  match (left, right) {
294
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a / b)),
472
+ (Value::Number(a), Value::Number(b)) => Value::Number(a / b),
295
473
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
296
- (a, b) => Ok(Value::Number(
474
+ (a, b) => Value::Number(
297
475
  a.as_number().unwrap_or(f64::NAN) / b.as_number().unwrap_or(f64::NAN),
298
- )),
476
+ ),
299
477
  }
300
478
  }
301
479
 
@@ -341,13 +519,13 @@ pub mod ops {
341
519
  }
342
520
 
343
521
  #[inline]
344
- pub fn modulo(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
522
+ pub fn modulo(left: &Value, right: &Value) -> Value {
345
523
  match (left, right) {
346
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a % b)),
524
+ (Value::Number(a), Value::Number(b)) => Value::Number(a % b),
347
525
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
348
- (a, b) => Ok(Value::Number(
526
+ (a, b) => Value::Number(
349
527
  a.as_number().unwrap_or(f64::NAN) % b.as_number().unwrap_or(f64::NAN),
350
- )),
528
+ ),
351
529
  }
352
530
  }
353
531
  }
@@ -370,7 +548,12 @@ use tishlang_core::{json_parse as core_json_parse, json_stringify as core_json_s
370
548
  /// from the runtime keeps the generated source decoupled from
371
549
  /// `tishlang_core` — generated code only ever names `tishlang_runtime`.
372
550
  pub mod json {
551
+ pub use tishlang_core::json_parse;
373
552
  pub use tishlang_core::json_stringify_into as stringify_into;
553
+ /// JS-correct number→JSON writer (NaN/Inf→`null`, integer fast path, else the
554
+ /// ECMAScript `Number::toString`). Used by codegen-emitted per-struct serialisers
555
+ /// (#315) for `number` fields, so their output matches `json_stringify_into` exactly.
556
+ pub use tishlang_core::write_json_number;
374
557
  /// Append the JSON-escaped contents of `s` (without surrounding
375
558
  /// quotes) to `buf`. Used by typed-struct serialisers for `String`
376
559
  /// fields. Falls through to `tishlang_core::json_stringify_into`'s
@@ -431,14 +614,107 @@ impl fmt::Display for TishError {
431
614
 
432
615
  impl std::error::Error for TishError {}
433
616
 
617
+ // #303 — the pending-throw slot lives in `tishlang_core` so the shared array builtins
618
+ // (`tishlang_builtins::array`) can poll it without a `builtins -> runtime` dependency cycle, and so
619
+ // the VM shares the same slot. Re-export the accessors so the Rust emitted by `tishlang_compile`
620
+ // keeps calling `tishlang_runtime::{set,has,take}_pending_throw`. See `tishlang_core` for the docs.
621
+ pub use tishlang_core::{has_pending_throw, set_pending_throw, take_pending_throw};
622
+
623
+ // #381 — the recursion-guard plumbing for generated native code. The depth counter lives in
624
+ // `tishlang_core` beside the pending-throw slot; the entry point generated code uses is the
625
+ // wrapper below, which adds the stack-pressure check the counter alone can't provide.
626
+ pub use tishlang_core::{stack_overflow_error, CallDepthGuard};
627
+
628
+ /// #381 — enter a boxed user-fn call frame, or trip the recursion guard. Emitted at the top of
629
+ /// every generated user-fn closure. Trips on EITHER limit: the counted ceiling
630
+ /// (`TISH_MAX_CALL_DEPTH`, default 20000 — parity with the interp/VM guards) or real stack
631
+ /// pressure ([`stack_low`]) — boxed native frames are large enough that 20000 of them can exceed
632
+ /// the stack before the counter does (the VM sidesteps this with `stacker::maybe_grow`; generated
633
+ /// code has no stack growth, so pressure must be its own trigger). On trip: parks the catchable
634
+ /// `RangeError` and returns `None`; the closure returns its dummy `Value::Null` and the throw
635
+ /// surfaces at the caller's pending-throw checkpoint.
636
+ #[inline]
637
+ pub fn enter_call_guarded() -> Option<CallDepthGuard> {
638
+ if stack_low() {
639
+ set_pending_throw(stack_overflow_error());
640
+ return None;
641
+ }
642
+ tishlang_core::enter_call_guarded()
643
+ }
644
+
645
+ #[cfg(not(target_family = "wasm"))]
646
+ thread_local! {
647
+ // The current thread's bail floor for guarded self-recursive native fns: the stack address below
648
+ // which recursion must stop (real stack bottom + headroom margin). 0 = not yet initialized.
649
+ // Thread-local because every thread's stack occupies a different address range. #381
650
+ static STACK_FLOOR: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
651
+ }
652
+
653
+ /// Headroom left below the deepest allowed recursion level (#381): must cover the levels emitted
654
+ /// between two guard checks (the rotation window), the bail path, and building/raising the
655
+ /// `RangeError`. Mirrors the VM JIT tier's margin (`RECUR_STACK_MARGIN`).
656
+ #[cfg(not(target_family = "wasm"))]
657
+ const RECUR_STACK_MARGIN: usize = 256 * 1024;
658
+
659
+ /// #381 — is the current thread's stack nearly exhausted? Emitted by the native backend at the
660
+ /// entry of self-recursive typed fns (every K-th rotation copy), so unbounded numeric recursion
661
+ /// bails into a catchable `RangeError` instead of overflowing the stack (an uncatchable abort).
662
+ /// One thread-local read + pointer compare after first init; the margin is capped at half the
663
+ /// remaining stack so a first call on an already-deep stack can't false-trip (limit stays below SP).
664
+ #[cfg(not(target_family = "wasm"))]
665
+ #[inline]
666
+ pub fn stack_low() -> bool {
667
+ let anchor = 0u8;
668
+ let sp = &anchor as *const u8 as usize;
669
+ STACK_FLOOR.with(|c| {
670
+ let mut floor = c.get();
671
+ if floor == 0 {
672
+ // `None` (unknown bounds) → floor 1: SP can never be below it, guard never trips —
673
+ // same do-no-harm fallback as the VM JIT tier.
674
+ floor = match stacker::remaining_stack() {
675
+ Some(rem) => {
676
+ let margin = RECUR_STACK_MARGIN.min(rem / 2);
677
+ sp.saturating_sub(rem).saturating_add(margin).max(1)
678
+ }
679
+ None => 1,
680
+ };
681
+ c.set(floor);
682
+ }
683
+ sp < floor
684
+ })
685
+ }
686
+
687
+ /// Wasm: the sandbox traps on overflow (contained by design), and `stacker` has no wasm support —
688
+ /// the guard compiles to a constant `false` so the emitted check folds away.
689
+ #[cfg(target_family = "wasm")]
690
+ #[inline]
691
+ pub fn stack_low() -> bool {
692
+ false
693
+ }
694
+
695
+ /// #381 — the bail path for a tripped typed-fn recursion guard: park the catchable `RangeError`
696
+ /// and return the NaN sentinel the f64 frame unwinds with. Typed native fns are pure numeric
697
+ /// (no side effects), so the NaN propagates harmlessly until the first `Value`/`Result` frame's
698
+ /// pending-throw checkpoint raises the error.
699
+ #[cold]
700
+ #[inline(never)]
701
+ pub fn recursion_tripped_f64() -> f64 {
702
+ set_pending_throw(stack_overflow_error());
703
+ f64::NAN
704
+ }
705
+
434
706
  /// Function-boundary unwind: convert a completion that escaped a function body's `Result`-closure
435
- /// back into the function's `Value`. A `return v` yields `v`; an uncaught `throw` panics (matching
436
- /// the behavior of a throw with no enclosing `try`); any other error panics.
707
+ /// back into the function's `Value`. A `return v` yields `v`; an uncaught `throw` is stored in the
708
+ /// pending-throw slot and the fn escapes with a dummy `Value` so the throw keeps propagating to the
709
+ /// caller's post-call check (#303); any other error panics.
437
710
  pub fn fn_unwind(e: Box<dyn std::error::Error>) -> Value {
438
711
  match e.downcast::<TishError>() {
439
712
  Ok(te) => match *te {
440
713
  TishError::Return(v) => v,
441
- TishError::Throw(v) => panic!("uncaught throw: {}", v.to_display_string()),
714
+ TishError::Throw(v) => {
715
+ set_pending_throw(v);
716
+ Value::Null
717
+ }
442
718
  },
443
719
  Err(orig) => panic!("error in native Tish: {:?}", orig),
444
720
  }
@@ -696,6 +972,48 @@ pub fn process_exec(args: &[Value]) -> Value {
696
972
  }
697
973
  }
698
974
 
975
+ /// `process.execFile(program, [args])` — run a program directly, WITHOUT a shell (no `sh -c`). Each
976
+ /// argument is passed to the program verbatim, so shell metacharacters in untrusted argument data are
977
+ /// never interpreted — the safe counterpart to `exec` when arguments derive from input (#384). Returns
978
+ /// the exit code, like `exec`.
979
+ #[cfg(feature = "process")]
980
+ pub fn process_exec_file(args: &[Value]) -> Value {
981
+ use std::process::Command;
982
+ let program = args
983
+ .first()
984
+ .map(|v| v.to_display_string())
985
+ .unwrap_or_default();
986
+ if program.is_empty() {
987
+ return Value::Number(0.0);
988
+ }
989
+ let argv: Vec<String> = match args.get(1) {
990
+ Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_display_string()).collect(),
991
+ _ => Vec::new(),
992
+ };
993
+ match Command::new(&program).args(&argv).status() {
994
+ Ok(status) => Value::Number(status.code().unwrap_or(1) as f64),
995
+ Err(_) => Value::Number(1.0),
996
+ }
997
+ }
998
+
999
+ #[cfg(all(test, feature = "process", unix))]
1000
+ mod execfile_tests_384 {
1001
+ use super::process_exec_file;
1002
+ use tishlang_core::{Value, VmRef};
1003
+
1004
+ #[test]
1005
+ fn execfile_runs_without_shell_and_returns_exit_code() {
1006
+ // Runs the program directly (no `sh -c`); `true`/`false`/`echo` exist on unix.
1007
+ assert!(matches!(process_exec_file(&[Value::String("true".into())]), Value::Number(n) if n == 0.0));
1008
+ assert!(matches!(process_exec_file(&[Value::String("false".into())]), Value::Number(n) if n == 1.0));
1009
+ // args are passed verbatim as a Value::Array.
1010
+ let args = Value::Array(VmRef::new(vec![Value::String("ok".into())]));
1011
+ assert!(matches!(process_exec_file(&[Value::String("echo".into()), args]), Value::Number(n) if n == 0.0));
1012
+ // empty program is a no-op returning 0, matching `exec`.
1013
+ assert!(matches!(process_exec_file(&[]), Value::Number(n) if n == 0.0));
1014
+ }
1015
+ }
1016
+
699
1017
  #[cfg(feature = "fs")]
700
1018
  pub fn read_file(args: &[Value]) -> Value {
701
1019
  let path = args
@@ -791,6 +1109,92 @@ pub fn mkdir(args: &[Value]) -> Value {
791
1109
 
792
1110
  use std::sync::Arc;
793
1111
 
1112
+ /// Per-site polymorphic inline cache for `obj.<literal key>` reads in generated native code (#179).
1113
+ ///
1114
+ /// The native backend otherwise pays a full dynamic lookup (RefCell/Mutex borrow + linear key scan)
1115
+ /// for every member read, even at sites that only ever see a few object shapes. `PropMap` already
1116
+ /// tracks a hidden-class [`ShapeId`] per object and exposes slot-indexed access, so once a
1117
+ /// `(shape, slot)` pair is observed at a site, a later object of the same shape resolves the property
1118
+ /// with one integer compare + a direct slot load instead of a key scan.
1119
+ ///
1120
+ /// Each of the 8 entries packs `(shape: u32) << 32 | (slot: u32)` into a single `AtomicU64`, so the
1121
+ /// pair is read and written atomically — no torn `(shape, slot)` under concurrent access (e.g. an
1122
+ /// HTTP handler shared across worker threads). A 9th distinct shape evicts an entry round-robin. An
1123
+ /// empty entry is `0`; a real object never has `EMPTY_SHAPE` (0), so `0` can't be a false hit.
1124
+ pub struct PropIC {
1125
+ entries: [std::sync::atomic::AtomicU64; 8],
1126
+ next: std::sync::atomic::AtomicU32,
1127
+ }
1128
+
1129
+ impl PropIC {
1130
+ #[allow(clippy::declare_interior_mutable_const)] // each `static IC: PropIC = PropIC::new()` site needs its own cell
1131
+ pub const fn new() -> Self {
1132
+ use std::sync::atomic::{AtomicU32, AtomicU64};
1133
+ PropIC {
1134
+ entries: [
1135
+ AtomicU64::new(0),
1136
+ AtomicU64::new(0),
1137
+ AtomicU64::new(0),
1138
+ AtomicU64::new(0),
1139
+ AtomicU64::new(0),
1140
+ AtomicU64::new(0),
1141
+ AtomicU64::new(0),
1142
+ AtomicU64::new(0),
1143
+ ],
1144
+ next: AtomicU32::new(0),
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ impl Default for PropIC {
1150
+ fn default() -> Self {
1151
+ Self::new()
1152
+ }
1153
+ }
1154
+
1155
+ /// Cached `obj.key` read — see [`PropIC`]. Behaviour is identical to [`get_prop`]: only objects with
1156
+ /// a stable hidden class (non-empty, non-dictionary) take the cached fast path; empty/dictionary
1157
+ /// objects, non-objects, and the special `size` key all fall through to `get_prop` (the borrow is
1158
+ /// released first, so the fallback's re-borrow can't self-deadlock the send-values `Mutex`). The
1159
+ /// caller (codegen) never emits this for the `size` key, but the fallback covers it regardless.
1160
+ #[inline]
1161
+ pub fn get_prop_ic(obj: &Value, key: &str, ic: &PropIC) -> Value {
1162
+ use std::sync::atomic::Ordering;
1163
+ if key != "size" {
1164
+ if let Value::Object(map) = obj {
1165
+ // Scope the borrow so it is released before the `get_prop` fallback below.
1166
+ {
1167
+ let b = map.borrow();
1168
+ let shape = b.strings.shape();
1169
+ if shape != tishlang_core::EMPTY_SHAPE && shape != tishlang_core::DICT_SHAPE {
1170
+ let shape_hi = (shape as u64) << 32;
1171
+ // Fast path: a cached entry whose shape matches gives the slot directly.
1172
+ for e in ic.entries.iter() {
1173
+ let packed = e.load(Ordering::Relaxed);
1174
+ if packed >> 32 == shape as u64 {
1175
+ if let Some(v) = b.strings.value_at_index((packed & 0xFFFF_FFFF) as usize)
1176
+ {
1177
+ return v.clone();
1178
+ }
1179
+ }
1180
+ }
1181
+ // Miss: resolve once, fill an entry round-robin with the packed (shape, slot).
1182
+ return match b.strings.get_with_index(key) {
1183
+ Some((v, i)) => {
1184
+ let k = (ic.next.fetch_add(1, Ordering::Relaxed) % 8) as usize;
1185
+ ic.entries[k].store(shape_hi | i as u64, Ordering::Relaxed);
1186
+ v.clone()
1187
+ }
1188
+ None => Value::Null,
1189
+ };
1190
+ }
1191
+ // empty / dictionary object → fall through (borrow dropped here)
1192
+ }
1193
+ }
1194
+ }
1195
+ get_prop(obj, key)
1196
+ }
1197
+
794
1198
  #[inline]
795
1199
  pub fn get_prop(obj: &Value, key: impl AsRef<str>) -> Value {
796
1200
  let key = key.as_ref();
@@ -832,7 +1236,7 @@ pub fn get_prop(obj: &Value, key: impl AsRef<str>) -> Value {
832
1236
  }
833
1237
  Value::String(s) => {
834
1238
  if key == "length" {
835
- Value::Number(s.chars().count() as f64)
1239
+ Value::Number(tishlang_builtins::string::char_count(s) as f64)
836
1240
  } else {
837
1241
  Value::Null
838
1242
  }
@@ -885,6 +1289,15 @@ pub fn get_prop(obj: &Value, key: impl AsRef<str>) -> Value {
885
1289
  }
886
1290
  _ => Value::Null,
887
1291
  },
1292
+ // Reading a property of the nullish value is a JS TypeError. PARK a catchable throw (#425)
1293
+ // and return the null sentinel; it surfaces at the next pending-throw checkpoint. This is the
1294
+ // ONLY receiver that throws — a number/bool/function with no such property reads back `null`
1295
+ // (JS `undefined`), matching the VM/interpreter/node. Free for valid reads: `Object` is matched
1296
+ // first, so this cold arm never runs on the hot path.
1297
+ Value::Null => {
1298
+ tishlang_core::set_pending_throw(tishlang_core::cannot_read_property_error(key));
1299
+ Value::Null
1300
+ }
888
1301
  _ => Value::Null,
889
1302
  }
890
1303
  }
@@ -910,11 +1323,11 @@ pub fn get_index(obj: &Value, index: &Value) -> Value {
910
1323
  // `str[i]` returns the character at index `i` (issue #17) — matches the VM /
911
1324
  // interpreter; out-of-bounds / negative / non-integer indices yield null.
912
1325
  Value::String(s) => match index {
913
- Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => s
914
- .chars()
915
- .nth(*n as usize)
916
- .map(|c| Value::String(c.to_string().into()))
917
- .unwrap_or(Value::Null),
1326
+ Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => {
1327
+ tishlang_builtins::string::nth_char(s, *n as usize)
1328
+ .map(|c| Value::String(c.to_string().into()))
1329
+ .unwrap_or(Value::Null)
1330
+ }
918
1331
  _ => Value::Null,
919
1332
  },
920
1333
  Value::Object(_) => tishlang_core::object_get(obj, index).unwrap_or(Value::Null),
@@ -925,6 +1338,16 @@ pub fn get_index(obj: &Value, index: &Value) -> Value {
925
1338
  Value::String(k) => get_prop(obj, k.as_str()),
926
1339
  _ => Value::Null,
927
1340
  },
1341
+ // Indexing the nullish value throws a catchable TypeError (#425), like `get_prop` above —
1342
+ // parked and surfaced at the next checkpoint. Every other receiver reads back `null`.
1343
+ Value::Null => {
1344
+ let key = match index {
1345
+ Value::String(s) => s.to_string(),
1346
+ other => other.to_js_string(),
1347
+ };
1348
+ tishlang_core::set_pending_throw(tishlang_core::cannot_read_property_error(&key));
1349
+ Value::Null
1350
+ }
928
1351
  _ => Value::Null,
929
1352
  }
930
1353
  }
@@ -1502,3 +1925,61 @@ pub fn string_search_regex(s: &Value, regexp: &Value) -> Value {
1502
1925
  _ => Value::Number(-1.0),
1503
1926
  }
1504
1927
  }
1928
+
1929
+ #[cfg(test)]
1930
+ mod null_read_parking_tests_425 {
1931
+ // Reading a property/index of the nullish value PARKS a catchable TypeError (#425) instead of
1932
+ // silently reading back `null` — the native/runtime read paths. The throw surfaces at the caller's
1933
+ // next pending-throw checkpoint. Every other receiver (number/bool/valid object/array) reads back
1934
+ // a value with NO parked throw.
1935
+ use super::{get_index, get_prop};
1936
+ use tishlang_core::{has_pending_throw, take_pending_throw, Value, VmRef};
1937
+
1938
+ fn parked_name() -> Option<String> {
1939
+ take_pending_throw().and_then(|v| {
1940
+ if let Value::Object(o) = v {
1941
+ if let Some(Value::String(s)) = o.borrow().strings.get("name") {
1942
+ return Some(s.to_string());
1943
+ }
1944
+ }
1945
+ None
1946
+ })
1947
+ }
1948
+
1949
+ #[test]
1950
+ fn get_prop_on_null_parks_type_error() {
1951
+ let _ = take_pending_throw();
1952
+ let r = get_prop(&Value::Null, "length");
1953
+ assert!(matches!(r, Value::Null));
1954
+ assert!(has_pending_throw());
1955
+ assert_eq!(parked_name().as_deref(), Some("TypeError"));
1956
+ }
1957
+
1958
+ #[test]
1959
+ fn get_index_on_null_parks_type_error() {
1960
+ let _ = take_pending_throw();
1961
+ let r = get_index(&Value::Null, &Value::Number(0.0));
1962
+ assert!(matches!(r, Value::Null));
1963
+ assert!(has_pending_throw());
1964
+ assert_eq!(parked_name().as_deref(), Some("TypeError"));
1965
+ }
1966
+
1967
+ #[test]
1968
+ fn valid_reads_do_not_park() {
1969
+ let _ = take_pending_throw();
1970
+ // object property, array index, and array length must NOT park a throw.
1971
+ let obj = Value::object({
1972
+ let mut m = tishlang_core::ObjectMap::default();
1973
+ m.insert(std::sync::Arc::from("a"), Value::Number(42.0));
1974
+ m
1975
+ });
1976
+ assert!(matches!(get_prop(&obj, "a"), Value::Number(n) if n == 42.0));
1977
+ assert!(!has_pending_throw(), "valid property read must not park");
1978
+ let arr = Value::Array(VmRef::new(vec![Value::Number(7.0)]));
1979
+ assert!(matches!(get_index(&arr, &Value::Number(0.0)), Value::Number(n) if n == 7.0));
1980
+ assert!(!has_pending_throw(), "valid index read must not park");
1981
+ // a MISSING object property reads back null WITHOUT parking (JS `undefined`, not a throw).
1982
+ assert!(matches!(get_prop(&obj, "missing"), Value::Null));
1983
+ assert!(!has_pending_throw(), "missing property must not park");
1984
+ }
1985
+ }