@tishlang/tish-lsp 2.12.0 → 2.35.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 (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-lsp +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/src/lib.rs +94 -1
  60. package/crates/tish_lexer/src/lib.rs +76 -0
  61. package/crates/tish_lexer/src/token.rs +4 -0
  62. package/crates/tish_lint/src/lib.rs +126 -0
  63. package/crates/tish_lsp/Cargo.toml +1 -1
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-lsp +0 -0
  85. package/platform/darwin-x64/tish-lsp +0 -0
  86. package/platform/linux-arm64/tish-lsp +0 -0
  87. package/platform/linux-x64/tish-lsp +0 -0
  88. package/platform/win32-x64/tish-lsp.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
@@ -64,17 +118,23 @@ pub use tishlang_builtins::array::{
64
118
  find_last_index as array_find_last_index, flat as array_flat_impl, flat_map as array_flat_map,
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
- 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,
121
+ fill as array_fill, last_index_of as array_last_index_of, copy_within as array_copy_within,
122
+ reduce as array_reduce, reduce_right as array_reduce_right, reverse as array_reverse,
123
+ keys as array_keys, values as array_values, entries as array_entries, shift as array_shift,
124
+ shuffle as array_shuffle, slice as array_slice_impl, snapshot_values as array_snapshot_values,
125
+ as_f64_snapshot as array_as_f64_snapshot, some as array_some,
126
+ sort_by_keys as array_sort_by_keys, sort_default as array_sort_default,
127
+ sort_numeric_asc as array_sort_numeric_asc, sort_numeric_desc as array_sort_numeric_desc,
71
128
  sort_with_comparator as array_sort_with_comparator, splice as array_splice_impl,
129
+ to_reversed as array_to_reversed, to_sorted as array_to_sorted,
130
+ to_spliced as array_to_spliced, with as array_with,
72
131
  unshift as array_unshift_impl,
73
132
  };
74
133
 
75
134
  // Re-export string methods from tishlang_builtins
76
135
  pub use tishlang_builtins::string::{
77
136
  at as string_at_impl, char_at as string_char_at_impl, char_code_at as string_char_code_at_impl,
137
+ code_point_at as string_code_point_at,
78
138
  ends_with as string_ends_with_impl, escape_html as string_escape_html_impl,
79
139
  includes as string_includes_impl, index_of as string_index_of_impl,
80
140
  last_index_of as string_last_index_of_impl, pad_end as string_pad_end_impl,
@@ -83,8 +143,9 @@ pub use tishlang_builtins::string::{
83
143
  slice as string_slice_impl, split as string_split_impl, split_limit as string_split_limit_impl,
84
144
  starts_with as string_starts_with_impl, substr as string_substr_impl,
85
145
  substring as string_substring_impl,
86
- to_lower_case as string_to_lower_case, to_upper_case as string_to_upper_case,
87
- trim as string_trim,
146
+ normalize as string_normalize, to_lower_case as string_to_lower_case,
147
+ to_upper_case as string_to_upper_case, trim as string_trim, trim_end as string_trim_end,
148
+ trim_start as string_trim_start,
88
149
  };
89
150
 
90
151
  // Wrapper functions to maintain API compatibility
@@ -94,8 +155,8 @@ pub fn array_push(arr: &Value, args: &[Value]) -> Value {
94
155
  pub fn array_unshift(arr: &Value, args: &[Value]) -> Value {
95
156
  array_unshift_impl(arr, args)
96
157
  }
97
- pub fn array_index_of(arr: &Value, search: &Value) -> Value {
98
- array_index_of_impl(arr, search)
158
+ pub fn array_index_of(arr: &Value, search: &Value, from: Option<&Value>) -> Value {
159
+ array_index_of_impl(arr, search, from)
99
160
  }
100
161
  pub fn array_includes(arr: &Value, search: &Value, from: &Value) -> Value {
101
162
  array_includes_impl(arr, search, Some(from))
@@ -162,11 +223,11 @@ pub fn string_split_limit(s: &Value, sep: &Value, limit: &Value) -> Value {
162
223
  }
163
224
  string_split_limit_impl(s, sep, max)
164
225
  }
165
- pub fn string_starts_with(s: &Value, search: &Value) -> Value {
166
- string_starts_with_impl(s, search)
226
+ pub fn string_starts_with(s: &Value, search: &Value, position: Option<&Value>) -> Value {
227
+ string_starts_with_impl(s, search, position)
167
228
  }
168
- pub fn string_ends_with(s: &Value, search: &Value) -> Value {
169
- string_ends_with_impl(s, search)
229
+ pub fn string_ends_with(s: &Value, search: &Value, end_position: Option<&Value>) -> Value {
230
+ string_ends_with_impl(s, search, end_position)
170
231
  }
171
232
  pub fn string_replace(s: &Value, search: &Value, replacement: &Value) -> Value {
172
233
  #[cfg(feature = "regex")]
@@ -195,6 +256,126 @@ pub fn value_at(recv: &Value, idx: &Value) -> Value {
195
256
  pub fn string_char_code_at(s: &Value, idx: &Value) -> Value {
196
257
  string_char_code_at_impl(s, idx)
197
258
  }
259
+
260
+ // ── #317: typed-`String` (RustType::String) receiver fast paths ───────────────────────────────
261
+ //
262
+ // When native codegen knows the receiver of `s.charCodeAt(i)` / `s.charAt(i)` / `s.at(i)` / `s[i]`
263
+ // / `s.length` is a Rust `String` local (the `TISH_NATIVE_OPT` default), it can BORROW the string
264
+ // (`s.as_str()`) instead of deep-cloning it into a fresh `Value::String(ArcStr)` on every call. The
265
+ // boxed path's `s.clone().into()` is an O(n) copy of the whole string per call — O(n²) in a strided
266
+ // scan loop. These `&str` entry points eliminate that per-call copy.
267
+ //
268
+ // Indexing is by Unicode SCALAR (code point), byte-identical to `s.chars().nth(i)` /
269
+ // `s.chars().count()` — the same semantics as the boxed builtins (`tishlang_builtins::string`).
270
+ // `chars().nth(idx)` is O(idx) per call (acceptable: the win is removing the O(n) copy), versus the
271
+ // boxed cursor cache's O(1)-for-ASCII; correctness is identical either way.
272
+
273
+ #[inline]
274
+ fn idx_as_usize(idx: &Value) -> usize {
275
+ match idx {
276
+ Value::Number(n) => *n as usize,
277
+ _ => 0,
278
+ }
279
+ }
280
+
281
+ /// `&str` charCodeAt — code point at `idx` as f64; OOB → NaN. Mirrors `string::char_code_at`.
282
+ #[inline]
283
+ pub fn str_char_code_at(s: &str, idx: &Value) -> Value {
284
+ match s.chars().nth(idx_as_usize(idx)) {
285
+ Some(c) => Value::Number(c as u32 as f64),
286
+ None => Value::Number(f64::NAN),
287
+ }
288
+ }
289
+
290
+ /// `&str` charAt — 1-char string at `idx`; OOB → `""`. Mirrors `string::char_at`.
291
+ #[inline]
292
+ pub fn str_char_at(s: &str, idx: &Value) -> Value {
293
+ match s.chars().nth(idx_as_usize(idx)) {
294
+ Some(c) => Value::String(c.to_string().into()),
295
+ None => Value::String("".into()),
296
+ }
297
+ }
298
+
299
+ /// `&str` String.prototype.at — negative `idx` counts from the end; OOB → null. Mirrors `string::at`.
300
+ #[inline]
301
+ pub fn str_at(s: &str, idx: &Value) -> Value {
302
+ let i = match idx {
303
+ Value::Number(n) => *n as i64,
304
+ _ => 0,
305
+ };
306
+ let resolved = if i < 0 {
307
+ s.chars().count() as i64 + i
308
+ } else {
309
+ i
310
+ };
311
+ if resolved >= 0 {
312
+ if let Some(c) = s.chars().nth(resolved as usize) {
313
+ return Value::String(c.to_string().into());
314
+ }
315
+ }
316
+ Value::Null
317
+ }
318
+
319
+ // ── O(1) char-slice forms: the loop-hoisted scan path ─────────────────────────────────────────
320
+ //
321
+ // `str_char_code_at` & friends index a `&str` via `chars().nth(i)` = O(i), so a strided scan
322
+ // (`for i in 0..s.length { s.charCodeAt(i) }`) is O(n²). When native codegen proves the String is
323
+ // loop-invariant it collects it ONCE into a `Vec<char>` and routes per-iteration accesses here, so
324
+ // each index is O(1) (scan → O(n)). Semantics are byte-identical to the `&str` forms: same
325
+ // `idx_as_usize`, `chars.len()` == `s.chars().count()`, `chars.get(i)` == `s.chars().nth(i)`.
326
+
327
+ /// O(1) char-slice `charCodeAt` — loop-hoisted [`str_char_code_at`]. OOB → NaN.
328
+ #[inline]
329
+ pub fn slice_char_code_at(chars: &[char], idx: &Value) -> Value {
330
+ match chars.get(idx_as_usize(idx)) {
331
+ Some(c) => Value::Number(*c as u32 as f64),
332
+ None => Value::Number(f64::NAN),
333
+ }
334
+ }
335
+
336
+ /// O(1) char-slice `charAt` — loop-hoisted [`str_char_at`]. OOB → `""`.
337
+ #[inline]
338
+ pub fn slice_char_at(chars: &[char], idx: &Value) -> Value {
339
+ match chars.get(idx_as_usize(idx)) {
340
+ Some(c) => Value::String(c.to_string().into()),
341
+ None => Value::String("".into()),
342
+ }
343
+ }
344
+
345
+ /// O(1) char-slice `at` (negative-index aware) — loop-hoisted [`str_at`]. OOB → null.
346
+ #[inline]
347
+ pub fn slice_at(chars: &[char], idx: &Value) -> Value {
348
+ let i = match idx {
349
+ Value::Number(n) => *n as i64,
350
+ _ => 0,
351
+ };
352
+ let resolved = if i < 0 { chars.len() as i64 + i } else { i };
353
+ if resolved >= 0 {
354
+ if let Some(c) = chars.get(resolved as usize) {
355
+ return Value::String(c.to_string().into());
356
+ }
357
+ }
358
+ Value::Null
359
+ }
360
+
361
+ /// `&str` `s[i]` — char at a non-negative integer `idx`; non-int / negative / OOB → null.
362
+ /// Mirrors the `Value::String` arm of [`get_index`].
363
+ #[inline]
364
+ pub fn str_index(s: &str, idx: &Value) -> Value {
365
+ match idx {
366
+ Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => match s.chars().nth(*n as usize) {
367
+ Some(c) => Value::String(c.to_string().into()),
368
+ None => Value::Null,
369
+ },
370
+ _ => Value::Null,
371
+ }
372
+ }
373
+
374
+ /// `&str` `.length` — Unicode scalar (code point) count as f64. Mirrors `string::char_count`.
375
+ #[inline]
376
+ pub fn str_char_count(s: &str) -> f64 {
377
+ s.chars().count() as f64
378
+ }
198
379
  pub fn string_repeat(s: &Value, count: &Value) -> Value {
199
380
  string_repeat_impl(s, count)
200
381
  }
@@ -216,6 +397,12 @@ pub fn string_last_index_of(s: &Value, search: &Value, position: &Value) -> Valu
216
397
  pub fn number_to_fixed(n: &Value, digits: &Value) -> Value {
217
398
  tishlang_builtins::number::to_fixed(n, digits)
218
399
  }
400
+ pub fn number_to_exponential(n: &Value, digits: &Value) -> Value {
401
+ tishlang_builtins::number::to_exponential(n, digits)
402
+ }
403
+ pub fn number_to_precision(n: &Value, precision: &Value) -> Value {
404
+ tishlang_builtins::number::to_precision(n, precision)
405
+ }
219
406
 
220
407
  /// `.toString([radix])` for the compiled backend (issue #59). A number receiver uses the
221
408
  /// shared radix formatter so it stays byte-identical with the VM / interpreter; any other
@@ -233,69 +420,72 @@ pub mod ops {
233
420
  use tishlang_core::Value;
234
421
 
235
422
  #[inline]
236
- pub fn add(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
423
+ // Arithmetic ops return a bare `Value` (not `Result`): they NEVER error — a non-number
424
+ // operand coerces to NaN, matching the VM's `eval_binop`. The former `Result<Value, Box<dyn
425
+ // Error>>` was vestigial and cost ~4× on the boxed path (memory-return + caller `.unwrap_or`;
426
+ // #201 measurement). Callers use the value directly (no suffix — see `ops_result_suffix`).
427
+ pub fn add(left: &Value, right: &Value) -> Value {
237
428
  match (left, right) {
238
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a + b)),
429
+ (Value::Number(a), Value::Number(b)) => Value::Number(a + b),
239
430
  (Value::String(a), Value::String(b)) => {
240
431
  let mut s = String::with_capacity(a.len() + b.len());
241
432
  s.push_str(a);
242
433
  s.push_str(b);
243
- Ok(Value::String(s.into()))
434
+ Value::String(s.into())
244
435
  }
245
436
  (Value::String(a), b) => {
246
437
  let b_str = b.to_js_string();
247
438
  let mut s = String::with_capacity(a.len() + b_str.len());
248
439
  s.push_str(a);
249
440
  s.push_str(&b_str);
250
- Ok(Value::String(s.into()))
441
+ Value::String(s.into())
251
442
  }
252
443
  (a, Value::String(b)) => {
253
444
  let a_str = a.to_js_string();
254
445
  let mut s = String::with_capacity(a_str.len() + b.len());
255
446
  s.push_str(&a_str);
256
447
  s.push_str(b);
257
- Ok(Value::String(s.into()))
448
+ Value::String(s.into())
258
449
  }
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(
450
+ // Neither operand is a string ⇒ numeric coercion, matching the VM's `eval_binop`
451
+ // (`as_number().unwrap_or(NaN)`): a null/bool/object operand (e.g. an out-of-bounds
452
+ // array read) coerces to NaN, so `number + null` is NaN. Never an error.
453
+ (a, b) => Value::Number(
264
454
  a.as_number().unwrap_or(f64::NAN) + b.as_number().unwrap_or(f64::NAN),
265
- )),
455
+ ),
266
456
  }
267
457
  }
268
458
 
269
459
  #[inline]
270
- pub fn sub(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
460
+ pub fn sub(left: &Value, right: &Value) -> Value {
271
461
  match (left, right) {
272
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a - b)),
462
+ (Value::Number(a), Value::Number(b)) => Value::Number(a - b),
273
463
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
274
- (a, b) => Ok(Value::Number(
464
+ (a, b) => Value::Number(
275
465
  a.as_number().unwrap_or(f64::NAN) - b.as_number().unwrap_or(f64::NAN),
276
- )),
466
+ ),
277
467
  }
278
468
  }
279
469
 
280
470
  #[inline]
281
- pub fn mul(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
471
+ pub fn mul(left: &Value, right: &Value) -> Value {
282
472
  match (left, right) {
283
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a * b)),
473
+ (Value::Number(a), Value::Number(b)) => Value::Number(a * b),
284
474
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
285
- (a, b) => Ok(Value::Number(
475
+ (a, b) => Value::Number(
286
476
  a.as_number().unwrap_or(f64::NAN) * b.as_number().unwrap_or(f64::NAN),
287
- )),
477
+ ),
288
478
  }
289
479
  }
290
480
 
291
481
  #[inline]
292
- pub fn div(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
482
+ pub fn div(left: &Value, right: &Value) -> Value {
293
483
  match (left, right) {
294
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a / b)),
484
+ (Value::Number(a), Value::Number(b)) => Value::Number(a / b),
295
485
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
296
- (a, b) => Ok(Value::Number(
486
+ (a, b) => Value::Number(
297
487
  a.as_number().unwrap_or(f64::NAN) / b.as_number().unwrap_or(f64::NAN),
298
- )),
488
+ ),
299
489
  }
300
490
  }
301
491
 
@@ -341,13 +531,13 @@ pub mod ops {
341
531
  }
342
532
 
343
533
  #[inline]
344
- pub fn modulo(left: &Value, right: &Value) -> Result<Value, Box<dyn std::error::Error>> {
534
+ pub fn modulo(left: &Value, right: &Value) -> Value {
345
535
  match (left, right) {
346
- (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a % b)),
536
+ (Value::Number(a), Value::Number(b)) => Value::Number(a % b),
347
537
  // VM-parity numeric coercion (null/non-number → NaN), see `add`.
348
- (a, b) => Ok(Value::Number(
538
+ (a, b) => Value::Number(
349
539
  a.as_number().unwrap_or(f64::NAN) % b.as_number().unwrap_or(f64::NAN),
350
- )),
540
+ ),
351
541
  }
352
542
  }
353
543
  }
@@ -370,7 +560,12 @@ use tishlang_core::{json_parse as core_json_parse, json_stringify as core_json_s
370
560
  /// from the runtime keeps the generated source decoupled from
371
561
  /// `tishlang_core` — generated code only ever names `tishlang_runtime`.
372
562
  pub mod json {
563
+ pub use tishlang_core::json_parse;
373
564
  pub use tishlang_core::json_stringify_into as stringify_into;
565
+ /// JS-correct number→JSON writer (NaN/Inf→`null`, integer fast path, else the
566
+ /// ECMAScript `Number::toString`). Used by codegen-emitted per-struct serialisers
567
+ /// (#315) for `number` fields, so their output matches `json_stringify_into` exactly.
568
+ pub use tishlang_core::write_json_number;
374
569
  /// Append the JSON-escaped contents of `s` (without surrounding
375
570
  /// quotes) to `buf`. Used by typed-struct serialisers for `String`
376
571
  /// fields. Falls through to `tishlang_core::json_stringify_into`'s
@@ -431,14 +626,107 @@ impl fmt::Display for TishError {
431
626
 
432
627
  impl std::error::Error for TishError {}
433
628
 
629
+ // #303 — the pending-throw slot lives in `tishlang_core` so the shared array builtins
630
+ // (`tishlang_builtins::array`) can poll it without a `builtins -> runtime` dependency cycle, and so
631
+ // the VM shares the same slot. Re-export the accessors so the Rust emitted by `tishlang_compile`
632
+ // keeps calling `tishlang_runtime::{set,has,take}_pending_throw`. See `tishlang_core` for the docs.
633
+ pub use tishlang_core::{has_pending_throw, set_pending_throw, take_pending_throw};
634
+
635
+ // #381 — the recursion-guard plumbing for generated native code. The depth counter lives in
636
+ // `tishlang_core` beside the pending-throw slot; the entry point generated code uses is the
637
+ // wrapper below, which adds the stack-pressure check the counter alone can't provide.
638
+ pub use tishlang_core::{stack_overflow_error, CallDepthGuard};
639
+
640
+ /// #381 — enter a boxed user-fn call frame, or trip the recursion guard. Emitted at the top of
641
+ /// every generated user-fn closure. Trips on EITHER limit: the counted ceiling
642
+ /// (`TISH_MAX_CALL_DEPTH`, default 20000 — parity with the interp/VM guards) or real stack
643
+ /// pressure ([`stack_low`]) — boxed native frames are large enough that 20000 of them can exceed
644
+ /// the stack before the counter does (the VM sidesteps this with `stacker::maybe_grow`; generated
645
+ /// code has no stack growth, so pressure must be its own trigger). On trip: parks the catchable
646
+ /// `RangeError` and returns `None`; the closure returns its dummy `Value::Null` and the throw
647
+ /// surfaces at the caller's pending-throw checkpoint.
648
+ #[inline]
649
+ pub fn enter_call_guarded() -> Option<CallDepthGuard> {
650
+ if stack_low() {
651
+ set_pending_throw(stack_overflow_error());
652
+ return None;
653
+ }
654
+ tishlang_core::enter_call_guarded()
655
+ }
656
+
657
+ #[cfg(not(target_family = "wasm"))]
658
+ thread_local! {
659
+ // The current thread's bail floor for guarded self-recursive native fns: the stack address below
660
+ // which recursion must stop (real stack bottom + headroom margin). 0 = not yet initialized.
661
+ // Thread-local because every thread's stack occupies a different address range. #381
662
+ static STACK_FLOOR: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
663
+ }
664
+
665
+ /// Headroom left below the deepest allowed recursion level (#381): must cover the levels emitted
666
+ /// between two guard checks (the rotation window), the bail path, and building/raising the
667
+ /// `RangeError`. Mirrors the VM JIT tier's margin (`RECUR_STACK_MARGIN`).
668
+ #[cfg(not(target_family = "wasm"))]
669
+ const RECUR_STACK_MARGIN: usize = 256 * 1024;
670
+
671
+ /// #381 — is the current thread's stack nearly exhausted? Emitted by the native backend at the
672
+ /// entry of self-recursive typed fns (every K-th rotation copy), so unbounded numeric recursion
673
+ /// bails into a catchable `RangeError` instead of overflowing the stack (an uncatchable abort).
674
+ /// One thread-local read + pointer compare after first init; the margin is capped at half the
675
+ /// remaining stack so a first call on an already-deep stack can't false-trip (limit stays below SP).
676
+ #[cfg(not(target_family = "wasm"))]
677
+ #[inline]
678
+ pub fn stack_low() -> bool {
679
+ let anchor = 0u8;
680
+ let sp = &anchor as *const u8 as usize;
681
+ STACK_FLOOR.with(|c| {
682
+ let mut floor = c.get();
683
+ if floor == 0 {
684
+ // `None` (unknown bounds) → floor 1: SP can never be below it, guard never trips —
685
+ // same do-no-harm fallback as the VM JIT tier.
686
+ floor = match stacker::remaining_stack() {
687
+ Some(rem) => {
688
+ let margin = RECUR_STACK_MARGIN.min(rem / 2);
689
+ sp.saturating_sub(rem).saturating_add(margin).max(1)
690
+ }
691
+ None => 1,
692
+ };
693
+ c.set(floor);
694
+ }
695
+ sp < floor
696
+ })
697
+ }
698
+
699
+ /// Wasm: the sandbox traps on overflow (contained by design), and `stacker` has no wasm support —
700
+ /// the guard compiles to a constant `false` so the emitted check folds away.
701
+ #[cfg(target_family = "wasm")]
702
+ #[inline]
703
+ pub fn stack_low() -> bool {
704
+ false
705
+ }
706
+
707
+ /// #381 — the bail path for a tripped typed-fn recursion guard: park the catchable `RangeError`
708
+ /// and return the NaN sentinel the f64 frame unwinds with. Typed native fns are pure numeric
709
+ /// (no side effects), so the NaN propagates harmlessly until the first `Value`/`Result` frame's
710
+ /// pending-throw checkpoint raises the error.
711
+ #[cold]
712
+ #[inline(never)]
713
+ pub fn recursion_tripped_f64() -> f64 {
714
+ set_pending_throw(stack_overflow_error());
715
+ f64::NAN
716
+ }
717
+
434
718
  /// 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.
719
+ /// back into the function's `Value`. A `return v` yields `v`; an uncaught `throw` is stored in the
720
+ /// pending-throw slot and the fn escapes with a dummy `Value` so the throw keeps propagating to the
721
+ /// caller's post-call check (#303); any other error panics.
437
722
  pub fn fn_unwind(e: Box<dyn std::error::Error>) -> Value {
438
723
  match e.downcast::<TishError>() {
439
724
  Ok(te) => match *te {
440
725
  TishError::Return(v) => v,
441
- TishError::Throw(v) => panic!("uncaught throw: {}", v.to_display_string()),
726
+ TishError::Throw(v) => {
727
+ set_pending_throw(v);
728
+ Value::Null
729
+ }
442
730
  },
443
731
  Err(orig) => panic!("error in native Tish: {:?}", orig),
444
732
  }
@@ -524,6 +812,15 @@ pub fn decode_uri(args: &[Value]) -> Value {
524
812
  pub fn encode_uri(args: &[Value]) -> Value {
525
813
  builtins_encode_uri(args)
526
814
  }
815
+ pub fn encode_uri_component(args: &[Value]) -> Value {
816
+ tishlang_builtins::globals::encode_uri_component(args)
817
+ }
818
+ pub fn decode_uri_component(args: &[Value]) -> Value {
819
+ tishlang_builtins::globals::decode_uri_component(args)
820
+ }
821
+ pub fn structured_clone(args: &[Value]) -> Value {
822
+ tishlang_builtins::globals::structured_clone(args)
823
+ }
527
824
 
528
825
  // Math functions - use tishlang_builtins::math
529
826
  pub use tishlang_builtins::math::{
@@ -627,6 +924,14 @@ runtime_math_unary!(math_atanh, atanh);
627
924
  runtime_math_unary!(math_cbrt, cbrt);
628
925
  runtime_math_unary!(math_log2, log2);
629
926
  runtime_math_unary!(math_log10, log10);
927
+ runtime_math_unary!(math_expm1, exp_m1);
928
+ runtime_math_unary!(math_log1p, ln_1p);
929
+ pub fn math_clz32(args: &[Value]) -> Value {
930
+ tishlang_builtins::math::clz32(args)
931
+ }
932
+ pub fn math_fround(args: &[Value]) -> Value {
933
+ tishlang_builtins::math::fround(args)
934
+ }
630
935
 
631
936
  pub fn json_stringify(args: &[Value]) -> Value {
632
937
  let v = args.first().cloned().unwrap_or(Value::Null);
@@ -645,6 +950,18 @@ pub fn json_parse(args: &[Value]) -> Value {
645
950
  pub fn array_is_array(args: &[Value]) -> Value {
646
951
  builtins_array_is_array(args)
647
952
  }
953
+ pub fn array_of(args: &[Value]) -> Value {
954
+ tishlang_builtins::globals::array_of(args)
955
+ }
956
+ pub fn array_from(args: &[Value]) -> Value {
957
+ tishlang_builtins::globals::array_from(args)
958
+ }
959
+ pub fn object_is(args: &[Value]) -> Value {
960
+ tishlang_builtins::globals::object_is(args)
961
+ }
962
+ pub fn object_has_own(args: &[Value]) -> Value {
963
+ tishlang_builtins::globals::object_has_own(args)
964
+ }
648
965
 
649
966
  pub fn string_from_char_code(args: &[Value]) -> Value {
650
967
  builtins_string_from_char_code(args)
@@ -659,6 +976,18 @@ pub fn string_convert(args: &[Value]) -> Value {
659
976
  pub fn number_convert(args: &[Value]) -> Value {
660
977
  builtins_number_convert(args)
661
978
  }
979
+ pub fn number_is_integer(args: &[Value]) -> Value {
980
+ tishlang_builtins::globals::number_is_integer(args)
981
+ }
982
+ pub fn number_is_safe_integer(args: &[Value]) -> Value {
983
+ tishlang_builtins::globals::number_is_safe_integer(args)
984
+ }
985
+ pub fn number_is_nan(args: &[Value]) -> Value {
986
+ tishlang_builtins::globals::number_is_nan(args)
987
+ }
988
+ pub fn number_is_finite(args: &[Value]) -> Value {
989
+ tishlang_builtins::globals::number_is_finite(args)
990
+ }
662
991
 
663
992
  #[cfg(feature = "process")]
664
993
  pub fn process_exit(args: &[Value]) -> Value {
@@ -696,6 +1025,48 @@ pub fn process_exec(args: &[Value]) -> Value {
696
1025
  }
697
1026
  }
698
1027
 
1028
+ /// `process.execFile(program, [args])` — run a program directly, WITHOUT a shell (no `sh -c`). Each
1029
+ /// argument is passed to the program verbatim, so shell metacharacters in untrusted argument data are
1030
+ /// never interpreted — the safe counterpart to `exec` when arguments derive from input (#384). Returns
1031
+ /// the exit code, like `exec`.
1032
+ #[cfg(feature = "process")]
1033
+ pub fn process_exec_file(args: &[Value]) -> Value {
1034
+ use std::process::Command;
1035
+ let program = args
1036
+ .first()
1037
+ .map(|v| v.to_display_string())
1038
+ .unwrap_or_default();
1039
+ if program.is_empty() {
1040
+ return Value::Number(0.0);
1041
+ }
1042
+ let argv: Vec<String> = match args.get(1) {
1043
+ Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_display_string()).collect(),
1044
+ _ => Vec::new(),
1045
+ };
1046
+ match Command::new(&program).args(&argv).status() {
1047
+ Ok(status) => Value::Number(status.code().unwrap_or(1) as f64),
1048
+ Err(_) => Value::Number(1.0),
1049
+ }
1050
+ }
1051
+
1052
+ #[cfg(all(test, feature = "process", unix))]
1053
+ mod execfile_tests_384 {
1054
+ use super::process_exec_file;
1055
+ use tishlang_core::{Value, VmRef};
1056
+
1057
+ #[test]
1058
+ fn execfile_runs_without_shell_and_returns_exit_code() {
1059
+ // Runs the program directly (no `sh -c`); `true`/`false`/`echo` exist on unix.
1060
+ assert!(matches!(process_exec_file(&[Value::String("true".into())]), Value::Number(n) if n == 0.0));
1061
+ assert!(matches!(process_exec_file(&[Value::String("false".into())]), Value::Number(n) if n == 1.0));
1062
+ // args are passed verbatim as a Value::Array.
1063
+ let args = Value::Array(VmRef::new(vec![Value::String("ok".into())]));
1064
+ assert!(matches!(process_exec_file(&[Value::String("echo".into()), args]), Value::Number(n) if n == 0.0));
1065
+ // empty program is a no-op returning 0, matching `exec`.
1066
+ assert!(matches!(process_exec_file(&[]), Value::Number(n) if n == 0.0));
1067
+ }
1068
+ }
1069
+
699
1070
  #[cfg(feature = "fs")]
700
1071
  pub fn read_file(args: &[Value]) -> Value {
701
1072
  let path = args
@@ -791,6 +1162,92 @@ pub fn mkdir(args: &[Value]) -> Value {
791
1162
 
792
1163
  use std::sync::Arc;
793
1164
 
1165
+ /// Per-site polymorphic inline cache for `obj.<literal key>` reads in generated native code (#179).
1166
+ ///
1167
+ /// The native backend otherwise pays a full dynamic lookup (RefCell/Mutex borrow + linear key scan)
1168
+ /// for every member read, even at sites that only ever see a few object shapes. `PropMap` already
1169
+ /// tracks a hidden-class [`ShapeId`] per object and exposes slot-indexed access, so once a
1170
+ /// `(shape, slot)` pair is observed at a site, a later object of the same shape resolves the property
1171
+ /// with one integer compare + a direct slot load instead of a key scan.
1172
+ ///
1173
+ /// Each of the 8 entries packs `(shape: u32) << 32 | (slot: u32)` into a single `AtomicU64`, so the
1174
+ /// pair is read and written atomically — no torn `(shape, slot)` under concurrent access (e.g. an
1175
+ /// HTTP handler shared across worker threads). A 9th distinct shape evicts an entry round-robin. An
1176
+ /// empty entry is `0`; a real object never has `EMPTY_SHAPE` (0), so `0` can't be a false hit.
1177
+ pub struct PropIC {
1178
+ entries: [std::sync::atomic::AtomicU64; 8],
1179
+ next: std::sync::atomic::AtomicU32,
1180
+ }
1181
+
1182
+ impl PropIC {
1183
+ #[allow(clippy::declare_interior_mutable_const)] // each `static IC: PropIC = PropIC::new()` site needs its own cell
1184
+ pub const fn new() -> Self {
1185
+ use std::sync::atomic::{AtomicU32, AtomicU64};
1186
+ PropIC {
1187
+ entries: [
1188
+ AtomicU64::new(0),
1189
+ AtomicU64::new(0),
1190
+ AtomicU64::new(0),
1191
+ AtomicU64::new(0),
1192
+ AtomicU64::new(0),
1193
+ AtomicU64::new(0),
1194
+ AtomicU64::new(0),
1195
+ AtomicU64::new(0),
1196
+ ],
1197
+ next: AtomicU32::new(0),
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ impl Default for PropIC {
1203
+ fn default() -> Self {
1204
+ Self::new()
1205
+ }
1206
+ }
1207
+
1208
+ /// Cached `obj.key` read — see [`PropIC`]. Behaviour is identical to [`get_prop`]: only objects with
1209
+ /// a stable hidden class (non-empty, non-dictionary) take the cached fast path; empty/dictionary
1210
+ /// objects, non-objects, and the special `size` key all fall through to `get_prop` (the borrow is
1211
+ /// released first, so the fallback's re-borrow can't self-deadlock the send-values `Mutex`). The
1212
+ /// caller (codegen) never emits this for the `size` key, but the fallback covers it regardless.
1213
+ #[inline]
1214
+ pub fn get_prop_ic(obj: &Value, key: &str, ic: &PropIC) -> Value {
1215
+ use std::sync::atomic::Ordering;
1216
+ if key != "size" {
1217
+ if let Value::Object(map) = obj {
1218
+ // Scope the borrow so it is released before the `get_prop` fallback below.
1219
+ {
1220
+ let b = map.borrow();
1221
+ let shape = b.strings.shape();
1222
+ if shape != tishlang_core::EMPTY_SHAPE && shape != tishlang_core::DICT_SHAPE {
1223
+ let shape_hi = (shape as u64) << 32;
1224
+ // Fast path: a cached entry whose shape matches gives the slot directly.
1225
+ for e in ic.entries.iter() {
1226
+ let packed = e.load(Ordering::Relaxed);
1227
+ if packed >> 32 == shape as u64 {
1228
+ if let Some(v) = b.strings.value_at_index((packed & 0xFFFF_FFFF) as usize)
1229
+ {
1230
+ return v.clone();
1231
+ }
1232
+ }
1233
+ }
1234
+ // Miss: resolve once, fill an entry round-robin with the packed (shape, slot).
1235
+ return match b.strings.get_with_index(key) {
1236
+ Some((v, i)) => {
1237
+ let k = (ic.next.fetch_add(1, Ordering::Relaxed) % 8) as usize;
1238
+ ic.entries[k].store(shape_hi | i as u64, Ordering::Relaxed);
1239
+ v.clone()
1240
+ }
1241
+ None => Value::Null,
1242
+ };
1243
+ }
1244
+ // empty / dictionary object → fall through (borrow dropped here)
1245
+ }
1246
+ }
1247
+ }
1248
+ get_prop(obj, key)
1249
+ }
1250
+
794
1251
  #[inline]
795
1252
  pub fn get_prop(obj: &Value, key: impl AsRef<str>) -> Value {
796
1253
  let key = key.as_ref();
@@ -832,7 +1289,7 @@ pub fn get_prop(obj: &Value, key: impl AsRef<str>) -> Value {
832
1289
  }
833
1290
  Value::String(s) => {
834
1291
  if key == "length" {
835
- Value::Number(s.chars().count() as f64)
1292
+ Value::Number(tishlang_builtins::string::char_count(s) as f64)
836
1293
  } else {
837
1294
  Value::Null
838
1295
  }
@@ -885,6 +1342,15 @@ pub fn get_prop(obj: &Value, key: impl AsRef<str>) -> Value {
885
1342
  }
886
1343
  _ => Value::Null,
887
1344
  },
1345
+ // Reading a property of the nullish value is a JS TypeError. PARK a catchable throw (#425)
1346
+ // and return the null sentinel; it surfaces at the next pending-throw checkpoint. This is the
1347
+ // ONLY receiver that throws — a number/bool/function with no such property reads back `null`
1348
+ // (JS `undefined`), matching the VM/interpreter/node. Free for valid reads: `Object` is matched
1349
+ // first, so this cold arm never runs on the hot path.
1350
+ Value::Null => {
1351
+ tishlang_core::set_pending_throw(tishlang_core::cannot_read_property_error(key));
1352
+ Value::Null
1353
+ }
888
1354
  _ => Value::Null,
889
1355
  }
890
1356
  }
@@ -895,6 +1361,11 @@ pub fn get_index(obj: &Value, index: &Value) -> Value {
895
1361
  Value::Array(arr) => {
896
1362
  let idx = match index {
897
1363
  Value::Number(n) => *n as usize,
1364
+ // Canonical integer string key ("0","1",…) indexes the array (#432); other keys → Null.
1365
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
1366
+ Some(i) => i,
1367
+ None => return Value::Null,
1368
+ },
898
1369
  _ => return Value::Null,
899
1370
  };
900
1371
  arr.borrow().get(idx).cloned().unwrap_or(Value::Null)
@@ -903,6 +1374,10 @@ pub fn get_index(obj: &Value, index: &Value) -> Value {
903
1374
  Value::NumberArray(arr) => {
904
1375
  let idx = match index {
905
1376
  Value::Number(n) => *n as usize,
1377
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
1378
+ Some(i) => i,
1379
+ None => return Value::Null,
1380
+ },
906
1381
  _ => return Value::Null,
907
1382
  };
908
1383
  arr.borrow().get(idx).copied().map(Value::Number).unwrap_or(Value::Null)
@@ -910,11 +1385,11 @@ pub fn get_index(obj: &Value, index: &Value) -> Value {
910
1385
  // `str[i]` returns the character at index `i` (issue #17) — matches the VM /
911
1386
  // interpreter; out-of-bounds / negative / non-integer indices yield null.
912
1387
  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),
1388
+ Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => {
1389
+ tishlang_builtins::string::nth_char(s, *n as usize)
1390
+ .map(|c| Value::String(c.to_string().into()))
1391
+ .unwrap_or(Value::Null)
1392
+ }
918
1393
  _ => Value::Null,
919
1394
  },
920
1395
  Value::Object(_) => tishlang_core::object_get(obj, index).unwrap_or(Value::Null),
@@ -925,6 +1400,16 @@ pub fn get_index(obj: &Value, index: &Value) -> Value {
925
1400
  Value::String(k) => get_prop(obj, k.as_str()),
926
1401
  _ => Value::Null,
927
1402
  },
1403
+ // Indexing the nullish value throws a catchable TypeError (#425), like `get_prop` above —
1404
+ // parked and surfaced at the next checkpoint. Every other receiver reads back `null`.
1405
+ Value::Null => {
1406
+ let key = match index {
1407
+ Value::String(s) => s.to_string(),
1408
+ other => other.to_js_string(),
1409
+ };
1410
+ tishlang_core::set_pending_throw(tishlang_core::cannot_read_property_error(&key));
1411
+ Value::Null
1412
+ }
928
1413
  _ => Value::Null,
929
1414
  }
930
1415
  }
@@ -993,6 +1478,11 @@ pub fn set_index(obj: &Value, idx: &Value, val: Value) -> Value {
993
1478
  Value::Array(arr) => {
994
1479
  let index = match idx {
995
1480
  Value::Number(n) => *n as usize,
1481
+ // Canonical integer string key → index (#432); a non-index string key is a no-op write.
1482
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
1483
+ Some(i) => i,
1484
+ None => return val,
1485
+ },
996
1486
  _ => panic!("Array index must be number"),
997
1487
  };
998
1488
  let mut arr_mut = arr.borrow_mut();
@@ -1009,6 +1499,11 @@ pub fn set_index(obj: &Value, idx: &Value, val: Value) -> Value {
1009
1499
  Value::NumberArray(arr) => {
1010
1500
  let index = match idx {
1011
1501
  Value::Number(n) => *n as usize,
1502
+ // Canonical integer string key → index (#432); a non-index string key is a no-op write.
1503
+ Value::String(s) => match tishlang_core::str_to_array_index(s) {
1504
+ Some(i) => i,
1505
+ None => return val,
1506
+ },
1012
1507
  _ => panic!("Array index must be number"),
1013
1508
  };
1014
1509
  let mut arr_mut = arr.borrow_mut();
@@ -1107,6 +1602,11 @@ mod timers;
1107
1602
  #[cfg(any(feature = "http", feature = "promise"))]
1108
1603
  mod promise;
1109
1604
 
1605
+ /// Node-compatible fs surface (sync + `fs/promises`). Referenced by the backends via
1606
+ /// `tishlang_runtime::fs_ext::*`. See issue #122.
1607
+ #[cfg(feature = "fs")]
1608
+ pub mod fs_ext;
1609
+
1110
1610
  #[cfg(feature = "http")]
1111
1611
  mod native_promise;
1112
1612
 
@@ -1407,6 +1907,48 @@ pub fn string_match_regex(s: &Value, regexp: &Value) -> Value {
1407
1907
  }
1408
1908
  }
1409
1909
 
1910
+ /// `String.prototype.matchAll(regexp)` — an ITERATOR over every match, each an exec-style match object
1911
+ /// (`{ "0", ...groups, index, input, groups }`). Per ECMA the regexp must be global, else a `TypeError`
1912
+ /// is parked; a non-RegExp argument is coerced to a global regex. Reuses `regexp_exec_impl` so the
1913
+ /// match objects are byte-identical to `exec`/`match`, then wraps them in the shared iterator.
1914
+ #[cfg(feature = "regex")]
1915
+ pub fn string_match_all_regex(s: &Value, regexp: &Value) -> Value {
1916
+ let input = match s {
1917
+ Value::String(s) => s.as_ref(),
1918
+ _ => return Value::Null,
1919
+ };
1920
+ let re_cell = match regexp {
1921
+ Value::RegExp(re) => re.clone(),
1922
+ other => {
1923
+ let pattern = other.to_display_string();
1924
+ match tishlang_core::TishRegExp::new(&pattern, "g") {
1925
+ Ok(re) => tishlang_core::VmRef::new(re),
1926
+ Err(_) => return Value::Null,
1927
+ }
1928
+ }
1929
+ };
1930
+ if !re_cell.borrow().flags.global {
1931
+ tishlang_core::set_pending_throw(tishlang_core::type_error(
1932
+ "String.prototype.matchAll called with a non-global RegExp argument",
1933
+ ));
1934
+ return Value::Null;
1935
+ }
1936
+ let mut results: Vec<Value> = Vec::new();
1937
+ {
1938
+ let mut re = re_cell.borrow_mut();
1939
+ re.last_index = 0;
1940
+ loop {
1941
+ let m = regexp_exec_impl(&mut re, input);
1942
+ if matches!(m, Value::Null) {
1943
+ break;
1944
+ }
1945
+ results.push(m);
1946
+ }
1947
+ re.last_index = 0;
1948
+ }
1949
+ tishlang_builtins::iterator::array_iterator(results)
1950
+ }
1951
+
1410
1952
  #[cfg(feature = "regex")]
1411
1953
  fn string_replace_regex_or_callback(s: &Value, search: &Value, replacement: &Value) -> Value {
1412
1954
  let input = match s {
@@ -1502,3 +2044,61 @@ pub fn string_search_regex(s: &Value, regexp: &Value) -> Value {
1502
2044
  _ => Value::Number(-1.0),
1503
2045
  }
1504
2046
  }
2047
+
2048
+ #[cfg(test)]
2049
+ mod null_read_parking_tests_425 {
2050
+ // Reading a property/index of the nullish value PARKS a catchable TypeError (#425) instead of
2051
+ // silently reading back `null` — the native/runtime read paths. The throw surfaces at the caller's
2052
+ // next pending-throw checkpoint. Every other receiver (number/bool/valid object/array) reads back
2053
+ // a value with NO parked throw.
2054
+ use super::{get_index, get_prop};
2055
+ use tishlang_core::{has_pending_throw, take_pending_throw, Value, VmRef};
2056
+
2057
+ fn parked_name() -> Option<String> {
2058
+ take_pending_throw().and_then(|v| {
2059
+ if let Value::Object(o) = v {
2060
+ if let Some(Value::String(s)) = o.borrow().strings.get("name") {
2061
+ return Some(s.to_string());
2062
+ }
2063
+ }
2064
+ None
2065
+ })
2066
+ }
2067
+
2068
+ #[test]
2069
+ fn get_prop_on_null_parks_type_error() {
2070
+ let _ = take_pending_throw();
2071
+ let r = get_prop(&Value::Null, "length");
2072
+ assert!(matches!(r, Value::Null));
2073
+ assert!(has_pending_throw());
2074
+ assert_eq!(parked_name().as_deref(), Some("TypeError"));
2075
+ }
2076
+
2077
+ #[test]
2078
+ fn get_index_on_null_parks_type_error() {
2079
+ let _ = take_pending_throw();
2080
+ let r = get_index(&Value::Null, &Value::Number(0.0));
2081
+ assert!(matches!(r, Value::Null));
2082
+ assert!(has_pending_throw());
2083
+ assert_eq!(parked_name().as_deref(), Some("TypeError"));
2084
+ }
2085
+
2086
+ #[test]
2087
+ fn valid_reads_do_not_park() {
2088
+ let _ = take_pending_throw();
2089
+ // object property, array index, and array length must NOT park a throw.
2090
+ let obj = Value::object({
2091
+ let mut m = tishlang_core::ObjectMap::default();
2092
+ m.insert(std::sync::Arc::from("a"), Value::Number(42.0));
2093
+ m
2094
+ });
2095
+ assert!(matches!(get_prop(&obj, "a"), Value::Number(n) if n == 42.0));
2096
+ assert!(!has_pending_throw(), "valid property read must not park");
2097
+ let arr = Value::Array(VmRef::new(vec![Value::Number(7.0)]));
2098
+ assert!(matches!(get_index(&arr, &Value::Number(0.0)), Value::Number(n) if n == 7.0));
2099
+ assert!(!has_pending_throw(), "valid index read must not park");
2100
+ // a MISSING object property reads back null WITHOUT parking (JS `undefined`, not a throw).
2101
+ assert!(matches!(get_prop(&obj, "missing"), Value::Null));
2102
+ assert!(!has_pending_throw(), "missing property must not park");
2103
+ }
2104
+ }