@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
@@ -98,6 +98,21 @@ fn resolve_import_to_key(
98
98
  ))
99
99
  }
100
100
 
101
+ /// The module specifier a statement imports/re-exports from, if any — covers both
102
+ /// `import … from "x"` and `export … from "x"` (#305 re-export). Dependency discovery and cycle
103
+ /// detection use this so they follow re-export edges, not just plain imports (otherwise a module
104
+ /// reached only via a re-export would never be loaded).
105
+ fn stmt_module_source(stmt: &Statement) -> Option<&str> {
106
+ match stmt {
107
+ Statement::Import { from, .. } => Some(&**from),
108
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
109
+ ExportDeclaration::ReExport { from, .. } => Some(&**from),
110
+ _ => None,
111
+ },
112
+ _ => None,
113
+ }
114
+ }
115
+
101
116
  /// Resolve all modules starting from the entry file. Returns modules in dependency order.
102
117
  pub fn resolve_virtual(
103
118
  entry_path: &str,
@@ -163,7 +178,7 @@ fn load_module_recursive(
163
178
 
164
179
  let from_dir = parent_dir(module_path);
165
180
  for stmt in &program.statements {
166
- if let Statement::Import { from, .. } = stmt {
181
+ if let Some(from) = stmt_module_source(stmt) {
167
182
  if is_native_import(from) {
168
183
  continue;
169
184
  }
@@ -217,7 +232,7 @@ fn has_cycle_from(
217
232
  visiting: &mut HashSet<usize>,
218
233
  ) -> Result<bool, String> {
219
234
  for stmt in &program.statements {
220
- if let Statement::Import { from, .. } = stmt {
235
+ if let Some(from) = stmt_module_source(stmt) {
221
236
  if is_native_import(from) {
222
237
  continue;
223
238
  }
@@ -292,6 +307,40 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
292
307
  let default_name = format!("__default_{}", idx);
293
308
  module_exports[idx].insert("default".to_string(), default_name);
294
309
  }
310
+ // #305: re-export — map each re-exported name to the DEP's binding (the dep is
311
+ // earlier in load order, so its export table is already built). `export *` copies
312
+ // every dep export without overriding an explicit one; `export { a as b }` maps
313
+ // b -> dep's binding for a. Mirrors `merge_modules` in tish_compile/src/resolve.rs.
314
+ ExportDeclaration::ReExport {
315
+ specifiers,
316
+ all,
317
+ from,
318
+ ..
319
+ } => {
320
+ let dir = parent_dir(&module.path);
321
+ let dep = resolve_import_to_key_for_cycle(from, dir, &path_to_idx)
322
+ .ok()
323
+ .and_then(|key| path_to_idx.get(&key).copied())
324
+ .map(|dep_idx| module_exports[dep_idx].clone());
325
+ if let Some(dep) = dep {
326
+ if *all {
327
+ for (k, v) in &dep {
328
+ module_exports[idx]
329
+ .entry(k.clone())
330
+ .or_insert_with(|| v.clone());
331
+ }
332
+ }
333
+ for spec in specifiers {
334
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
335
+ if let Some(binding) = dep.get(name.as_ref()) {
336
+ let export_name =
337
+ alias.as_deref().unwrap_or(name.as_ref()).to_string();
338
+ module_exports[idx].insert(export_name, binding.clone());
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
295
344
  }
296
345
  }
297
346
  }
@@ -440,6 +489,10 @@ pub fn merge_modules_virtual(modules: Vec<VirtualModule>) -> Result<Program, Str
440
489
  span: espan,
441
490
  });
442
491
  }
492
+ // #305: re-export emits no code — `module_exports` already maps the re-exported
493
+ // names to the dep's bindings (in scope in the flattened bundle), so downstream
494
+ // imports resolve directly. Nothing to push here.
495
+ ExportDeclaration::ReExport { .. } => {}
443
496
  },
444
497
  _ => statements.push(stmt.clone()),
445
498
  }
@@ -471,4 +524,49 @@ mod tests {
471
524
  let program = merge_modules_virtual(modules).unwrap();
472
525
  assert!(!program.statements.is_empty());
473
526
  }
527
+
528
+ #[test]
529
+ fn test_resolve_virtual_reexport_alias() {
530
+ // #305: `export { add as plus } from "./dep"` re-exports dep's `add` as `plus`; a downstream
531
+ // `import { plus }` must resolve to dep's binding. Regression guard: the merge previously did
532
+ // not handle `ExportDeclaration::ReExport` (E0004 build failure), and discovery did not follow
533
+ // the re-export edge (so a dep reached only via re-export was never loaded).
534
+ let mut files = HashMap::new();
535
+ files.insert(
536
+ "dep.tish".to_string(),
537
+ "export fn add(a, b) { return a + b }".to_string(),
538
+ );
539
+ files.insert(
540
+ "mid.tish".to_string(),
541
+ "export { add as plus } from \"./dep.tish\"".to_string(),
542
+ );
543
+ files.insert(
544
+ "main.tish".to_string(),
545
+ "import { plus } from \"./mid.tish\"\nconsole.log(plus(1, 2))".to_string(),
546
+ );
547
+ let modules = resolve_virtual("main.tish", &files).unwrap();
548
+ // dep.tish is reached ONLY via mid's re-export → discovery must have loaded it.
549
+ assert!(
550
+ modules.iter().any(|m| m.path == "dep.tish"),
551
+ "re-exported dep should be discovered and loaded"
552
+ );
553
+ detect_cycles_virtual(&modules).unwrap();
554
+ let program = merge_modules_virtual(modules).unwrap();
555
+ let has_add_fn = program
556
+ .statements
557
+ .iter()
558
+ .any(|s| matches!(s, Statement::FunDecl { name, .. } if name.as_ref() == "add"));
559
+ let binds_plus_to_add = program.statements.iter().any(|s| {
560
+ matches!(
561
+ s,
562
+ Statement::VarDecl { name, init: Some(Expr::Ident { name: src, .. }), .. }
563
+ if name.as_ref() == "plus" && src.as_ref() == "add"
564
+ )
565
+ });
566
+ assert!(has_add_fn, "dep's `add` fn should be in the merged bundle");
567
+ assert!(
568
+ binds_plus_to_add,
569
+ "import of re-exported `plus` should bind to dep's `add`"
570
+ );
571
+ }
474
572
  }
@@ -25,6 +25,10 @@ smallvec = "1"
25
25
  indexmap = "2"
26
26
  # Fast integer→decimal for the JSON.stringify number fast-path (already in the workspace lock).
27
27
  itoa = "1"
28
+ # Fast shortest-float→decimal for the number→string general path (JSON/template/log). ryu writes the
29
+ # shortest round-trip digits straight to a buffer with none of `core::fmt`'s `{:e}` Formatter overhead
30
+ # (profiled as ~40% of JSON.stringify self-time on numeric payloads). Already in the workspace lock.
31
+ ryu = "1"
28
32
  fancy-regex = { version = "0.17.0", optional = true }
29
33
  # Only under `send-values` (the Arc<Mutex> path): parking_lot's uncontended lock is a
30
34
  # single atomic with no pthread syscall — profiled as a top cost on object/array access.
@@ -80,10 +80,31 @@ pub fn json_parse(json: &str) -> Result<Value, String> {
80
80
  /// per-node `String` only to copy + drop it on the way back up. For a
81
81
  /// 20-row TFB `/queries` response (~40 numbers, 2 keys × 20 = ~80 string
82
82
  /// ops) that saves dozens of small allocations per request.
83
+ fn json_stringify_capacity_hint(value: &Value) -> usize {
84
+ match value {
85
+ Value::Array(arr) => {
86
+ let n = arr.borrow().len();
87
+ if n > 64 {
88
+ // json_roundtrip / large API payloads: ~80–100 B per row is typical.
89
+ n.saturating_mul(96).max(256)
90
+ } else {
91
+ 256
92
+ }
93
+ }
94
+ Value::Object(obj) => {
95
+ let n = obj.borrow().strings.len();
96
+ if n > 32 {
97
+ n.saturating_mul(128).max(256)
98
+ } else {
99
+ 256
100
+ }
101
+ }
102
+ _ => 256,
103
+ }
104
+ }
105
+
83
106
  pub fn json_stringify(value: &Value) -> String {
84
- // 256 B is enough for typical TFB responses (`/db` is 31 B,
85
- // `/queries=20` is ~700 B). Larger payloads reallocate normally.
86
- let mut buf = String::with_capacity(256);
107
+ let mut buf = String::with_capacity(json_stringify_capacity_hint(value));
87
108
  json_stringify_into(&mut buf, value);
88
109
  buf
89
110
  }
@@ -91,7 +112,39 @@ pub fn json_stringify(value: &Value) -> String {
91
112
  /// Append a JSON-stringified `value` to `buf`. Used by JSON.stringify for
92
113
  /// the recursive case so we don't pay for an intermediate `String` per
93
114
  /// node.
115
+ ///
116
+ /// Cyclic object graphs (`a.self = a`) are detected and — matching JS, which throws
117
+ /// `TypeError: Converting circular structure to JSON` — set a pending throw and emit `null` for the
118
+ /// back-reference instead of recursing forever (#381: this was an unrecoverable thread hang, directly
119
+ /// reachable from HTTP response serialization).
94
120
  pub fn json_stringify_into(buf: &mut String, value: &Value) {
121
+ // Track the CURRENT ancestor path only (not all-visited): a node repeated across sibling branches
122
+ // is a legal DAG and must serialize twice; only a back-edge to an ancestor is a cycle.
123
+ let mut ancestors: Vec<*const ()> = Vec::new();
124
+ json_stringify_into_guarded(buf, value, &mut ancestors);
125
+ }
126
+
127
+ /// Set the JS "circular structure" TypeError as the pending throw (once). The `{ name, message }`
128
+ /// shape matches node (`e.name === "TypeError"`, `e.message === "Converting circular structure to
129
+ /// JSON"`) and tish's own thrown-error convention (`construct_builtin::error_object`, the recursion
130
+ /// `RangeError` from [`crate::stack_overflow_error`]) — the previous `{ error: <msg> }` shape left
131
+ /// `e.name` null in a catch block, diverging from node.
132
+ fn signal_circular_json_throw() {
133
+ if !crate::has_pending_throw() {
134
+ crate::set_pending_throw(Value::object_from_pairs([
135
+ (
136
+ std::sync::Arc::from("name"),
137
+ Value::String("TypeError".into()),
138
+ ),
139
+ (
140
+ std::sync::Arc::from("message"),
141
+ Value::String("Converting circular structure to JSON".into()),
142
+ ),
143
+ ]));
144
+ }
145
+ }
146
+
147
+ fn json_stringify_into_guarded(buf: &mut String, value: &Value, ancestors: &mut Vec<*const ()>) {
95
148
  match value {
96
149
  Value::Null => buf.push_str("null"),
97
150
  Value::Bool(true) => buf.push_str("true"),
@@ -103,15 +156,24 @@ pub fn json_stringify_into(buf: &mut String, value: &Value) {
103
156
  buf.push('"');
104
157
  }
105
158
  Value::Array(arr) => {
159
+ let ptr = arr.as_ptr();
160
+ if ancestors.contains(&ptr) {
161
+ signal_circular_json_throw();
162
+ buf.push_str("null");
163
+ return;
164
+ }
165
+ ancestors.push(ptr);
106
166
  let borrowed = arr.borrow();
107
167
  buf.push('[');
108
168
  for (i, item) in borrowed.iter().enumerate() {
109
169
  if i > 0 {
110
170
  buf.push(',');
111
171
  }
112
- json_stringify_into(buf, item);
172
+ json_stringify_into_guarded(buf, item, ancestors);
113
173
  }
114
174
  buf.push(']');
175
+ drop(borrowed);
176
+ ancestors.pop();
115
177
  }
116
178
  Value::NumberArray(arr) => {
117
179
  let borrowed = arr.borrow();
@@ -125,6 +187,13 @@ pub fn json_stringify_into(buf: &mut String, value: &Value) {
125
187
  buf.push(']');
126
188
  }
127
189
  Value::Object(obj) => {
190
+ let ptr = obj.as_ptr();
191
+ if ancestors.contains(&ptr) {
192
+ signal_circular_json_throw();
193
+ buf.push_str("null");
194
+ return;
195
+ }
196
+ ancestors.push(ptr);
128
197
  let borrowed = obj.borrow();
129
198
  // Iterate in insertion order (PropMap preserves it) — matches JS/Node
130
199
  // and `Object.keys`. No intermediate key Vec, no sort: one fewer
@@ -137,9 +206,11 @@ pub fn json_stringify_into(buf: &mut String, value: &Value) {
137
206
  buf.push('"');
138
207
  escape_json_string_into(buf, key);
139
208
  buf.push_str("\":");
140
- json_stringify_into(buf, val);
209
+ json_stringify_into_guarded(buf, val, ancestors);
141
210
  }
142
211
  buf.push('}');
212
+ drop(borrowed);
213
+ ancestors.pop();
143
214
  }
144
215
  Value::Function(_) | Value::Promise(_) | Value::Opaque(_) | Value::Symbol(_) => {
145
216
  buf.push_str("null");
@@ -477,6 +548,34 @@ fn parse_object<'a>(
477
548
  mod tests {
478
549
  use super::*;
479
550
 
551
+ /// #381: a self-referential array must NOT loop forever — `JSON.stringify` terminates, emits a
552
+ /// finite string, and (matching JS) leaves a pending TypeError. The test *completing* is itself
553
+ /// the assertion that the former infinite hang is gone.
554
+ #[test]
555
+ fn json_stringify_cyclic_array_terminates_and_throws() {
556
+ let _ = crate::take_pending_throw(); // isolate from any prior thread-local throw
557
+ let a = Value::Array(crate::VmRef::new(Vec::new()));
558
+ if let Value::Array(inner) = &a {
559
+ inner.borrow_mut().push(a.clone()); // a = [a]
560
+ }
561
+ let s = json_stringify(&a);
562
+ assert_eq!(s, "[null]", "back-reference serializes as null, finite output");
563
+ assert!(crate::has_pending_throw(), "cyclic stringify must set a pending TypeError");
564
+ let _ = crate::take_pending_throw(); // clean up thread-local for other tests
565
+ }
566
+
567
+ /// A shared (non-cyclic) node repeated across sibling branches is a legal DAG and must serialize
568
+ /// twice — the ancestor-only tracking must NOT misflag it as a cycle.
569
+ #[test]
570
+ fn json_stringify_shared_dag_node_is_not_a_cycle() {
571
+ let _ = crate::take_pending_throw();
572
+ let shared = Value::Array(crate::VmRef::new(vec![Value::Number(1.0)]));
573
+ let root = Value::Array(crate::VmRef::new(vec![shared.clone(), shared.clone()]));
574
+ let s = json_stringify(&root);
575
+ assert_eq!(s, "[[1],[1]]", "a shared node is a DAG, not a cycle");
576
+ assert!(!crate::has_pending_throw(), "a DAG must NOT throw");
577
+ }
578
+
480
579
  #[test]
481
580
  fn test_parse_primitives() {
482
581
  assert!(matches!(json_parse("null").unwrap(), Value::Null));
@@ -40,3 +40,177 @@ pub fn process_argv() -> Vec<String> {
40
40
  None => std::env::args().collect(),
41
41
  }
42
42
  }
43
+
44
+ /// #303 — a thrown JS value parked while it unwinds across a boundary that can't carry a `Result`.
45
+ ///
46
+ /// The native value-fn ABI is `Fn(&[Value]) -> Value`, and `Callable::call` likewise returns a bare
47
+ /// `Value`, so a `throw` crossing such a boundary can't ride a `Result`. It is parked here and picked
48
+ /// up at the next checkpoint: native codegen checks it after each call; the VM checks it after each
49
+ /// `Callable::call`; and the shared array builtins (`forEach`/`map`/`sort`/…) check it between
50
+ /// elements so they stop iterating promptly instead of running the callback for the rest of the
51
+ /// array. The slot lives in `tishlang_core` (rather than `tishlang_runtime`) so `tishlang_builtins`
52
+ /// can poll it without a `builtins -> runtime` dependency cycle; `tishlang_runtime` and `tishlang_vm`
53
+ /// share this one slot. First-throw-wins; drained exactly once by [`take_pending_throw`] at the frame
54
+ /// that converts it back into a `Result`.
55
+ thread_local! {
56
+ static PENDING_THROW: std::cell::RefCell<Option<Value>> = const { std::cell::RefCell::new(None) };
57
+ }
58
+
59
+ /// Park a thrown value to propagate across a non-`Result` boundary. First-throw-wins: if one is
60
+ /// already pending (an erroneous continuation reached a second throw before the slot was drained),
61
+ /// keep the first — that is the throw JS would have raised.
62
+ pub fn set_pending_throw(v: Value) {
63
+ PENDING_THROW.with(|c| {
64
+ let mut slot = c.borrow_mut();
65
+ if slot.is_none() {
66
+ *slot = Some(v);
67
+ }
68
+ });
69
+ }
70
+
71
+ /// Is a thrown value waiting to propagate?
72
+ pub fn has_pending_throw() -> bool {
73
+ PENDING_THROW.with(|c| c.borrow().is_some())
74
+ }
75
+
76
+ /// Take the parked thrown value, clearing the slot (drains it exactly once).
77
+ pub fn take_pending_throw() -> Option<Value> {
78
+ PENDING_THROW.with(|c| c.borrow_mut().take())
79
+ }
80
+
81
+ thread_local! {
82
+ /// Current user-function call depth for the bytecode VM. The VM's recursive path builds a fresh
83
+ /// `Vm` per call (so no shared struct field can accumulate) and its `Callable::call` signature is
84
+ /// fixed — a thread-local is the VM's equivalent of the interpreter's shared `Rc<Cell>` counter.
85
+ /// Lives here (not `tish_vm`) beside `PENDING_THROW` for the same layering reason. #381
86
+ static CALL_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
87
+ }
88
+
89
+ /// Enter one VM call frame; returns the new depth. Pair every call with [`dec_call_depth`].
90
+ #[inline]
91
+ pub fn inc_call_depth() -> usize {
92
+ CALL_DEPTH.with(|c| {
93
+ let d = c.get() + 1;
94
+ c.set(d);
95
+ d
96
+ })
97
+ }
98
+
99
+ /// Leave one VM call frame.
100
+ #[inline]
101
+ pub fn dec_call_depth() {
102
+ CALL_DEPTH.with(|c| c.set(c.get().saturating_sub(1)));
103
+ }
104
+
105
+ /// Default recursion ceiling shared by every backend that counts call depth (#381). Chosen with the
106
+ /// interpreter: comfortably deep for real programs, but reached long before counted recursion can
107
+ /// exhaust memory or the stack.
108
+ pub const DEFAULT_MAX_CALL_DEPTH: usize = 20_000;
109
+
110
+ thread_local! {
111
+ // The recursion ceiling, lazily initialized from `TISH_MAX_CALL_DEPTH` (0 = uninitialized). A
112
+ // thread-local Cell (not OnceLock) so tests can override it per-thread without racing the
113
+ // process-wide env. Shared by the VM's guard and the native backend's boxed-call guard. #381
114
+ static MAX_CALL_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
115
+ }
116
+
117
+ /// The recursion ceiling for depth-counting guards: user-fn calls deeper than this raise a catchable
118
+ /// `RangeError` instead of overflowing the native stack. `TISH_MAX_CALL_DEPTH` overrides (default
119
+ /// [`DEFAULT_MAX_CALL_DEPTH`]). #381
120
+ pub fn max_call_depth() -> usize {
121
+ MAX_CALL_DEPTH.with(|c| {
122
+ let v = c.get();
123
+ if v != 0 {
124
+ return v;
125
+ }
126
+ let init = std::env::var("TISH_MAX_CALL_DEPTH")
127
+ .ok()
128
+ .and_then(|s| s.parse::<usize>().ok())
129
+ .filter(|&n| n > 0)
130
+ .unwrap_or(DEFAULT_MAX_CALL_DEPTH);
131
+ c.set(init);
132
+ init
133
+ })
134
+ }
135
+
136
+ /// Test hook: pin the ceiling for the current thread (bypasses the env read).
137
+ pub fn set_max_call_depth_for_test(n: usize) {
138
+ MAX_CALL_DEPTH.with(|c| c.set(n));
139
+ }
140
+
141
+ /// The catchable `RangeError` raised when a recursion guard trips. Built by hand (`tishlang_core`
142
+ /// sits below `tishlang_builtins`) but shape-identical to `construct_builtin::error_object`:
143
+ /// a `{ name, message }` object — keep the two in lock-step.
144
+ pub fn stack_overflow_error() -> Value {
145
+ let mut e = ObjectMap::default();
146
+ e.insert(
147
+ std::sync::Arc::from("name"),
148
+ Value::String("RangeError".into()),
149
+ );
150
+ e.insert(
151
+ std::sync::Arc::from("message"),
152
+ Value::String("Maximum call stack size exceeded".into()),
153
+ );
154
+ Value::object(e)
155
+ }
156
+
157
+ /// A catchable `TypeError` for calling a non-callable value — the `{ name, message }` shape matches
158
+ /// the VM/interpreter/node (`e.name === "TypeError"`). Used to PARK a pending throw (#381) instead of
159
+ /// aborting the process, so `value_call` on a non-function surfaces as a catchable error at the next
160
+ /// pending-throw checkpoint rather than an uncatchable native panic.
161
+ pub fn not_a_function_error(message: impl Into<String>) -> Value {
162
+ let mut e = ObjectMap::default();
163
+ e.insert(
164
+ std::sync::Arc::from("name"),
165
+ Value::String("TypeError".into()),
166
+ );
167
+ e.insert(
168
+ std::sync::Arc::from("message"),
169
+ Value::String(message.into().into()),
170
+ );
171
+ Value::object(e)
172
+ }
173
+
174
+ /// A catchable `TypeError` for reading a property/index of the nullish value — the `{ name, message }`
175
+ /// shape matches the VM/interpreter/node (`e.name === "TypeError"`). Used to PARK a pending throw in
176
+ /// the native/runtime `get_prop`/`get_index` null arm (#425), so `null.length` / `null[0]` surface a
177
+ /// catchable error at the next pending-throw checkpoint instead of silently reading back `null`.
178
+ pub fn cannot_read_property_error(prop: &str) -> Value {
179
+ let mut e = ObjectMap::default();
180
+ e.insert(
181
+ std::sync::Arc::from("name"),
182
+ Value::String("TypeError".into()),
183
+ );
184
+ e.insert(
185
+ std::sync::Arc::from("message"),
186
+ Value::String(format!("Cannot read property '{}' of null", prop).into()),
187
+ );
188
+ Value::object(e)
189
+ }
190
+
191
+ /// RAII frame for the depth-counting recursion guard: holding one means the depth was incremented;
192
+ /// dropping it decrements, so early returns in generated code can't leak a level. #381
193
+ pub struct CallDepthGuard(());
194
+
195
+ impl Drop for CallDepthGuard {
196
+ #[inline]
197
+ fn drop(&mut self) {
198
+ dec_call_depth();
199
+ }
200
+ }
201
+
202
+ /// Enter a counted user-fn call frame, or trip the recursion guard: past [`max_call_depth`] this
203
+ /// parks the catchable stack-overflow `RangeError` in the pending-throw slot and returns `None` —
204
+ /// the caller just returns its frame's dummy value (`Value::Null`) and the throw surfaces at the
205
+ /// next pending-throw checkpoint. The native backend emits this at the top of every boxed user-fn
206
+ /// closure. #381
207
+ #[inline]
208
+ pub fn enter_call_guarded() -> Option<CallDepthGuard> {
209
+ let depth = inc_call_depth();
210
+ if depth > max_call_depth() {
211
+ dec_call_depth();
212
+ set_pending_throw(stack_overflow_error());
213
+ return None;
214
+ }
215
+ Some(CallDepthGuard(()))
216
+ }
@@ -12,7 +12,6 @@
12
12
  //! `PropMap` on a cache miss). Phase 1b will attach the ordered key list to each shape so objects can
13
13
  //! drop per-object key storage entirely (the butterfly representation).
14
14
 
15
- use std::collections::HashMap;
16
15
  use std::sync::{Arc, OnceLock, RwLock};
17
16
 
18
17
  /// Identity of an object's ordered key-set.
@@ -27,9 +26,12 @@ pub const EMPTY_SHAPE: ShapeId = 0;
27
26
  pub const DICT_SHAPE: ShapeId = u32::MAX;
28
27
 
29
28
  /// One node in the structure-transition tree: from this shape, adding a given key yields a child shape.
29
+ /// Edges are keyed by an `ahash` map, not the default SipHash one: `transition` is on the hot path of
30
+ /// every object construction (and `JSON.parse` — profiled as ~16% of parse, almost all SipHash), and
31
+ /// ahash is ~2–3× faster on short string keys. Random-seeded per process, so no HashDoS regression.
30
32
  #[derive(Default)]
31
33
  struct ShapeNode {
32
- transitions: HashMap<Arc<str>, ShapeId>,
34
+ transitions: ahash::AHashMap<Arc<str>, ShapeId>,
33
35
  }
34
36
 
35
37
  struct Registry {