@tishlang/tish 1.13.2 → 2.0.1

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 (106) hide show
  1. package/Cargo.toml +2 -0
  2. package/bin/tish +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +1 -0
  4. package/crates/tish/Cargo.toml +11 -3
  5. package/crates/tish/build.rs +21 -0
  6. package/crates/tish/src/cli_help.rs +15 -4
  7. package/crates/tish/src/main.rs +93 -21
  8. package/crates/tish/src/repl_completion.rs +0 -1
  9. package/crates/tish/tests/error_source_location.rs +36 -0
  10. package/crates/tish/tests/fixtures/runtime_error_location.tish +5 -0
  11. package/crates/tish/tests/fixtures/trycatch_runtime_errors.tish +15 -0
  12. package/crates/tish/tests/fixtures/tty_capability.tish +9 -0
  13. package/crates/tish/tests/integration_test.rs +402 -91
  14. package/crates/tish/tests/trycatch_runtime_errors.rs +45 -0
  15. package/crates/tish/tests/tty_capability.rs +43 -0
  16. package/crates/tish_ast/src/ast.rs +37 -8
  17. package/crates/tish_builtins/Cargo.toml +2 -0
  18. package/crates/tish_builtins/src/array.rs +375 -13
  19. package/crates/tish_builtins/src/collections.rs +481 -0
  20. package/crates/tish_builtins/src/construct.rs +59 -19
  21. package/crates/tish_builtins/src/date.rs +538 -0
  22. package/crates/tish_builtins/src/globals.rs +86 -6
  23. package/crates/tish_builtins/src/iterator.rs +129 -0
  24. package/crates/tish_builtins/src/lib.rs +5 -0
  25. package/crates/tish_builtins/src/number.rs +96 -0
  26. package/crates/tish_builtins/src/object.rs +2 -2
  27. package/crates/tish_builtins/src/string.rs +19 -20
  28. package/crates/tish_builtins/src/symbol.rs +1 -1
  29. package/crates/tish_builtins/src/typedarrays.rs +298 -0
  30. package/crates/tish_bytecode/src/chunk.rs +69 -1
  31. package/crates/tish_bytecode/src/compiler.rs +933 -89
  32. package/crates/tish_bytecode/src/encoding.rs +2 -0
  33. package/crates/tish_bytecode/src/lib.rs +2 -1
  34. package/crates/tish_bytecode/src/opcode.rs +47 -4
  35. package/crates/tish_bytecode/src/serialize.rs +31 -1
  36. package/crates/tish_compile/Cargo.toml +1 -0
  37. package/crates/tish_compile/src/check.rs +774 -0
  38. package/crates/tish_compile/src/codegen.rs +2334 -349
  39. package/crates/tish_compile/src/infer.rs +1395 -6
  40. package/crates/tish_compile/src/lib.rs +50 -8
  41. package/crates/tish_compile/src/resolve.rs +584 -21
  42. package/crates/tish_compile/src/types.rs +106 -2
  43. package/crates/tish_compile_js/src/codegen.rs +67 -0
  44. package/crates/tish_compile_js/src/tests_jsx.rs +64 -0
  45. package/crates/tish_core/Cargo.toml +7 -1
  46. package/crates/tish_core/src/console_style.rs +11 -1
  47. package/crates/tish_core/src/json.rs +81 -38
  48. package/crates/tish_core/src/lib.rs +3 -0
  49. package/crates/tish_core/src/shape.rs +85 -0
  50. package/crates/tish_core/src/value.rs +679 -25
  51. package/crates/tish_core/src/vmref.rs +13 -8
  52. package/crates/tish_cranelift/src/link.rs +17 -4
  53. package/crates/tish_cranelift_runtime/Cargo.toml +1 -0
  54. package/crates/tish_eval/Cargo.toml +6 -0
  55. package/crates/tish_eval/src/eval.rs +665 -117
  56. package/crates/tish_eval/src/http.rs +4 -1
  57. package/crates/tish_eval/src/natives.rs +165 -13
  58. package/crates/tish_eval/src/value.rs +31 -13
  59. package/crates/tish_eval/src/value_convert.rs +10 -4
  60. package/crates/tish_ffi/Cargo.toml +26 -0
  61. package/crates/tish_ffi/src/lib.rs +518 -0
  62. package/crates/tish_ffi/tests/fixtures/testmod/Cargo.toml +18 -0
  63. package/crates/tish_ffi/tests/fixtures/testmod/src/lib.rs +46 -0
  64. package/crates/tish_ffi/tests/loader.rs +65 -0
  65. package/crates/tish_fmt/src/lib.rs +61 -5
  66. package/crates/tish_lexer/src/lib.rs +397 -9
  67. package/crates/tish_lexer/src/token.rs +7 -0
  68. package/crates/tish_lint/src/lib.rs +2 -10
  69. package/crates/tish_lsp/src/import_goto.rs +2 -0
  70. package/crates/tish_lsp/src/main.rs +439 -26
  71. package/crates/tish_native/src/build.rs +55 -1
  72. package/crates/tish_opt/src/lib.rs +126 -23
  73. package/crates/tish_parser/src/lib.rs +55 -1
  74. package/crates/tish_parser/src/parser.rs +456 -34
  75. package/crates/tish_pg/src/lib.rs +3 -3
  76. package/crates/tish_resolve/src/lib.rs +99 -59
  77. package/crates/tish_runtime/Cargo.toml +4 -0
  78. package/crates/tish_runtime/src/http.rs +66 -17
  79. package/crates/tish_runtime/src/http_fetch.rs +29 -8
  80. package/crates/tish_runtime/src/http_hyper.rs +25 -2
  81. package/crates/tish_runtime/src/lib.rs +299 -44
  82. package/crates/tish_runtime/src/promise.rs +328 -18
  83. package/crates/tish_runtime/src/timers.rs +13 -7
  84. package/crates/tish_runtime/src/tty.rs +226 -0
  85. package/crates/tish_runtime/src/ws.rs +35 -18
  86. package/crates/tish_runtime/tests/fetch_readable_stream.rs +2 -2
  87. package/crates/tish_ui/src/jsx.rs +10 -0
  88. package/crates/tish_ui/src/runtime/hooks.rs +19 -15
  89. package/crates/tish_ui/src/runtime/mod.rs +15 -12
  90. package/crates/tish_vm/Cargo.toml +14 -1
  91. package/crates/tish_vm/src/jit.rs +1050 -0
  92. package/crates/tish_vm/src/lib.rs +2 -0
  93. package/crates/tish_vm/src/vm.rs +1546 -202
  94. package/crates/tish_vm/tests/concurrent_shared_state.rs +140 -0
  95. package/crates/tish_wasm/src/lib.rs +6 -2
  96. package/crates/tish_wasm_runtime/src/gpu.rs +17 -1
  97. package/crates/tishlang_cargo_bindgen/src/classify.rs +1 -3
  98. package/crates/tishlang_cargo_bindgen/src/lib.rs +2 -2
  99. package/crates/tishlang_cargo_bindgen/src/metadata.rs +1 -1
  100. package/justfile +8 -0
  101. package/package.json +1 -1
  102. package/platform/darwin-arm64/tish +0 -0
  103. package/platform/darwin-x64/tish +0 -0
  104. package/platform/linux-arm64/tish +0 -0
  105. package/platform/linux-x64/tish +0 -0
  106. package/platform/win32-x64/tish.exe +0 -0
@@ -2,7 +2,28 @@
2
2
  //!
3
3
  //! The global `Promise` value is an **object** with a `__call` entry so the VM can
4
4
  //! invoke `Promise(executor)` like `new Promise(executor)` in JS. Static methods live
5
- //! on the same object (`resolve`, `reject`, `all`, `race`).
5
+ //! on the same object (`resolve`, `reject`, `all`, `race`, `any`, `allSettled`, `spawn`).
6
+ //!
7
+ //! ## Concurrency model for race / any / allSettled / spawn
8
+ //!
9
+ //! `TishPromise::block_until_settled` is a *blocking* call. To wait on "whichever of N
10
+ //! settles first" without serializing them, we spawn one OS thread per promise — each
11
+ //! calls `block_until_settled` and forwards the result (with its index) to a shared
12
+ //! `mpsc::channel`. The main thread reads from that channel:
13
+ //! - `race` → first message wins (fulfilled or rejected).
14
+ //! - `any` → first *fulfilled* message wins; collect rejections; if all reject →
15
+ //! `AggregateError` (array of reasons).
16
+ //! - `allSettled` → drain all N messages, sort by index, build `{status,value|reason}`.
17
+ //!
18
+ //! This requires `Value: Send`, which holds under the `send-values` feature (all handles
19
+ //! become `Arc<Mutex<…>>`). The `send-values` feature is enabled in every build that has
20
+ //! `http` (i.e. the shipped `full` binary). Without it (wasm / wasi) we fall back to a
21
+ //! sequential path — correct but not concurrent.
22
+ //!
23
+ //! `Promise.spawn(fn)` runs `fn()` on a fresh OS thread and returns a Promise. This is
24
+ //! the primitive for CPU-bound and GPU-bound work (e.g. `Promise.spawn(() => matmul(…))`
25
+ //! from `tish:mlx` or `tish:metal`). The thread is an ordinary OS thread, not a tokio
26
+ //! task, so it does not contend with the I/O runtime.
6
27
 
7
28
  use std::sync::mpsc;
8
29
  use std::sync::{Arc, Mutex};
@@ -32,6 +53,12 @@ impl TishPromise for ImmediateSettledPromise {
32
53
  "Promise already settled or consumed".into(),
33
54
  )))
34
55
  }
56
+ /// Always already settled — return the result immediately without blocking.
57
+ fn try_settle(&self) -> Option<std::result::Result<Value, Value>> {
58
+ Some(self.slot.lock().unwrap().take().unwrap_or(
59
+ Err(Value::String("Promise already consumed".into())),
60
+ ))
61
+ }
35
62
  }
36
63
 
37
64
  fn fulfilled(v: Value) -> Value {
@@ -66,6 +93,30 @@ impl TishPromise for DeferredChannelPromise {
66
93
  )),
67
94
  }
68
95
  }
96
+
97
+ /// Non-blocking settle: if the executor has already called resolve/reject (the channel
98
+ /// has a message waiting), return it immediately. Returns `None` if the work is still
99
+ /// pending (channel empty). This lets `race`/`any`/`allSettled` handle already-settled
100
+ /// `new Promise(executor)` promises in input-order without spawning threads.
101
+ fn try_settle(&self) -> Option<std::result::Result<Value, Value>> {
102
+ let mut lock = self.rx.lock().unwrap();
103
+ match lock.as_ref() {
104
+ None => Some(Err(Value::String("Promise already consumed".into()))),
105
+ Some(rx) => match rx.try_recv() {
106
+ Ok(r) => {
107
+ *lock = None; // consumed — block_until_settled would error now (correct)
108
+ Some(r)
109
+ }
110
+ Err(mpsc::TryRecvError::Disconnected) => {
111
+ *lock = None;
112
+ Some(Err(Value::String(
113
+ "Promise executor did not call resolve or reject".into(),
114
+ )))
115
+ }
116
+ Err(mpsc::TryRecvError::Empty) => None, // still pending
117
+ },
118
+ }
119
+ }
69
120
  }
70
121
 
71
122
  /// `.then` / `.catch` chain: when awaited, settle the predecessor then optionally invoke a handler.
@@ -80,14 +131,14 @@ impl TishPromise for ThenPromise {
80
131
  match self.pred.block_until_settled() {
81
132
  Ok(v) => {
82
133
  if let Some(Value::Function(f)) = &self.on_fulfilled {
83
- flatten_chain_out(f(&[v]))
134
+ flatten_chain_out(f.call(&[v]))
84
135
  } else {
85
136
  Ok(v)
86
137
  }
87
138
  }
88
139
  Err(e) => {
89
140
  if let Some(Value::Function(f)) = &self.on_rejected {
90
- flatten_chain_out(f(&[e]))
141
+ flatten_chain_out(f.call(&[e]))
91
142
  } else {
92
143
  Err(e)
93
144
  }
@@ -137,24 +188,252 @@ pub fn promise_all(args: &[Value]) -> Value {
137
188
  }
138
189
  }
139
190
 
140
- /// `Promise.race(iterable)` — first element wins (blocking first promise if it is one).
141
- pub fn promise_race(args: &[Value]) -> Value {
191
+ // ---------------------------------------------------------------------------
192
+ // Concurrent combinators (race / any / allSettled) + Promise.spawn
193
+ //
194
+ // All three combinators need to wait on multiple promises concurrently. We
195
+ // spawn one OS thread per promise; each thread calls block_until_settled and
196
+ // sends (index, Result) to a shared mpsc channel on the calling thread.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /// Extract the array of items from `Promise.all/race/any/allSettled(array)`.
200
+ fn combinator_items(args: &[Value]) -> Option<Vec<Value>> {
142
201
  match args.first() {
143
- Some(Value::Array(arr)) => {
144
- let borrowed = arr.borrow();
145
- for v in borrowed.iter() {
146
- if let Value::Promise(p) = v {
147
- return match p.block_until_settled() {
148
- Ok(x) => fulfilled(x),
149
- Err(e) => rejected(e),
150
- };
202
+ Some(Value::Array(arr)) => Some(arr.borrow().clone()),
203
+ _ => None,
204
+ }
205
+ }
206
+
207
+ /// Concurrent settlement channel for `race`/`any`/`allSettled`.
208
+ ///
209
+ /// **Two-phase:** already-settled promises (`try_settle` returns `Some`) are handled
210
+ /// inline in input-order before any threads are spawned. This gives deterministic
211
+ /// JS-compatible ordering for already-settled inputs (e.g. `Promise.any([rej, ok, ok])`
212
+ /// reliably returns the first fulfilled, not a random thread-schedule winner). Only
213
+ /// genuinely-pending promises (e.g. from `Promise.spawn`) go to background threads,
214
+ /// which is where concurrency matters.
215
+ ///
216
+ /// Returns the receiving end of the channel plus the count of items it will send.
217
+ #[cfg(feature = "send-values")]
218
+ #[allow(clippy::type_complexity)]
219
+ fn race_channel(
220
+ items: Vec<Value>,
221
+ ) -> (mpsc::Receiver<(usize, std::result::Result<Value, Value>)>, usize) {
222
+ let (tx, rx) = mpsc::channel::<(usize, std::result::Result<Value, Value>)>();
223
+ let mut count = 0usize;
224
+ for (i, v) in items.into_iter().enumerate() {
225
+ count += 1;
226
+ match v {
227
+ Value::Promise(ref p) => {
228
+ // Phase 1: try non-blocking settle (ImmediateSettledPromise, ThenPromise
229
+ // over immediate, etc.). These never need a thread; handle in order.
230
+ if let Some(r) = p.try_settle() {
231
+ let _ = tx.send((i, r));
232
+ } else {
233
+ // Phase 2: genuinely pending — spawn a thread.
234
+ let p = Arc::clone(p);
235
+ let tx = tx.clone();
236
+ std::thread::spawn(move || {
237
+ let r = p.block_until_settled();
238
+ let _ = tx.send((i, r));
239
+ });
240
+ }
241
+ }
242
+ other => {
243
+ let _ = tx.send((i, Ok(other)));
244
+ }
245
+ }
246
+ }
247
+ drop(tx); // closes the channel when all senders finish
248
+ (rx, count)
249
+ }
250
+
251
+ /// `Promise.race(iterable)` — first to settle (fulfilled or rejected) wins.
252
+ /// Fixed: genuinely concurrent — the old impl only ever blocked on element 0.
253
+ pub fn promise_race(args: &[Value]) -> Value {
254
+ let items = match combinator_items(args) {
255
+ Some(v) => v,
256
+ None => return fulfilled(args.first().cloned().unwrap_or(Value::Null)),
257
+ };
258
+ if items.is_empty() {
259
+ return rejected(Value::String("Promise.race: empty iterable".into()));
260
+ }
261
+ #[cfg(feature = "send-values")]
262
+ {
263
+ let (rx, _) = race_channel(items);
264
+ match rx.recv() {
265
+ Ok((_, Ok(v))) => fulfilled(v),
266
+ Ok((_, Err(e))) => rejected(e),
267
+ Err(_) => rejected(Value::String("Promise.race: all promises dropped".into())),
268
+ }
269
+ }
270
+ #[cfg(not(feature = "send-values"))]
271
+ {
272
+ // Sequential fallback (no threads): first item wins, whether promise or value.
273
+ for item in items {
274
+ return match item {
275
+ Value::Promise(p) => match p.block_until_settled() {
276
+ Ok(v) => fulfilled(v),
277
+ Err(e) => rejected(e),
278
+ },
279
+ other => fulfilled(other),
280
+ };
281
+ }
282
+ rejected(Value::String("Promise.race: empty iterable".into()))
283
+ }
284
+ }
285
+
286
+ /// `Promise.any(iterable)` — resolves with the **first fulfilled** value.
287
+ /// Rejects with an array of all rejection reasons only if every promise rejects
288
+ /// (matching the JS `AggregateError.errors` convention — we return the array
289
+ /// directly, not wrapped, to keep things simple without a full AggregateError class).
290
+ pub fn promise_any(args: &[Value]) -> Value {
291
+ let items = match combinator_items(args) {
292
+ Some(v) => v,
293
+ None => return fulfilled(args.first().cloned().unwrap_or(Value::Null)),
294
+ };
295
+ if items.is_empty() {
296
+ return rejected(Value::Array(VmRef::new(vec![])));
297
+ }
298
+ let n = items.len();
299
+ #[cfg(feature = "send-values")]
300
+ {
301
+ let (rx, sent) = race_channel(items);
302
+ let mut errors = vec![Value::Null; n];
303
+ let mut reject_count = 0usize;
304
+ // Drain the channel: the first fulfilled result wins immediately; collect
305
+ // all rejections in case every promise rejects.
306
+ let mut received = 0usize;
307
+ while received < sent {
308
+ match rx.recv() {
309
+ Ok((_, Ok(v))) => return fulfilled(v), // first fulfillment wins
310
+ Ok((i, Err(e))) => {
311
+ errors[i] = e;
312
+ reject_count += 1;
313
+ received += 1;
314
+ if reject_count == sent {
315
+ return rejected(Value::Array(VmRef::new(errors)));
316
+ }
151
317
  }
152
- return fulfilled(v.clone());
318
+ Err(_) => break,
153
319
  }
154
- Value::Null
155
320
  }
156
- Some(v) => fulfilled(v.clone()),
157
- None => Value::Null,
321
+ rejected(Value::Array(VmRef::new(errors)))
322
+ }
323
+ #[cfg(not(feature = "send-values"))]
324
+ {
325
+ // Sequential: return first fulfilled, or array of all rejections.
326
+ let mut errors = Vec::with_capacity(n);
327
+ for item in items {
328
+ match item {
329
+ Value::Promise(p) => match p.block_until_settled() {
330
+ Ok(v) => return fulfilled(v),
331
+ Err(e) => errors.push(e),
332
+ },
333
+ other => return fulfilled(other),
334
+ }
335
+ }
336
+ rejected(Value::Array(VmRef::new(errors)))
337
+ }
338
+ }
339
+
340
+ /// `Promise.allSettled(iterable)` — always fulfills with an array of outcome objects.
341
+ /// Each entry is `{status:"fulfilled",value:v}` or `{status:"rejected",reason:e}`.
342
+ pub fn promise_all_settled(args: &[Value]) -> Value {
343
+ let items = match combinator_items(args) {
344
+ Some(v) => v,
345
+ None => return fulfilled(Value::Array(VmRef::new(vec![]))),
346
+ };
347
+ let n = items.len();
348
+ if n == 0 {
349
+ return fulfilled(Value::Array(VmRef::new(vec![])));
350
+ }
351
+
352
+ fn make_settled(r: std::result::Result<Value, Value>) -> Value {
353
+ let mut obj = ObjectMap::default();
354
+ match r {
355
+ Ok(v) => {
356
+ obj.insert(Arc::from("status"), Value::String("fulfilled".into()));
357
+ obj.insert(Arc::from("value"), v);
358
+ }
359
+ Err(e) => {
360
+ obj.insert(Arc::from("status"), Value::String("rejected".into()));
361
+ obj.insert(Arc::from("reason"), e);
362
+ }
363
+ }
364
+ Value::object(obj)
365
+ }
366
+
367
+ #[cfg(feature = "send-values")]
368
+ {
369
+ let (rx, _) = race_channel(items);
370
+ let mut results = vec![None::<std::result::Result<Value, Value>>; n];
371
+ while let Ok((i, r)) = rx.recv() {
372
+ results[i] = Some(r);
373
+ }
374
+ let out: Vec<Value> = results
375
+ .into_iter()
376
+ .map(|r| make_settled(r.unwrap_or(Err(Value::String("Promise dropped".into())))))
377
+ .collect();
378
+ fulfilled(Value::Array(VmRef::new(out)))
379
+ }
380
+ #[cfg(not(feature = "send-values"))]
381
+ {
382
+ let out: Vec<Value> = items.into_iter().map(|item| {
383
+ let r = match item {
384
+ Value::Promise(p) => p.block_until_settled(),
385
+ other => Ok(other),
386
+ };
387
+ make_settled(r)
388
+ }).collect();
389
+ fulfilled(Value::Array(VmRef::new(out)))
390
+ }
391
+ }
392
+
393
+ /// `Promise.spawn(fn)` — run `fn()` on a background OS thread and return a Promise
394
+ /// that resolves with the function's return value. This is the key primitive for
395
+ /// CPU-bound and GPU-bound work:
396
+ ///
397
+ /// ```tish
398
+ /// import { matmul } from 'tish:mlx'
399
+ /// let result = await Promise.any([
400
+ /// Promise.spawn(() => matmul(a, b, N)), // MLX GPU path
401
+ /// Promise.spawn(() => fallback(a, b, N)), // CPU fallback
402
+ /// ])
403
+ /// ```
404
+ ///
405
+ /// Under `send-values` (the shipped `full` build), the function runs on a real OS
406
+ /// thread; other threads can proceed concurrently. Without `send-values` (wasm/wasi),
407
+ /// the function runs synchronously and the result is wrapped in an immediate promise.
408
+ pub fn promise_spawn(args: &[Value]) -> Value {
409
+ let f = match args.first() {
410
+ Some(Value::Function(f)) => Arc::clone(f),
411
+ _ => return rejected(Value::String("Promise.spawn: expected a function argument".into())),
412
+ };
413
+ #[cfg(feature = "send-values")]
414
+ {
415
+ let (tx, rx) = mpsc::channel::<std::result::Result<Value, Value>>();
416
+ std::thread::spawn(move || {
417
+ // Wrap in catch_unwind so a panicking GPU/CPU kernel rejects the promise
418
+ // rather than aborting the whole process.
419
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f.call(&[])));
420
+ let _ = tx.send(match result {
421
+ Ok(v) => Ok(v),
422
+ Err(_) => Err(Value::String("Promise.spawn: task panicked".into())),
423
+ });
424
+ });
425
+ Value::Promise(Arc::new(DeferredChannelPromise {
426
+ rx: Mutex::new(Some(rx)),
427
+ }))
428
+ }
429
+ #[cfg(not(feature = "send-values"))]
430
+ {
431
+ // No threads available (wasm/wasi): run synchronously, wrap result.
432
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f.call(&[])));
433
+ match result {
434
+ Ok(v) => fulfilled(v),
435
+ Err(_) => rejected(Value::String("Promise.spawn: task panicked".into())),
436
+ }
158
437
  }
159
438
  }
160
439
 
@@ -188,7 +467,7 @@ pub fn promise_object() -> Value {
188
467
  Value::Null
189
468
  }
190
469
  });
191
- let _ = f(&[resolve, reject]);
470
+ let _ = f.call(&[resolve, reject]);
192
471
  Value::Promise(Arc::new(DeferredChannelPromise {
193
472
  rx: Mutex::new(Some(rx)),
194
473
  }))
@@ -213,6 +492,18 @@ pub fn promise_object() -> Value {
213
492
  Arc::from("race"),
214
493
  Value::native(|args: &[Value]| promise_race(args)),
215
494
  );
495
+ map.insert(
496
+ Arc::from("any"),
497
+ Value::native(|args: &[Value]| promise_any(args)),
498
+ );
499
+ map.insert(
500
+ Arc::from("allSettled"),
501
+ Value::native(|args: &[Value]| promise_all_settled(args)),
502
+ );
503
+ map.insert(
504
+ Arc::from("spawn"),
505
+ Value::native(|args: &[Value]| promise_spawn(args)),
506
+ );
216
507
  Value::object(map)
217
508
  }
218
509
 
@@ -246,3 +537,22 @@ pub fn await_promise(v: Value) -> Value {
246
537
  v
247
538
  }
248
539
  }
540
+
541
+ /// Like [`await_promise`], but a REJECTED promise surfaces as a catchable throw rather than
542
+ /// silently yielding the rejection value. `await Promise.reject(x)` must throw `x` (so a
543
+ /// surrounding `try/catch` fires) — matching interp/vm/cranelift/wasi. The codegen emits this
544
+ /// variant (with `?`) wherever an error channel exists (inside a `try` body, or top-level `run()`),
545
+ /// and falls back to [`await_promise`] only where there is no channel (a nested value-fn with no
546
+ /// enclosing try), mirroring how `throw` is lowered.
547
+ pub fn await_promise_throw(v: Value) -> Result<Value, Box<dyn std::error::Error>> {
548
+ if let Value::Promise(p) = v {
549
+ match p.block_until_settled() {
550
+ Ok(val) => Ok(val),
551
+ Err(rejection) => {
552
+ Err(Box::new(crate::TishError::Throw(rejection)) as Box<dyn std::error::Error>)
553
+ }
554
+ }
555
+ } else {
556
+ Ok(v)
557
+ }
558
+ }
@@ -56,7 +56,7 @@ fn run_due_timers() {
56
56
  }
57
57
  for (id, callback, args, interval_ms) in due {
58
58
  if let Value::Function(f) = &callback {
59
- let _ = f(&args);
59
+ let _ = f.call(&args);
60
60
  }
61
61
  if interval_ms > 0 {
62
62
  re_register_interval(id, callback, args, interval_ms);
@@ -69,15 +69,21 @@ fn take_due_timers() -> Vec<(u64, Value, Vec<Value>, u64)> {
69
69
  let now = Instant::now();
70
70
  REGISTRY.with(|r| {
71
71
  let mut reg = r.borrow_mut();
72
- let due: Vec<_> = reg
72
+ let mut due: Vec<_> = reg
73
73
  .iter()
74
74
  .filter(|(_, e)| e.due <= now)
75
- .map(|(id, e)| (*id, e.callback.clone(), e.args.clone(), e.interval_ms))
75
+ .map(|(id, e)| (e.due, *id, e.callback.clone(), e.args.clone(), e.interval_ms))
76
76
  .collect();
77
- for (id, _, _, _) in &due {
77
+ // Deterministic JS timer order: earliest `due` first, ties broken by registration order
78
+ // (the monotonic id). REGISTRY is a HashMap whose iteration order is otherwise arbitrary,
79
+ // which scrambled same-delay timers (e.g. three `setTimeout(_, 0)` firing out of order).
80
+ due.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
81
+ for (_, id, _, _, _) in &due {
78
82
  reg.remove(id);
79
83
  }
80
- due
84
+ due.into_iter()
85
+ .map(|(_, id, cb, args, iv)| (id, cb, args, iv))
86
+ .collect()
81
87
  })
82
88
  }
83
89
 
@@ -100,7 +106,7 @@ fn re_register_interval(id: u64, callback: Value, args: Vec<Value>, interval_ms:
100
106
  /// Callbacks run when run_due_timers() is invoked (e.g. from ws.receiveTimeout poll loop).
101
107
  pub fn set_timeout(args: &[Value]) -> Value {
102
108
  let callback = args.first().cloned().unwrap_or(Value::Null);
103
- let delay_ms = extract_num(args.get(1)).min(3600_000);
109
+ let delay_ms = extract_num(args.get(1)).min(3_600_000);
104
110
  let extra_args: Vec<Value> = args.iter().skip(2).cloned().collect();
105
111
  if matches!(callback, Value::Null) {
106
112
  return Value::Number(next_id() as f64);
@@ -124,7 +130,7 @@ pub fn set_timeout(args: &[Value]) -> Value {
124
130
  /// setInterval(callback, intervalMs, ...args) — first run after `intervalMs`, then repeats.
125
131
  pub fn set_interval(args: &[Value]) -> Value {
126
132
  let callback = args.first().cloned().unwrap_or(Value::Null);
127
- let interval_ms = extract_num(args.get(1)).min(3600_000);
133
+ let interval_ms = extract_num(args.get(1)).min(3_600_000);
128
134
  let extra_args: Vec<Value> = args.iter().skip(2).cloned().collect();
129
135
  if matches!(callback, Value::Null) {
130
136
  return Value::Number(next_id() as f64);
@@ -0,0 +1,226 @@
1
+ //! Interactive terminal I/O for Tish (issue #101), behind the `tty` feature.
2
+ //!
3
+ //! Exposes raw mode, the alternate screen, terminal size, and key/resize **events** via
4
+ //! `crossterm`, so Tish programs can build interactive TUIs (menus, forms, live keyboard
5
+ //! navigation). Imported as `import { … } from 'tish:tty'`.
6
+ //!
7
+ //! The Value-agnostic core (`size`, `is_tty`, `set_raw_mode`, `read_event`, …) returns plain
8
+ //! Rust data so every backend — the bytecode VM (via the `tty_*` wrappers here) and the
9
+ //! tree-walk interpreter (whose `Value` is a distinct type) — can build its own `Value` from
10
+ //! the same logic. Errors surface as `null`/`false` rather than panicking.
11
+
12
+ use std::io::{IsTerminal, Write};
13
+ use std::sync::Arc;
14
+ use std::time::Duration;
15
+
16
+ use tishlang_core::{ObjectMap, Value};
17
+
18
+ use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
19
+ use crossterm::terminal;
20
+
21
+ /// A terminal event delivered by [`read_event`]. Plain data so each backend maps it to its
22
+ /// own `Value` object.
23
+ pub enum TtyEvent {
24
+ Key {
25
+ key: String,
26
+ ctrl: bool,
27
+ alt: bool,
28
+ shift: bool,
29
+ },
30
+ Resize {
31
+ cols: u16,
32
+ rows: u16,
33
+ },
34
+ /// Mouse / focus / paste — reported generically so an input loop can ignore it.
35
+ Other,
36
+ }
37
+
38
+ // ── Value-agnostic core (shared by every backend) ───────────────────────────────────────
39
+
40
+ /// Terminal `(cols, rows)`, or `None` if stdout is not connected to a terminal.
41
+ ///
42
+ /// Gated on `stdout().is_terminal()` to match Node (`process.stdout.columns` is
43
+ /// `undefined` when stdout is not a TTY). Without the gate, `crossterm::terminal::size()`
44
+ /// can still succeed via the controlling terminal even when stdout is redirected — so a
45
+ /// piped run (e.g. CI, `tish run x.tish | cat`) would report a bogus size instead of null.
46
+ pub fn size() -> Option<(u16, u16)> {
47
+ if std::io::stdout().is_terminal() {
48
+ terminal::size().ok()
49
+ } else {
50
+ None
51
+ }
52
+ }
53
+
54
+ /// Whether stdin **and** stdout are connected to a terminal.
55
+ pub fn is_tty() -> bool {
56
+ std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
57
+ }
58
+
59
+ /// Enter (cbreak) or leave raw mode. Returns `true` on success.
60
+ pub fn set_raw_mode(enabled: bool) -> bool {
61
+ if enabled {
62
+ terminal::enable_raw_mode().is_ok()
63
+ } else {
64
+ terminal::disable_raw_mode().is_ok()
65
+ }
66
+ }
67
+
68
+ /// Switch to / from the alternate screen buffer (full-screen apps).
69
+ pub fn enter_alt_screen() -> bool {
70
+ let mut out = std::io::stdout();
71
+ let ok = crossterm::execute!(out, terminal::EnterAlternateScreen).is_ok();
72
+ let _ = out.flush();
73
+ ok
74
+ }
75
+ pub fn leave_alt_screen() -> bool {
76
+ let mut out = std::io::stdout();
77
+ let ok = crossterm::execute!(out, terminal::LeaveAlternateScreen).is_ok();
78
+ let _ = out.flush();
79
+ ok
80
+ }
81
+
82
+ /// Read the next terminal event. `timeout_ms = None` blocks; `Some(ms)` polls for `ms`
83
+ /// milliseconds (0 = non-blocking) and returns `None` on timeout. Key-release events
84
+ /// (Windows) are skipped, yielding `None`.
85
+ pub fn read_event(timeout_ms: Option<u64>) -> Option<TtyEvent> {
86
+ if let Some(ms) = timeout_ms {
87
+ match event::poll(Duration::from_millis(ms)) {
88
+ Ok(true) => {}
89
+ _ => return None,
90
+ }
91
+ }
92
+ match event::read().ok()? {
93
+ Event::Key(k) => {
94
+ if k.kind == KeyEventKind::Release {
95
+ return None;
96
+ }
97
+ Some(TtyEvent::Key {
98
+ key: key_code_name(k.code),
99
+ ctrl: k.modifiers.contains(KeyModifiers::CONTROL),
100
+ alt: k.modifiers.contains(KeyModifiers::ALT),
101
+ shift: k.modifiers.contains(KeyModifiers::SHIFT),
102
+ })
103
+ }
104
+ Event::Resize(cols, rows) => Some(TtyEvent::Resize { cols, rows }),
105
+ _ => Some(TtyEvent::Other),
106
+ }
107
+ }
108
+
109
+ /// Read one line of **cooked** input from stdin (line mode), with the trailing newline
110
+ /// stripped. Returns `None` at EOF. For raw key-by-key input use [`read_event`].
111
+ pub fn read_line() -> Option<String> {
112
+ use std::io::BufRead;
113
+ let mut s = String::new();
114
+ match std::io::stdin().lock().read_line(&mut s) {
115
+ Ok(0) => None,
116
+ Ok(_) => {
117
+ while s.ends_with('\n') || s.ends_with('\r') {
118
+ s.pop();
119
+ }
120
+ Some(s)
121
+ }
122
+ Err(_) => None,
123
+ }
124
+ }
125
+
126
+ /// Normalize a crossterm key code to a stable JS-friendly name (`"a"`, `"Enter"`, `"Up"`,
127
+ /// `"Esc"`, `"F1"`, …).
128
+ fn key_code_name(code: KeyCode) -> String {
129
+ match code {
130
+ KeyCode::Char(c) => c.to_string(),
131
+ KeyCode::Enter => "Enter".into(),
132
+ KeyCode::Esc => "Esc".into(),
133
+ KeyCode::Backspace => "Backspace".into(),
134
+ KeyCode::Tab => "Tab".into(),
135
+ KeyCode::BackTab => "BackTab".into(),
136
+ KeyCode::Delete => "Delete".into(),
137
+ KeyCode::Insert => "Insert".into(),
138
+ KeyCode::Home => "Home".into(),
139
+ KeyCode::End => "End".into(),
140
+ KeyCode::PageUp => "PageUp".into(),
141
+ KeyCode::PageDown => "PageDown".into(),
142
+ KeyCode::Up => "Up".into(),
143
+ KeyCode::Down => "Down".into(),
144
+ KeyCode::Left => "Left".into(),
145
+ KeyCode::Right => "Right".into(),
146
+ KeyCode::F(n) => format!("F{n}"),
147
+ KeyCode::Null => "Null".into(),
148
+ other => format!("{other:?}"),
149
+ }
150
+ }
151
+
152
+ // ── core::Value wrappers for the bytecode VM / native runtime ────────────────────────────
153
+
154
+ fn obj(pairs: Vec<(&str, Value)>) -> Value {
155
+ let mut m = ObjectMap::default();
156
+ for (k, v) in pairs {
157
+ m.insert(Arc::from(k), v);
158
+ }
159
+ Value::object(m)
160
+ }
161
+
162
+ /// `size()` → `{ cols, rows }` or `null`.
163
+ pub fn tty_size(_args: &[Value]) -> Value {
164
+ match size() {
165
+ Some((cols, rows)) => obj(vec![
166
+ ("cols", Value::Number(cols as f64)),
167
+ ("rows", Value::Number(rows as f64)),
168
+ ]),
169
+ None => Value::Null,
170
+ }
171
+ }
172
+
173
+ /// `isTTY()` → bool.
174
+ pub fn tty_is_tty(_args: &[Value]) -> Value {
175
+ Value::Bool(is_tty())
176
+ }
177
+
178
+ /// `setRawMode(enabled)` → bool (success).
179
+ pub fn tty_set_raw_mode(args: &[Value]) -> Value {
180
+ Value::Bool(set_raw_mode(args.first().map(|v| v.is_truthy()).unwrap_or(false)))
181
+ }
182
+
183
+ /// `enterAltScreen()` / `leaveAltScreen()` → bool.
184
+ pub fn tty_enter_alt_screen(_args: &[Value]) -> Value {
185
+ Value::Bool(enter_alt_screen())
186
+ }
187
+ pub fn tty_leave_alt_screen(_args: &[Value]) -> Value {
188
+ Value::Bool(leave_alt_screen())
189
+ }
190
+
191
+ /// `readLine()` → one line of cooked stdin (no trailing newline), or `null` at EOF.
192
+ pub fn tty_read_line(_args: &[Value]) -> Value {
193
+ match read_line() {
194
+ Some(s) => Value::String(s.into()),
195
+ None => Value::Null,
196
+ }
197
+ }
198
+
199
+ /// `read(timeoutMs?)` → an event object (`{ type, … }`) or `null`.
200
+ pub fn tty_read(args: &[Value]) -> Value {
201
+ let timeout = match args.first() {
202
+ Some(Value::Number(ms)) => Some(ms.max(0.0) as u64),
203
+ _ => None,
204
+ };
205
+ match read_event(timeout) {
206
+ Some(TtyEvent::Key {
207
+ key,
208
+ ctrl,
209
+ alt,
210
+ shift,
211
+ }) => obj(vec![
212
+ ("type", Value::String("key".into())),
213
+ ("key", Value::String(key.into())),
214
+ ("ctrl", Value::Bool(ctrl)),
215
+ ("alt", Value::Bool(alt)),
216
+ ("shift", Value::Bool(shift)),
217
+ ]),
218
+ Some(TtyEvent::Resize { cols, rows }) => obj(vec![
219
+ ("type", Value::String("resize".into())),
220
+ ("cols", Value::Number(cols as f64)),
221
+ ("rows", Value::Number(rows as f64)),
222
+ ]),
223
+ Some(TtyEvent::Other) => obj(vec![("type", Value::String("other".into()))]),
224
+ None => Value::Null,
225
+ }
226
+ }