@tishlang/tish-lsp 2.16.13 → 2.36.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 (52) hide show
  1. package/bin/tish-lsp +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  3. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  4. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  5. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  6. package/crates/tish/tests/fixtures/process_capture.tish +16 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/process_capture.rs +40 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +77 -3
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +195 -16
  13. package/crates/tish_builtins/src/collections.rs +37 -2
  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 +168 -15
  18. package/crates/tish_bytecode/src/compiler.rs +56 -4
  19. package/crates/tish_bytecode/src/opcode.rs +4 -2
  20. package/crates/tish_compile/src/codegen.rs +519 -46
  21. package/crates/tish_compile/src/infer.rs +26 -11
  22. package/crates/tish_compile/src/resolve.rs +87 -10
  23. package/crates/tish_compile_js/src/codegen.rs +82 -24
  24. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  25. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +17 -2
  26. package/crates/tish_core/src/json.rs +5 -1
  27. package/crates/tish_core/src/lib.rs +48 -2
  28. package/crates/tish_core/src/uri.rs +45 -0
  29. package/crates/tish_core/src/value.rs +6 -0
  30. package/crates/tish_eval/Cargo.toml +2 -1
  31. package/crates/tish_eval/src/eval.rs +661 -27
  32. package/crates/tish_eval/src/natives.rs +318 -18
  33. package/crates/tish_eval/src/regex.rs +47 -1
  34. package/crates/tish_fmt/src/lib.rs +23 -4
  35. package/crates/tish_lsp/Cargo.toml +1 -1
  36. package/crates/tish_opt/src/lib.rs +20 -1
  37. package/crates/tish_parser/src/lib.rs +41 -0
  38. package/crates/tish_parser/src/parser.rs +180 -11
  39. package/crates/tish_resolve/src/lib.rs +66 -5
  40. package/crates/tish_runtime/Cargo.toml +5 -1
  41. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  42. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  43. package/crates/tish_runtime/src/lib.rs +236 -10
  44. package/crates/tish_ui/src/jsx.rs +18 -3
  45. package/crates/tish_vm/src/jit.rs +1 -1
  46. package/crates/tish_vm/src/vm.rs +294 -60
  47. package/package.json +1 -1
  48. package/platform/darwin-arm64/tish-lsp +0 -0
  49. package/platform/darwin-x64/tish-lsp +0 -0
  50. package/platform/linux-arm64/tish-lsp +0 -0
  51. package/platform/linux-x64/tish-lsp +0 -0
  52. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -73,17 +73,13 @@ pub fn parse_float(args: &[Value]) -> Result<Value, String> {
73
73
  }
74
74
 
75
75
  pub fn is_finite(args: &[Value]) -> Result<Value, String> {
76
- let b = args
77
- .first()
78
- .is_some_and(|v| matches!(v, Value::Number(n) if n.is_finite()));
79
- Ok(Value::Bool(b))
76
+ // Global isFinite coerces via ToNumber (like Number()); absent arg (undefined) → NaN → false.
77
+ Ok(Value::Bool(args.first().map_or(f64::NAN, to_number).is_finite()))
80
78
  }
81
79
 
82
80
  pub fn is_nan(args: &[Value]) -> Result<Value, String> {
83
- let b = args.first().is_none_or(|v| {
84
- matches!(v, Value::Number(n) if n.is_nan()) || !matches!(v, Value::Number(_))
85
- });
86
- Ok(Value::Bool(b))
81
+ // Global isNaN coerces via ToNumber; absent arg (undefined) → NaN → true.
82
+ Ok(Value::Bool(args.first().map_or(f64::NAN, to_number).is_nan()))
87
83
  }
88
84
 
89
85
  pub fn boolean_native(args: &[Value]) -> Result<Value, String> {
@@ -103,6 +99,20 @@ pub fn encode_uri(args: &[Value]) -> Result<Value, String> {
103
99
  Ok(Value::String(tishlang_core::percent_encode(&s).into()))
104
100
  }
105
101
 
102
+ pub fn encode_uri_component(args: &[Value]) -> Result<Value, String> {
103
+ let s = args.first().map(|v| v.to_string()).unwrap_or_default();
104
+ Ok(Value::String(
105
+ tishlang_core::percent_encode_component(&s).into(),
106
+ ))
107
+ }
108
+
109
+ pub fn decode_uri_component(args: &[Value]) -> Result<Value, String> {
110
+ let s = args.first().map(|v| v.to_string()).unwrap_or_default();
111
+ Ok(Value::String(
112
+ tishlang_core::percent_decode_component(&s).unwrap_or(s).into(),
113
+ ))
114
+ }
115
+
106
116
  pub fn html_escape(args: &[Value]) -> Result<Value, String> {
107
117
  let input = match args.first() {
108
118
  Some(Value::String(s)) => s.to_string(),
@@ -170,12 +180,12 @@ pub fn math_max(args: &[Value]) -> Result<Value, String> {
170
180
  }
171
181
 
172
182
  // hypot/asin/acos/atan/atan2 existed on the vm's Math but not the interpreter's — a direct interp≠vm
173
- // gap (#247). These are 1:1 with Rust f64 methods (same as the builtins), so compute directly; hypot
174
- // defaults missing args to 0.0 to match `tishlang_builtins::math::hypot`.
183
+ // gap (#247). asin/acos/atan/atan2 are 1:1 with Rust f64 methods, so compute directly. hypot is
184
+ // VARIADIC in JS and must propagate NaN/Infinity, so it delegates to the shared `hypot_f64` (exactly
185
+ // like min/max) — a 2-arg `x.hypot(y)` gave `Math.hypot(3,4,12)` = 5 instead of 13.
175
186
  pub fn math_hypot(args: &[Value]) -> Result<Value, String> {
176
- let x = args.first().map(get_num).unwrap_or(0.0);
177
- let y = args.get(1).map(get_num).unwrap_or(0.0);
178
- Ok(Value::Number(x.hypot(y)))
187
+ let nums: Vec<f64> = args.iter().map(get_num).collect();
188
+ Ok(Value::Number(tishlang_builtins::math::hypot_f64(&nums)))
179
189
  }
180
190
 
181
191
  pub fn math_asin(args: &[Value]) -> Result<Value, String> {
@@ -226,6 +236,17 @@ pub fn math_pow(args: &[Value]) -> Result<Value, String> {
226
236
  Ok(Value::Number(base.powf(exp)))
227
237
  }
228
238
 
239
+ /// ES6 `Math.imul`: an exact 32-bit integer multiply. Both operands go through JS `ToInt32`
240
+ /// (modulo 2³², NaN/±Infinity → 0), NOT a saturating `as i32` cast, then a wrapping i32 multiply —
241
+ /// bit-for-bit identical to the shared `tishlang_builtins::math::imul`, the vm, the native path, and
242
+ /// V8. Was previously absent from the interpreter's `Math` object, so `Math.imul(...)` threw
243
+ /// "Not a function" in `--backend interp` while working natively (a cross-backend divergence).
244
+ pub fn math_imul(args: &[Value]) -> Result<Value, String> {
245
+ let a = tishlang_core::to_int32(get_num(args.first().unwrap_or(&Value::Null)));
246
+ let b = tishlang_core::to_int32(get_num(args.get(1).unwrap_or(&Value::Null)));
247
+ Ok(Value::Number(a.wrapping_mul(b) as f64))
248
+ }
249
+
229
250
  pub fn math_sin(args: &[Value]) -> Result<Value, String> {
230
251
  Ok(Value::Number(
231
252
  get_num(args.first().unwrap_or(&Value::Null)).sin(),
@@ -265,7 +286,8 @@ pub fn math_sign(args: &[Value]) -> Result<Value, String> {
265
286
  } else if n < 0.0 {
266
287
  -1.0
267
288
  } else {
268
- 0.0
289
+ // ±0 → return the input so `Math.sign(-0)` is `-0` (per ES); bare `0.0` drops the sign.
290
+ n
269
291
  };
270
292
  Ok(Value::Number(sign))
271
293
  }
@@ -297,6 +319,17 @@ math_unary!(math_atanh, atanh);
297
319
  math_unary!(math_cbrt, cbrt);
298
320
  math_unary!(math_log2, log2);
299
321
  math_unary!(math_log10, log10);
322
+ math_unary!(math_expm1, exp_m1);
323
+ math_unary!(math_log1p, ln_1p);
324
+
325
+ pub fn math_clz32(args: &[Value]) -> Result<Value, String> {
326
+ let n = tishlang_core::to_uint32(get_num(args.first().unwrap_or(&Value::Null)));
327
+ Ok(Value::Number(n.leading_zeros() as f64))
328
+ }
329
+ pub fn math_fround(args: &[Value]) -> Result<Value, String> {
330
+ let x = get_num(args.first().unwrap_or(&Value::Null));
331
+ Ok(Value::Number(x as f32 as f64))
332
+ }
300
333
 
301
334
  pub fn array_is_array(args: &[Value]) -> Result<Value, String> {
302
335
  Ok(Value::Bool(matches!(args.first(), Some(Value::Array(_)))))
@@ -337,6 +370,47 @@ pub fn array_construct(args: &[Value]) -> Result<Value, String> {
337
370
  Ok(Value::array(args.to_vec()))
338
371
  }
339
372
 
373
+ /// `Array.of(...items)` — args become elements (a single number is NOT a length, unlike `Array(n)`).
374
+ pub fn array_of(args: &[Value]) -> Result<Value, String> {
375
+ Ok(Value::array(args.to_vec()))
376
+ }
377
+
378
+ /// `Object.is(a, b)` — SameValue: like `===`, but NaN equals NaN and `+0` is not `-0`.
379
+ pub fn object_is(args: &[Value]) -> Result<Value, String> {
380
+ let a = args.first().unwrap_or(&Value::Null);
381
+ let b = args.get(1).unwrap_or(&Value::Null);
382
+ let same = match (a, b) {
383
+ (Value::Number(x), Value::Number(y)) => {
384
+ if x.is_nan() && y.is_nan() {
385
+ true
386
+ } else if *x == 0.0 && *y == 0.0 {
387
+ x.is_sign_negative() == y.is_sign_negative()
388
+ } else {
389
+ x == y
390
+ }
391
+ }
392
+ _ => a.strict_eq(b),
393
+ };
394
+ Ok(Value::Bool(same))
395
+ }
396
+
397
+ /// `Object.hasOwn(obj, key)` (ES2022) — true if `key` is an OWN property of `obj`. Mirrors the shared
398
+ /// `tishlang_builtins::globals::object_has_own` on the interpreter's `Value` type.
399
+ pub fn object_has_own(args: &[Value]) -> Result<Value, String> {
400
+ let key = match args.get(1) {
401
+ Some(Value::String(s)) => s.to_string(),
402
+ Some(v) => v.to_string(),
403
+ None => return Ok(Value::Bool(false)),
404
+ };
405
+ Ok(match args.first() {
406
+ Some(Value::Object(obj)) => Value::Bool(obj.borrow().strings.contains_key(key.as_str())),
407
+ Some(Value::Array(arr)) => Value::Bool(
408
+ key == "length" || matches!(key.parse::<usize>(), Ok(i) if i < arr.borrow().len()),
409
+ ),
410
+ _ => Value::Bool(false),
411
+ })
412
+ }
413
+
340
414
  /// `String(value)` as a function: JS `ToString` coercion (arrays comma-join recursively,
341
415
  /// objects → "[object Object]", null → "null"), matching the VM/native `string_convert`.
342
416
  pub fn string_convert(args: &[Value]) -> Result<Value, String> {
@@ -347,8 +421,13 @@ pub fn string_convert(args: &[Value]) -> Result<Value, String> {
347
421
  /// `Number(value)` coercion (issue #36) — shares the string parser with the VM/native
348
422
  /// backend so the result is byte-identical.
349
423
  pub fn number_convert(args: &[Value]) -> Result<Value, String> {
350
- let v = args.first().unwrap_or(&Value::Null);
351
- let n = match v {
424
+ Ok(Value::Number(to_number(args.first().unwrap_or(&Value::Null))))
425
+ }
426
+
427
+ /// JS `ToNumber(v)` for the interpreter's Value — shares the string parser with the VM/native path
428
+ /// so `Number()`, `isNaN`, `isFinite` are byte-identical across backends.
429
+ pub fn to_number(v: &Value) -> f64 {
430
+ match v {
352
431
  Value::Number(n) => *n,
353
432
  Value::Bool(b) => {
354
433
  if *b {
@@ -360,8 +439,22 @@ pub fn number_convert(args: &[Value]) -> Result<Value, String> {
360
439
  Value::Null => 0.0,
361
440
  Value::String(s) => tishlang_builtins::globals::parse_numeric_string(s),
362
441
  other => tishlang_builtins::globals::parse_numeric_string(&other.to_js_string()),
363
- };
364
- Ok(Value::Number(n))
442
+ }
443
+ }
444
+
445
+ // `Number.*` static predicates — no coercion (the coercing forms are the bare globals).
446
+ pub fn number_is_integer(args: &[Value]) -> Result<Value, String> {
447
+ Ok(Value::Bool(matches!(args.first(), Some(Value::Number(n)) if n.is_finite() && n.fract() == 0.0)))
448
+ }
449
+ pub fn number_is_safe_integer(args: &[Value]) -> Result<Value, String> {
450
+ Ok(Value::Bool(matches!(args.first(),
451
+ Some(Value::Number(n)) if n.is_finite() && n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0)))
452
+ }
453
+ pub fn number_is_nan(args: &[Value]) -> Result<Value, String> {
454
+ Ok(Value::Bool(matches!(args.first(), Some(Value::Number(n)) if n.is_nan())))
455
+ }
456
+ pub fn number_is_finite(args: &[Value]) -> Result<Value, String> {
457
+ Ok(Value::Bool(matches!(args.first(), Some(Value::Number(n)) if n.is_finite())))
365
458
  }
366
459
 
367
460
  pub fn string_from_char_code(args: &[Value]) -> Result<Value, String> {
@@ -430,6 +523,60 @@ pub fn process_exec_file(args: &[Value]) -> Result<Value, String> {
430
523
  Ok(Value::Number(output.status.code().unwrap_or(1) as f64))
431
524
  }
432
525
 
526
+ /// `{ code, stdout, stderr }` capture object (#475) — lossy-UTF-8 streams (fs convention). Insertion
527
+ /// order (code, stdout, stderr) matches the vm/native builders so JSON.stringify agrees cross-backend.
528
+ #[cfg(feature = "process")]
529
+ fn process_capture_obj(code: i32, stdout: String, stderr: String) -> Value {
530
+ let mut m = crate::value::PropMap::default();
531
+ m.insert("code".into(), Value::Number(code as f64));
532
+ m.insert("stdout".into(), Value::String(stdout.into()));
533
+ m.insert("stderr".into(), Value::String(stderr.into()));
534
+ Value::object(m)
535
+ }
536
+
537
+ #[cfg(feature = "process")]
538
+ fn process_capture_result(out: std::io::Result<std::process::Output>) -> Value {
539
+ match out {
540
+ Ok(o) => process_capture_obj(
541
+ o.status.code().unwrap_or(-1),
542
+ String::from_utf8_lossy(&o.stdout).into_owned(),
543
+ String::from_utf8_lossy(&o.stderr).into_owned(),
544
+ ),
545
+ Err(e) => process_capture_obj(-1, String::new(), e.to_string()),
546
+ }
547
+ }
548
+
549
+ /// `process.execCapture(cmd)` — `sh -c cmd`, capturing `{ code, stdout, stderr }` (#475).
550
+ #[cfg(feature = "process")]
551
+ pub fn process_exec_capture(args: &[Value]) -> Result<Value, String> {
552
+ use std::process::Command;
553
+ let cmd = args.first().map(|v| v.to_string()).unwrap_or_default();
554
+ if cmd.is_empty() {
555
+ return Ok(process_capture_obj(0, String::new(), String::new()));
556
+ }
557
+ Ok(process_capture_result(
558
+ Command::new("sh").arg("-c").arg(&cmd).output(),
559
+ ))
560
+ }
561
+
562
+ /// `process.execFileCapture(program, [args])` — no-shell run with output capture (safe for untrusted
563
+ /// args, #384/#475). Returns `{ code, stdout, stderr }`.
564
+ #[cfg(feature = "process")]
565
+ pub fn process_exec_file_capture(args: &[Value]) -> Result<Value, String> {
566
+ use std::process::Command;
567
+ let program = args.first().map(|v| v.to_string()).unwrap_or_default();
568
+ if program.is_empty() {
569
+ return Ok(process_capture_obj(0, String::new(), String::new()));
570
+ }
571
+ let argv: Vec<String> = match args.get(1) {
572
+ Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_string()).collect(),
573
+ _ => Vec::new(),
574
+ };
575
+ Ok(process_capture_result(
576
+ Command::new(&program).args(&argv).output(),
577
+ ))
578
+ }
579
+
433
580
  #[cfg(feature = "fs")]
434
581
  pub fn read_file(args: &[Value]) -> Result<Value, String> {
435
582
  let path = args.first().map(|v| v.to_string()).unwrap_or_default();
@@ -455,6 +602,109 @@ pub fn read_file_bytes(args: &[Value]) -> Result<Value, String> {
455
602
  }
456
603
  }
457
604
 
605
+ // ── Node-compatible fs surface (#122) — bridged to tishlang_runtime::fs_ext so the interpreter
606
+ // shares one implementation with the VM/native backends. eval args -> core, call, core -> eval.
607
+ #[cfg(feature = "fs")]
608
+ macro_rules! fs_bridge {
609
+ ($name:ident => $core:path) => {
610
+ pub fn $name(args: &[Value]) -> Result<Value, String> {
611
+ let core_args = args
612
+ .iter()
613
+ .map(crate::value_convert::eval_to_core)
614
+ .collect::<Result<Vec<_>, String>>()?;
615
+ Ok(crate::value_convert::core_to_eval($core(&core_args)))
616
+ }
617
+ };
618
+ }
619
+
620
+ #[cfg(feature = "fs")]
621
+ pub mod fsx {
622
+ use super::Value;
623
+ use tishlang_runtime::fs_ext as fx;
624
+ // sync
625
+ fs_bridge!(read_file => fx::read_file);
626
+ fs_bridge!(read_file_bytes => fx::read_file_bytes);
627
+ fs_bridge!(write_file => fx::write_file);
628
+ fs_bridge!(append_file => fx::append_file);
629
+ fs_bridge!(exists => fx::exists);
630
+ fs_bridge!(mkdir => fx::mkdir);
631
+ fs_bridge!(readdir => fx::readdir);
632
+ fs_bridge!(stat => fx::stat);
633
+ fs_bridge!(lstat => fx::lstat);
634
+ fs_bridge!(rm => fx::rm);
635
+ fs_bridge!(rmdir => fx::rmdir);
636
+ fs_bridge!(unlink => fx::unlink);
637
+ fs_bridge!(rename => fx::rename);
638
+ fs_bridge!(copy_file => fx::copy_file);
639
+ fs_bridge!(realpath => fx::realpath);
640
+ fs_bridge!(readlink => fx::readlink);
641
+ fs_bridge!(truncate => fx::truncate);
642
+ fs_bridge!(mkdtemp => fx::mkdtemp);
643
+ fs_bridge!(cp => fx::cp);
644
+ fs_bridge!(access => fx::access);
645
+ // promises
646
+ fs_bridge!(read_file_p => fx::read_file_promise);
647
+ fs_bridge!(read_file_bytes_p => fx::read_file_bytes_promise);
648
+ fs_bridge!(write_file_p => fx::write_file_promise);
649
+ fs_bridge!(append_file_p => fx::append_file_promise);
650
+ fs_bridge!(exists_p => fx::exists_promise);
651
+ fs_bridge!(mkdir_p => fx::mkdir_promise);
652
+ fs_bridge!(readdir_p => fx::readdir_promise);
653
+ fs_bridge!(stat_p => fx::stat_promise);
654
+ fs_bridge!(lstat_p => fx::lstat_promise);
655
+ fs_bridge!(rm_p => fx::rm_promise);
656
+ fs_bridge!(rmdir_p => fx::rmdir_promise);
657
+ fs_bridge!(unlink_p => fx::unlink_promise);
658
+ fs_bridge!(rename_p => fx::rename_promise);
659
+ fs_bridge!(copy_file_p => fx::copy_file_promise);
660
+ fs_bridge!(realpath_p => fx::realpath_promise);
661
+ fs_bridge!(readlink_p => fx::readlink_promise);
662
+ fs_bridge!(truncate_p => fx::truncate_promise);
663
+ fs_bridge!(mkdtemp_p => fx::mkdtemp_promise);
664
+ fs_bridge!(cp_p => fx::cp_promise);
665
+ fs_bridge!(access_p => fx::access_promise);
666
+ pub fn constants() -> Value {
667
+ crate::value_convert::core_to_eval(fx::constants())
668
+ }
669
+
670
+ /// Core op behind a Node-callback-style fs function (Ok = data, Err = error value), if `f` is one.
671
+ /// The interpreter binds fs to self-less `Value::Native`s that can't invoke an eval callback, so
672
+ /// `call_func` uses this to run the sync op and split its Result into `(err, data)` for the
673
+ /// callback form (#122). Covers both the old tish aliases (`readFile` → `natives::read_file`) and
674
+ /// the `fsx::*` bridges. `exists`/`access` are omitted — they answer a boolean and never error.
675
+ pub type CoreOp = fn(&[tishlang_core::Value]) -> Result<tishlang_core::Value, tishlang_core::Value>;
676
+ pub fn callback_core(f: crate::value::NativeFn) -> Option<CoreOp> {
677
+ use std::ptr::fn_addr_eq;
678
+ type NF = crate::value::NativeFn;
679
+ // Node names bound to the old tish aliases (natives::*).
680
+ if fn_addr_eq(f, super::read_file as NF) { return Some(fx::read_file_core); }
681
+ if fn_addr_eq(f, super::write_file as NF) { return Some(fx::write_file_core); }
682
+ if fn_addr_eq(f, super::read_file_bytes as NF) { return Some(fx::read_file_bytes_core); }
683
+ if fn_addr_eq(f, super::read_dir as NF) { return Some(fx::readdir_core); }
684
+ if fn_addr_eq(f, super::mkdir as NF) { return Some(fx::mkdir_core); }
685
+ // The `fsx::*` bridges (Node *Sync names and the ops with no old alias).
686
+ if fn_addr_eq(f, read_file as NF) { return Some(fx::read_file_core); }
687
+ if fn_addr_eq(f, write_file as NF) { return Some(fx::write_file_core); }
688
+ if fn_addr_eq(f, append_file as NF) { return Some(fx::append_file_core); }
689
+ if fn_addr_eq(f, read_file_bytes as NF) { return Some(fx::read_file_bytes_core); }
690
+ if fn_addr_eq(f, readdir as NF) { return Some(fx::readdir_core); }
691
+ if fn_addr_eq(f, stat as NF) { return Some(fx::stat_core); }
692
+ if fn_addr_eq(f, lstat as NF) { return Some(fx::lstat_core); }
693
+ if fn_addr_eq(f, mkdir as NF) { return Some(fx::mkdir_core); }
694
+ if fn_addr_eq(f, rm as NF) { return Some(fx::rm_core); }
695
+ if fn_addr_eq(f, rmdir as NF) { return Some(fx::rmdir_core); }
696
+ if fn_addr_eq(f, unlink as NF) { return Some(fx::unlink_core); }
697
+ if fn_addr_eq(f, rename as NF) { return Some(fx::rename_core); }
698
+ if fn_addr_eq(f, copy_file as NF) { return Some(fx::copy_file_core); }
699
+ if fn_addr_eq(f, cp as NF) { return Some(fx::cp_core); }
700
+ if fn_addr_eq(f, realpath as NF) { return Some(fx::realpath_core); }
701
+ if fn_addr_eq(f, readlink as NF) { return Some(fx::readlink_core); }
702
+ if fn_addr_eq(f, truncate as NF) { return Some(fx::truncate_core); }
703
+ if fn_addr_eq(f, mkdtemp as NF) { return Some(fx::mkdtemp_core); }
704
+ None
705
+ }
706
+ }
707
+
458
708
  #[cfg(feature = "fs")]
459
709
  pub fn write_file(args: &[Value]) -> Result<Value, String> {
460
710
  let path = args.first().map(|v| v.to_string()).unwrap_or_default();
@@ -584,3 +834,53 @@ pub fn tty_read(args: &[Value]) -> Result<Value, String> {
584
834
  None => Value::Null,
585
835
  })
586
836
  }
837
+
838
+ /// `structuredClone(value)` — recursive deep copy of arrays/objects/primitives with cycles preserved
839
+ /// (a repeated reference clones to the same new node). Matches the shared vm/native impl in
840
+ /// `tishlang_builtins::globals::structured_clone`; functions and exotic types are shared/shallow.
841
+ pub fn structured_clone(args: &[Value]) -> Result<Value, String> {
842
+ let mut seen: Vec<(*const (), Value)> = Vec::new();
843
+ Ok(deep_clone(args.first().unwrap_or(&Value::Null), &mut seen))
844
+ }
845
+
846
+ fn deep_clone(v: &Value, seen: &mut Vec<(*const (), Value)>) -> Value {
847
+ use std::rc::Rc;
848
+ match v {
849
+ Value::Array(a) => {
850
+ let ptr = Rc::as_ptr(a) as *const ();
851
+ if let Some((_, c)) = seen.iter().find(|(p, _)| *p == ptr) {
852
+ return c.clone();
853
+ }
854
+ let new = Value::array(Vec::new());
855
+ if let Value::Array(na) = &new {
856
+ seen.push((ptr, new.clone()));
857
+ let src = a.borrow().clone();
858
+ let cloned: Vec<Value> = src.iter().map(|e| deep_clone(e, seen)).collect();
859
+ *na.borrow_mut() = cloned;
860
+ }
861
+ new
862
+ }
863
+ Value::Object(o) => {
864
+ let ptr = Rc::as_ptr(o) as *const ();
865
+ if let Some((_, c)) = seen.iter().find(|(p, _)| *p == ptr) {
866
+ return c.clone();
867
+ }
868
+ let entries: Vec<(std::sync::Arc<str>, Value)> = o
869
+ .borrow()
870
+ .strings
871
+ .iter()
872
+ .map(|(k, val)| (k.clone(), val.clone()))
873
+ .collect();
874
+ let new = Value::object(crate::value::PropMap::default());
875
+ if let Value::Object(no) = &new {
876
+ seen.push((ptr, new.clone()));
877
+ for (k, val) in entries {
878
+ let cloned = deep_clone(&val, seen);
879
+ no.borrow_mut().strings.insert(k, cloned);
880
+ }
881
+ }
882
+ new
883
+ }
884
+ other => other.clone(),
885
+ }
886
+ }
@@ -147,6 +147,34 @@ pub fn string_match(input: &str, regexp: &Value) -> Value {
147
147
  }
148
148
  }
149
149
 
150
+ /// `String.prototype.matchAll(regexp)` — collects every match as an exec-style match object (reusing
151
+ /// `regexp_exec`). Returns `Err(())` if a non-global RegExp is passed (the caller raises a TypeError);
152
+ /// a non-RegExp argument is coerced to a global regex. The caller wraps the results in an iterator.
153
+ pub fn string_match_all(input: &str, regexp: &Value) -> Result<Vec<Value>, ()> {
154
+ let re_cell = match regexp {
155
+ Value::RegExp(re) => re.clone(),
156
+ other => match TishRegExp::new(&other.to_string(), "g") {
157
+ Ok(re) => Rc::new(RefCell::new(re)),
158
+ Err(_) => return Ok(Vec::new()),
159
+ },
160
+ };
161
+ if !re_cell.borrow().flags.global {
162
+ return Err(());
163
+ }
164
+ let mut results = Vec::new();
165
+ let mut re = re_cell.borrow_mut();
166
+ re.last_index = 0;
167
+ loop {
168
+ let m = regexp_exec(&mut re, input);
169
+ if matches!(m, Value::Null) {
170
+ break;
171
+ }
172
+ results.push(m);
173
+ }
174
+ re.last_index = 0;
175
+ Ok(results)
176
+ }
177
+
150
178
  /// String.prototype.replace(searchValue, replaceValue)
151
179
  pub fn string_replace(input: &str, search: &Value, replace: &Value) -> Value {
152
180
  let replacement = match replace {
@@ -170,7 +198,25 @@ pub fn string_replace(input: &str, search: &Value, replace: &Value) -> Value {
170
198
  }
171
199
  }
172
200
  Value::String(pattern) => {
173
- Value::String(input.replacen(pattern.as_ref(), &replacement, 1).into())
201
+ let p = pattern.as_ref();
202
+ if replacement.contains('$') && !p.is_empty() {
203
+ if let Some(m) = input.find(p) {
204
+ let mut out = String::with_capacity(input.len());
205
+ out.push_str(&input[..m]);
206
+ out.push_str(&tishlang_builtins::string::expand_replacement(
207
+ &replacement,
208
+ p,
209
+ &input[..m],
210
+ &input[m + p.len()..],
211
+ ));
212
+ out.push_str(&input[m + p.len()..]);
213
+ Value::String(out.into())
214
+ } else {
215
+ Value::String(input.into())
216
+ }
217
+ } else {
218
+ Value::String(input.replacen(p, &replacement, 1).into())
219
+ }
174
220
  }
175
221
  _ => Value::String(input.into()),
176
222
  }
@@ -613,6 +613,20 @@ impl Printer {
613
613
  self.buf.push_str(") ");
614
614
  self.stmt_inline_or_block(body, level);
615
615
  }
616
+ Statement::ForIn {
617
+ name,
618
+ object,
619
+ body,
620
+ ..
621
+ } => {
622
+ self.indent(level);
623
+ self.buf.push_str("for (let ");
624
+ self.buf.push_str(name);
625
+ self.buf.push_str(" in ");
626
+ self.expr(object);
627
+ self.buf.push_str(") ");
628
+ self.stmt_inline_or_block(body, level);
629
+ }
616
630
  Statement::Return { value, .. } => {
617
631
  self.indent(level);
618
632
  self.buf.push_str("return");
@@ -825,8 +839,9 @@ impl Printer {
825
839
  ..
826
840
  } => {
827
841
  if *all {
842
+ // `export *` always has a source.
828
843
  self.buf.push_str("* from \"");
829
- self.buf.push_str(from);
844
+ self.buf.push_str(from.as_deref().unwrap_or(""));
830
845
  self.buf.push('"');
831
846
  } else {
832
847
  self.buf.push_str("{ ");
@@ -844,9 +859,13 @@ impl Printer {
844
859
  }
845
860
  }
846
861
  }
847
- self.buf.push_str(" } from \"");
848
- self.buf.push_str(from);
849
- self.buf.push('"');
862
+ self.buf.push_str(" }");
863
+ // #415: a local named export (`export { a }`) has no `from` clause.
864
+ if let Some(from) = from {
865
+ self.buf.push_str(" from \"");
866
+ self.buf.push_str(from);
867
+ self.buf.push('"');
868
+ }
850
869
  }
851
870
  }
852
871
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lsp"
3
- version = "2.16.13"
3
+ version = "2.36.0"
4
4
  edition = "2021"
5
5
  description = "Language Server Protocol implementation for Tish"
6
6
  license-file = { workspace = true }
@@ -118,6 +118,19 @@ fn optimize_statement(stmt: &Statement) -> Statement {
118
118
  body: Box::new(optimize_statement(body)),
119
119
  span: *span,
120
120
  },
121
+ Statement::ForIn {
122
+ name,
123
+ name_span,
124
+ object,
125
+ body,
126
+ span,
127
+ } => Statement::ForIn {
128
+ name: Arc::clone(name),
129
+ name_span: *name_span,
130
+ object: optimize_expr(object),
131
+ body: Box::new(optimize_statement(body)),
132
+ span: *span,
133
+ },
121
134
  Statement::Return { value, span } => Statement::Return {
122
135
  value: value.as_ref().map(optimize_expr),
123
136
  span: *span,
@@ -500,12 +513,18 @@ fn optimize_expr(expr: &Expr) -> Expr {
500
513
  value: Box::new(optimize_expr(value)),
501
514
  span: *span,
502
515
  },
503
- Expr::ArrowFunction { params, body, span } => {
516
+ Expr::ArrowFunction {
517
+ async_,
518
+ params,
519
+ body,
520
+ span,
521
+ } => {
504
522
  let opt_body = match body {
505
523
  ArrowBody::Expr(e) => ArrowBody::Expr(Box::new(optimize_expr(e))),
506
524
  ArrowBody::Block(s) => ArrowBody::Block(Box::new(optimize_statement(s))),
507
525
  };
508
526
  Expr::ArrowFunction {
527
+ async_: *async_,
509
528
  params: params.clone(),
510
529
  body: opt_body,
511
530
  span: *span,
@@ -60,6 +60,47 @@ mod tests {
60
60
  assert!(parse("fn f() { if (a) { if (b) { return 1 } } }").is_ok());
61
61
  }
62
62
 
63
+ /// #244: loose equality `==` / `!=` is rejected uniformly at parse (across every backend), with an
64
+ /// actionable message pointing at the strict form. `===` / `!==` still parse.
65
+ #[test]
66
+ fn loose_equality_is_rejected_at_parse() {
67
+ let eq = parse("let b = 1 == 1").unwrap_err();
68
+ assert!(eq.contains("'=='") && eq.contains("'==='"), "got: {eq}");
69
+ let ne = parse("let b = 1 != 2").unwrap_err();
70
+ assert!(ne.contains("'!='") && ne.contains("'!=='"), "got: {ne}");
71
+ // Rejected wherever an expression is parsed, not just at top level.
72
+ assert!(parse("fn f(x) { if (x == 0) { return 1 } return 0 }").is_err());
73
+ assert!(parse("let y = a != null && a.b").is_err());
74
+ // Strict forms are unaffected.
75
+ assert!(parse("let b = 1 === 1").is_ok());
76
+ assert!(parse("let b = 1 !== 2").is_ok());
77
+ assert!(parse("let y = a !== null && a.b").is_ok());
78
+ }
79
+
80
+ /// #428: `async` in expression position parses as an async arrow (paren and single-param forms);
81
+ /// non-async arrows keep `async_: false`.
82
+ #[test]
83
+ fn async_arrow_expressions_parse() {
84
+ use tishlang_ast::Expr;
85
+ let arrow_async = |src: &str| -> bool {
86
+ let p = parse(src).expect("parse");
87
+ let Some(Statement::VarDecl { init: Some(e), .. }) = p.statements.first() else {
88
+ panic!("expected `let x = <arrow>`");
89
+ };
90
+ match e {
91
+ Expr::ArrowFunction { async_, .. } => *async_,
92
+ other => panic!("expected ArrowFunction, got {other:?}"),
93
+ }
94
+ };
95
+ assert!(arrow_async("let f = async () => 1"), "async paren arrow");
96
+ assert!(arrow_async("let g = async x => x + 1"), "async single-param arrow");
97
+ assert!(arrow_async("let h = async (a, b) => a + b"), "async multi-param arrow");
98
+ assert!(!arrow_async("let f = () => 1"), "plain arrow is not async");
99
+ assert!(!arrow_async("let g = x => x"), "plain single-param arrow is not async");
100
+ // `async` not followed by an arrow in expr position is an error, not a silent identifier.
101
+ assert!(parse("let x = async 5").is_err());
102
+ }
103
+
63
104
  #[test]
64
105
  fn test_async_fn_parse() {
65
106
  let program = parse("async fn foo() { }").expect("parse async fn");