@tishlang/tish-lint 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-lint +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/Cargo.toml +1 -1
  63. package/crates/tish_lint/src/lib.rs +126 -0
  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-lint +0 -0
  85. package/platform/darwin-x64/tish-lint +0 -0
  86. package/platform/linux-arm64/tish-lint +0 -0
  87. package/platform/linux-x64/tish-lint +0 -0
  88. package/platform/win32-x64/tish-lint.exe +0 -0
@@ -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");
@@ -153,7 +224,11 @@ pub fn json_stringify_into(buf: &mut String, value: &Value) {
153
224
  /// to `buf`. Optimised for the common case where the input is ASCII and
154
225
  /// contains no characters that need escaping — we fast-pass the bytes
155
226
  /// straight through, only falling into the per-char path on a hit.
156
- fn escape_json_string_into(buf: &mut String, s: &str) {
227
+ /// Append `s` to `buf` as a JSON string body (WITHOUT the surrounding quotes), escaping `"`, `\`,
228
+ /// and every control char < 0x20 (`\n \r \t \b \f`, else `\u00xx`). The single source of truth for
229
+ /// JSON string escaping, shared by the vm/native path here and the interpreter's `JSON.stringify`
230
+ /// (which otherwise hand-rolled an escaper that missed `\b`/`\f`/`\u00xx` and emitted invalid JSON).
231
+ pub fn escape_json_string_into(buf: &mut String, s: &str) {
157
232
  let bytes = s.as_bytes();
158
233
  let mut start = 0usize;
159
234
  for (i, &b) in bytes.iter().enumerate() {
@@ -477,6 +552,34 @@ fn parse_object<'a>(
477
552
  mod tests {
478
553
  use super::*;
479
554
 
555
+ /// #381: a self-referential array must NOT loop forever — `JSON.stringify` terminates, emits a
556
+ /// finite string, and (matching JS) leaves a pending TypeError. The test *completing* is itself
557
+ /// the assertion that the former infinite hang is gone.
558
+ #[test]
559
+ fn json_stringify_cyclic_array_terminates_and_throws() {
560
+ let _ = crate::take_pending_throw(); // isolate from any prior thread-local throw
561
+ let a = Value::Array(crate::VmRef::new(Vec::new()));
562
+ if let Value::Array(inner) = &a {
563
+ inner.borrow_mut().push(a.clone()); // a = [a]
564
+ }
565
+ let s = json_stringify(&a);
566
+ assert_eq!(s, "[null]", "back-reference serializes as null, finite output");
567
+ assert!(crate::has_pending_throw(), "cyclic stringify must set a pending TypeError");
568
+ let _ = crate::take_pending_throw(); // clean up thread-local for other tests
569
+ }
570
+
571
+ /// A shared (non-cyclic) node repeated across sibling branches is a legal DAG and must serialize
572
+ /// twice — the ancestor-only tracking must NOT misflag it as a cycle.
573
+ #[test]
574
+ fn json_stringify_shared_dag_node_is_not_a_cycle() {
575
+ let _ = crate::take_pending_throw();
576
+ let shared = Value::Array(crate::VmRef::new(vec![Value::Number(1.0)]));
577
+ let root = Value::Array(crate::VmRef::new(vec![shared.clone(), shared.clone()]));
578
+ let s = json_stringify(&root);
579
+ assert_eq!(s, "[[1],[1]]", "a shared node is a DAG, not a cycle");
580
+ assert!(!crate::has_pending_throw(), "a DAG must NOT throw");
581
+ }
582
+
480
583
  #[test]
481
584
  fn test_parse_primitives() {
482
585
  assert!(matches!(json_parse("null").unwrap(), Value::Null));
@@ -12,9 +12,11 @@ mod value;
12
12
  mod vmref;
13
13
 
14
14
  pub use console_style::{format_value_styled, format_values_for_console, use_console_colors};
15
- pub use json::{json_parse, json_stringify, json_stringify_into, write_json_number};
15
+ pub use json::{
16
+ escape_json_string_into, json_parse, json_stringify, json_stringify_into, write_json_number,
17
+ };
16
18
  pub use shape::{ShapeId, DICT_SHAPE, EMPTY_SHAPE};
17
- pub use uri::{percent_decode, percent_encode};
19
+ pub use uri::{percent_decode, percent_decode_component, percent_encode, percent_encode_component};
18
20
  pub use arcstr::ArcStr;
19
21
  pub use value::*;
20
22
  pub use vmref::{VmReadGuard, VmRef, VmWriteGuard};
@@ -40,3 +42,221 @@ pub fn process_argv() -> Vec<String> {
40
42
  None => std::env::args().collect(),
41
43
  }
42
44
  }
45
+
46
+ /// #303 — a thrown JS value parked while it unwinds across a boundary that can't carry a `Result`.
47
+ ///
48
+ /// The native value-fn ABI is `Fn(&[Value]) -> Value`, and `Callable::call` likewise returns a bare
49
+ /// `Value`, so a `throw` crossing such a boundary can't ride a `Result`. It is parked here and picked
50
+ /// up at the next checkpoint: native codegen checks it after each call; the VM checks it after each
51
+ /// `Callable::call`; and the shared array builtins (`forEach`/`map`/`sort`/…) check it between
52
+ /// elements so they stop iterating promptly instead of running the callback for the rest of the
53
+ /// array. The slot lives in `tishlang_core` (rather than `tishlang_runtime`) so `tishlang_builtins`
54
+ /// can poll it without a `builtins -> runtime` dependency cycle; `tishlang_runtime` and `tishlang_vm`
55
+ /// share this one slot. First-throw-wins; drained exactly once by [`take_pending_throw`] at the frame
56
+ /// that converts it back into a `Result`.
57
+ thread_local! {
58
+ static PENDING_THROW: std::cell::RefCell<Option<Value>> = const { std::cell::RefCell::new(None) };
59
+ }
60
+
61
+ /// Park a thrown value to propagate across a non-`Result` boundary. First-throw-wins: if one is
62
+ /// already pending (an erroneous continuation reached a second throw before the slot was drained),
63
+ /// keep the first — that is the throw JS would have raised.
64
+ pub fn set_pending_throw(v: Value) {
65
+ PENDING_THROW.with(|c| {
66
+ let mut slot = c.borrow_mut();
67
+ if slot.is_none() {
68
+ *slot = Some(v);
69
+ }
70
+ });
71
+ }
72
+
73
+ /// Is a thrown value waiting to propagate?
74
+ pub fn has_pending_throw() -> bool {
75
+ PENDING_THROW.with(|c| c.borrow().is_some())
76
+ }
77
+
78
+ /// Take the parked thrown value, clearing the slot (drains it exactly once).
79
+ pub fn take_pending_throw() -> Option<Value> {
80
+ PENDING_THROW.with(|c| c.borrow_mut().take())
81
+ }
82
+
83
+ thread_local! {
84
+ /// Current user-function call depth for the bytecode VM. The VM's recursive path builds a fresh
85
+ /// `Vm` per call (so no shared struct field can accumulate) and its `Callable::call` signature is
86
+ /// fixed — a thread-local is the VM's equivalent of the interpreter's shared `Rc<Cell>` counter.
87
+ /// Lives here (not `tish_vm`) beside `PENDING_THROW` for the same layering reason. #381
88
+ static CALL_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
89
+ }
90
+
91
+ /// Enter one VM call frame; returns the new depth. Pair every call with [`dec_call_depth`].
92
+ #[inline]
93
+ pub fn inc_call_depth() -> usize {
94
+ CALL_DEPTH.with(|c| {
95
+ let d = c.get() + 1;
96
+ c.set(d);
97
+ d
98
+ })
99
+ }
100
+
101
+ /// Leave one VM call frame.
102
+ #[inline]
103
+ pub fn dec_call_depth() {
104
+ CALL_DEPTH.with(|c| c.set(c.get().saturating_sub(1)));
105
+ }
106
+
107
+ /// Default recursion ceiling shared by every backend that counts call depth (#381). Chosen with the
108
+ /// interpreter: comfortably deep for real programs, but reached long before counted recursion can
109
+ /// exhaust memory or the stack.
110
+ pub const DEFAULT_MAX_CALL_DEPTH: usize = 20_000;
111
+
112
+ thread_local! {
113
+ // The recursion ceiling, lazily initialized from `TISH_MAX_CALL_DEPTH` (0 = uninitialized). A
114
+ // thread-local Cell (not OnceLock) so tests can override it per-thread without racing the
115
+ // process-wide env. Shared by the VM's guard and the native backend's boxed-call guard. #381
116
+ static MAX_CALL_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
117
+ }
118
+
119
+ /// The recursion ceiling for depth-counting guards: user-fn calls deeper than this raise a catchable
120
+ /// `RangeError` instead of overflowing the native stack. `TISH_MAX_CALL_DEPTH` overrides (default
121
+ /// [`DEFAULT_MAX_CALL_DEPTH`]). #381
122
+ pub fn max_call_depth() -> usize {
123
+ MAX_CALL_DEPTH.with(|c| {
124
+ let v = c.get();
125
+ if v != 0 {
126
+ return v;
127
+ }
128
+ let init = std::env::var("TISH_MAX_CALL_DEPTH")
129
+ .ok()
130
+ .and_then(|s| s.parse::<usize>().ok())
131
+ .filter(|&n| n > 0)
132
+ .unwrap_or(DEFAULT_MAX_CALL_DEPTH);
133
+ c.set(init);
134
+ init
135
+ })
136
+ }
137
+
138
+ /// Test hook: pin the ceiling for the current thread (bypasses the env read).
139
+ pub fn set_max_call_depth_for_test(n: usize) {
140
+ MAX_CALL_DEPTH.with(|c| c.set(n));
141
+ }
142
+
143
+ /// The catchable `RangeError` raised when a recursion guard trips. Built by hand (`tishlang_core`
144
+ /// sits below `tishlang_builtins`) but shape-identical to `construct_builtin::error_object`:
145
+ /// a `{ name, message }` object — keep the two in lock-step.
146
+ pub fn stack_overflow_error() -> Value {
147
+ let mut e = ObjectMap::default();
148
+ e.insert(
149
+ std::sync::Arc::from("name"),
150
+ Value::String("RangeError".into()),
151
+ );
152
+ e.insert(
153
+ std::sync::Arc::from("message"),
154
+ Value::String("Maximum call stack size exceeded".into()),
155
+ );
156
+ Value::object(e)
157
+ }
158
+
159
+ /// A catchable `TypeError` for calling a non-callable value — the `{ name, message }` shape matches
160
+ /// the VM/interpreter/node (`e.name === "TypeError"`). Used to PARK a pending throw (#381) instead of
161
+ /// aborting the process, so `value_call` on a non-function surfaces as a catchable error at the next
162
+ /// pending-throw checkpoint rather than an uncatchable native panic.
163
+ pub fn not_a_function_error(message: impl Into<String>) -> Value {
164
+ let mut e = ObjectMap::default();
165
+ e.insert(
166
+ std::sync::Arc::from("name"),
167
+ Value::String("TypeError".into()),
168
+ );
169
+ e.insert(
170
+ std::sync::Arc::from("message"),
171
+ Value::String(message.into().into()),
172
+ );
173
+ Value::object(e)
174
+ }
175
+
176
+ /// A catchable `TypeError` with an arbitrary message (`{ name: "TypeError", message }`), matching the
177
+ /// VM/interpreter/node shape. Used to PARK a pending throw from a shared builtin that returns a plain
178
+ /// `Value` and so cannot signal an error with `Result` (e.g. `[].reduce(fn)` with no initial value).
179
+ pub fn type_error(message: impl Into<String>) -> Value {
180
+ let mut e = ObjectMap::default();
181
+ e.insert(
182
+ std::sync::Arc::from("name"),
183
+ Value::String("TypeError".into()),
184
+ );
185
+ e.insert(
186
+ std::sync::Arc::from("message"),
187
+ Value::String(message.into().into()),
188
+ );
189
+ Value::object(e)
190
+ }
191
+
192
+ /// A string key names a canonical array index iff it is a non-negative integer whose decimal form
193
+ /// round-trips and is below the array-index ceiling (`2³²−1`): `"0"`, `"12"` → `Some`; `"01"`, `"-1"`,
194
+ /// `"1.5"`, `"foo"`, `" 1"`, `""` → `None`. Shared so `arr["0"] === arr[0]` on every backend (#432).
195
+ pub fn str_to_array_index(s: &str) -> Option<usize> {
196
+ let i: usize = s.parse().ok()?;
197
+ if i < 4_294_967_295 && i.to_string() == s {
198
+ Some(i)
199
+ } else {
200
+ None
201
+ }
202
+ }
203
+
204
+ /// A catchable `RangeError` with an arbitrary message (`{ name: "RangeError", message }`), matching the
205
+ /// VM/interpreter/node shape. Used to PARK a pending throw from a shared builtin that returns a plain
206
+ /// `Value` (e.g. `[1,2].with(5, x)` with an out-of-range index).
207
+ pub fn range_error(message: impl Into<String>) -> Value {
208
+ let mut e = ObjectMap::default();
209
+ e.insert(
210
+ std::sync::Arc::from("name"),
211
+ Value::String("RangeError".into()),
212
+ );
213
+ e.insert(
214
+ std::sync::Arc::from("message"),
215
+ Value::String(message.into().into()),
216
+ );
217
+ Value::object(e)
218
+ }
219
+
220
+ /// A catchable `TypeError` for reading a property/index of the nullish value — the `{ name, message }`
221
+ /// shape matches the VM/interpreter/node (`e.name === "TypeError"`). Used to PARK a pending throw in
222
+ /// the native/runtime `get_prop`/`get_index` null arm (#425), so `null.length` / `null[0]` surface a
223
+ /// catchable error at the next pending-throw checkpoint instead of silently reading back `null`.
224
+ pub fn cannot_read_property_error(prop: &str) -> Value {
225
+ let mut e = ObjectMap::default();
226
+ e.insert(
227
+ std::sync::Arc::from("name"),
228
+ Value::String("TypeError".into()),
229
+ );
230
+ e.insert(
231
+ std::sync::Arc::from("message"),
232
+ Value::String(format!("Cannot read property '{}' of null", prop).into()),
233
+ );
234
+ Value::object(e)
235
+ }
236
+
237
+ /// RAII frame for the depth-counting recursion guard: holding one means the depth was incremented;
238
+ /// dropping it decrements, so early returns in generated code can't leak a level. #381
239
+ pub struct CallDepthGuard(());
240
+
241
+ impl Drop for CallDepthGuard {
242
+ #[inline]
243
+ fn drop(&mut self) {
244
+ dec_call_depth();
245
+ }
246
+ }
247
+
248
+ /// Enter a counted user-fn call frame, or trip the recursion guard: past [`max_call_depth`] this
249
+ /// parks the catchable stack-overflow `RangeError` in the pending-throw slot and returns `None` —
250
+ /// the caller just returns its frame's dummy value (`Value::Null`) and the throw surfaces at the
251
+ /// next pending-throw checkpoint. The native backend emits this at the top of every boxed user-fn
252
+ /// closure. #381
253
+ #[inline]
254
+ pub fn enter_call_guarded() -> Option<CallDepthGuard> {
255
+ let depth = inc_call_depth();
256
+ if depth > max_call_depth() {
257
+ dec_call_depth();
258
+ set_pending_throw(stack_overflow_error());
259
+ return None;
260
+ }
261
+ Some(CallDepthGuard(()))
262
+ }
@@ -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 {
@@ -88,6 +88,51 @@ pub fn percent_encode(input: &str) -> String {
88
88
  result
89
89
  }
90
90
 
91
+ /// Percent-DECODE for `decodeURIComponent` — decodes EVERY `%XX` (unlike `percent_decode`/`decodeURI`,
92
+ /// which preserves the reserved delimiters). Bytes are collected then UTF-8 decoded, so multi-byte
93
+ /// escapes (`%E2%82%AC` → `€`) round-trip. Errors on a malformed `%` sequence (JS `URIError`).
94
+ pub fn percent_decode_component(input: &str) -> Result<String, String> {
95
+ let bytes = input.as_bytes();
96
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
97
+ let mut i = 0;
98
+ while i < bytes.len() {
99
+ if bytes[i] == b'%' {
100
+ if i + 2 < bytes.len() {
101
+ if let Some(b) = std::str::from_utf8(&bytes[i + 1..i + 3])
102
+ .ok()
103
+ .and_then(|s| u8::from_str_radix(s, 16).ok())
104
+ {
105
+ out.push(b);
106
+ i += 3;
107
+ continue;
108
+ }
109
+ }
110
+ return Err("URIError: malformed URI sequence".to_string());
111
+ }
112
+ out.push(bytes[i]);
113
+ i += 1;
114
+ }
115
+ String::from_utf8(out).map_err(|_| "URIError: malformed URI sequence".to_string())
116
+ }
117
+
118
+ /// Percent-encode for `encodeURIComponent` — escapes everything except the unreserved set
119
+ /// `A-Za-z0-9 - _ . ! ~ * ' ( )` (stricter than `encodeURI`, which also preserves the URI reserved
120
+ /// delimiters `; / ? : @ & = + $ , #`).
121
+ pub fn percent_encode_component(input: &str) -> String {
122
+ const UNRESERVED: &[char] = &['-', '_', '.', '!', '~', '*', '\'', '(', ')'];
123
+ let mut result = String::with_capacity(input.len());
124
+ for c in input.chars() {
125
+ if c.is_ascii_alphanumeric() || UNRESERVED.contains(&c) {
126
+ result.push(c);
127
+ } else {
128
+ for byte in c.to_string().as_bytes() {
129
+ result.push_str(&format!("%{:02X}", byte));
130
+ }
131
+ }
132
+ }
133
+ result
134
+ }
135
+
91
136
  #[cfg(test)]
92
137
  mod tests {
93
138
  use super::*;