@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
@@ -1,7 +1,7 @@
1
1
  //! Math builtin functions.
2
2
 
3
3
  use crate::helpers::extract_num;
4
- use tishlang_core::Value;
4
+ use tishlang_core::{to_int32, to_uint32, Value};
5
5
 
6
6
  macro_rules! math_unary {
7
7
  ($name:ident, $op:ident) => {
@@ -28,6 +28,20 @@ math_unary!(log2, log2);
28
28
  math_unary!(exp, exp);
29
29
  math_unary!(trunc, trunc);
30
30
  math_unary!(cbrt, cbrt);
31
+ math_unary!(expm1, exp_m1);
32
+ math_unary!(log1p, ln_1p);
33
+
34
+ /// `Math.clz32(x)` — count leading zero bits of `ToUint32(x)` (0..=32).
35
+ pub fn clz32(args: &[Value]) -> Value {
36
+ let n = to_uint32(extract_num(args.first()).unwrap_or(0.0));
37
+ Value::Number(n.leading_zeros() as f64)
38
+ }
39
+
40
+ /// `Math.fround(x)` — round `x` to the nearest 32-bit float, back to f64.
41
+ pub fn fround(args: &[Value]) -> Value {
42
+ let x = extract_num(args.first()).unwrap_or(f64::NAN);
43
+ Value::Number(x as f32 as f64)
44
+ }
31
45
 
32
46
  // --- f64-domain semantics (single source of truth) -------------------------------------------------
33
47
  // These hold the JS-specific Math rules so every backend agrees: the vm/native paths call the
@@ -119,7 +133,9 @@ pub fn sign(args: &[Value]) -> Value {
119
133
  } else if n < 0.0 {
120
134
  -1.0
121
135
  } else {
122
- 0.0
136
+ // ±0 → return the input unchanged so `Math.sign(-0)` is `-0` (per ES);
137
+ // `n < 0.0` is false for -0, so it lands here — bare `0.0` would drop the sign.
138
+ n
123
139
  })
124
140
  }
125
141
 
@@ -129,15 +145,57 @@ pub fn atan2(args: &[Value]) -> Value {
129
145
  Value::Number(y.atan2(x))
130
146
  }
131
147
 
148
+ /// ECMAScript `Math.hypot(...args)` core: sqrt of the sum of squares, VARIADIC and
149
+ /// NaN/Infinity-correct. Infinity takes precedence over NaN (any ±∞ arg → +∞, even alongside a NaN);
150
+ /// otherwise any NaN → NaN; the finite case is scaled by the max magnitude to avoid intermediate
151
+ /// overflow/underflow (matches V8). `Math.hypot()` (no args) → 0. Shared so interp/vm/native agree
152
+ /// (#247 — the interp/native path was 2-arg only, so `Math.hypot(3,4,12)` gave 5 not 13, and the vm
153
+ /// dropped non-number args instead of propagating NaN).
154
+ pub fn hypot_f64(nums: &[f64]) -> f64 {
155
+ let mut any_nan = false;
156
+ let mut max = 0.0_f64;
157
+ for &n in nums {
158
+ if n.is_infinite() {
159
+ return f64::INFINITY;
160
+ }
161
+ if n.is_nan() {
162
+ any_nan = true;
163
+ }
164
+ let a = n.abs();
165
+ if a > max {
166
+ max = a;
167
+ }
168
+ }
169
+ if any_nan {
170
+ return f64::NAN;
171
+ }
172
+ if max == 0.0 {
173
+ return 0.0;
174
+ }
175
+ let mut sum = 0.0_f64;
176
+ for &n in nums {
177
+ let r = n / max;
178
+ sum += r * r;
179
+ }
180
+ max * sum.sqrt()
181
+ }
182
+
132
183
  pub fn hypot(args: &[Value]) -> Value {
133
- let x = extract_num(args.first()).unwrap_or(0.0);
134
- let y = extract_num(args.get(1)).unwrap_or(0.0);
135
- Value::Number(x.hypot(y))
184
+ let nums: Vec<f64> = args
185
+ .iter()
186
+ .map(|v| extract_num(Some(v)).unwrap_or(f64::NAN))
187
+ .collect();
188
+ Value::Number(hypot_f64(&nums))
136
189
  }
137
190
 
138
191
  /// ES6 `Math.imul`: 32-bit integer multiply (used by xmur3 PRNG in juke-cards).
192
+ ///
193
+ /// Operands go through JS `ToInt32` (modulo 2³², NaN/±Infinity → 0), NOT a saturating `as i32`
194
+ /// cast: the latter clamps any argument ≥ 2³¹ (e.g. a full uint32 hash state) to `i32::MAX` and
195
+ /// diverges from ECMAScript / V8 (`Math.imul(3e9, 1)` = -1294967296, not 2147483647). The typed
196
+ /// native path inlines the same ToInt32 + wrapping multiply, so all backends agree.
139
197
  pub fn imul(args: &[Value]) -> Value {
140
- let a = extract_num(args.first()).unwrap_or(0.0) as i32;
141
- let b = extract_num(args.get(1)).unwrap_or(0.0) as i32;
198
+ let a = to_int32(extract_num(args.first()).unwrap_or(0.0));
199
+ let b = to_int32(extract_num(args.get(1)).unwrap_or(0.0));
142
200
  Value::Number(a.wrapping_mul(b) as f64)
143
201
  }
@@ -43,6 +43,179 @@ pub fn to_fixed_str(num: f64, digits: usize) -> String {
43
43
  format!("{:.*}", digits, rounded)
44
44
  }
45
45
 
46
+ /// `Number.prototype.toExponential(fractionDigits?)` — ECMA-262 §21.1.3.2. Exponential notation with
47
+ /// `fractionDigits` mantissa fraction digits (0–100), or the minimal digits needed when omitted. The
48
+ /// exponent always carries an explicit sign (`1.23e+4`, `1e-7`) to match V8.
49
+ pub fn to_exponential(n: &Value, digits: &Value) -> Value {
50
+ let num = match n {
51
+ Value::Number(x) => *x,
52
+ _ => f64::NAN,
53
+ };
54
+ let d = match digits {
55
+ Value::Number(x) => Some((*x as i32).clamp(0, 100) as usize),
56
+ _ => None,
57
+ };
58
+ Value::String(to_exponential_str(num, d).into())
59
+ }
60
+
61
+ /// f64-domain core of `toExponential`, shared with the tree-walk interpreter.
62
+ pub fn to_exponential_str(num: f64, digits: Option<usize>) -> String {
63
+ if num.is_nan() {
64
+ return "NaN".to_string();
65
+ }
66
+ if num.is_infinite() {
67
+ return if num < 0.0 { "-Infinity" } else { "Infinity" }.to_string();
68
+ }
69
+ let d = match digits {
70
+ // No fractionDigits: shortest round-trip mantissa (Rust's `{:e}` matches V8 here).
71
+ None => return fix_exponent_sign(&format!("{:e}", num)),
72
+ Some(d) => d,
73
+ };
74
+ if num == 0.0 {
75
+ let mant = if d == 0 {
76
+ "0".to_string()
77
+ } else {
78
+ format!("0.{}", "0".repeat(d))
79
+ };
80
+ return format!("{}e+0", mant);
81
+ }
82
+ let sign = if num < 0.0 { "-" } else { "" };
83
+ let (ds, e) = sig_digits(num.abs(), d + 1);
84
+ let mant = if d == 0 {
85
+ ds
86
+ } else {
87
+ format!("{}.{}", &ds[..1], &ds[1..])
88
+ };
89
+ let esign = if e >= 0 { "+" } else { "-" };
90
+ format!("{}{}e{}{}", sign, mant, esign, e.abs())
91
+ }
92
+
93
+ /// Round `x` (finite, `x > 0`) to `sig` significant decimal digits with **half-away-from-zero**
94
+ /// rounding — ECMA's "pick the larger n" tie rule (`2.5.toPrecision(1) === "3"`, not "2"). Returns the
95
+ /// `sig`-digit string and the base-10 exponent of the leading digit.
96
+ ///
97
+ /// The exact value of an f64 has a finite decimal expansion; scaling it through `f64` arithmetic
98
+ /// re-rounds and can land a genuinely-below-half value (e.g. `2.675` ≈ `2.67499…`) exactly on `.5`,
99
+ /// corrupting the tie test. So we format with many guard digits — `{:.Ne}` gives the correctly-rounded
100
+ /// exact-value expansion — capturing the TRUE digit at position `sig`, then round the digit string:
101
+ /// `digit[sig] >= 5` rounds up (half-away; `== 5` is a true tie only because the guard digits show no
102
+ /// nonzero remainder). Rust's `{:e}` alone rounds half-to-even, which is exactly what we must avoid.
103
+ fn sig_digits(x: f64, sig: usize) -> (String, i32) {
104
+ let sig = sig.max(1);
105
+ // Guard digits push the format's own (half-to-even) rounding far to the right of digit[sig], so
106
+ // digit[sig] is exact. A cascade into digit[sig] would need 16 consecutive 9s — not a real input.
107
+ let guard = sig + 16;
108
+ let raw = format!("{:.*e}", guard, x);
109
+ let (mant, exp_str) = raw.split_once('e').unwrap();
110
+ let mut e: i32 = exp_str.parse().unwrap_or(0);
111
+ let mut digits: Vec<u8> = mant
112
+ .bytes()
113
+ .filter(u8::is_ascii_digit)
114
+ .map(|b| b - b'0')
115
+ .collect();
116
+ if digits.len() > sig {
117
+ let round_up = digits[sig] >= 5;
118
+ digits.truncate(sig);
119
+ if round_up {
120
+ let mut i = sig;
121
+ loop {
122
+ if i == 0 {
123
+ // carry out of the leading digit: 9…9 → 1 0…0, one more place
124
+ digits.insert(0, 1);
125
+ digits.truncate(sig);
126
+ e += 1;
127
+ break;
128
+ }
129
+ i -= 1;
130
+ if digits[i] == 9 {
131
+ digits[i] = 0;
132
+ } else {
133
+ digits[i] += 1;
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ }
139
+ let s: String = digits.iter().map(|d| (d + b'0') as char).collect();
140
+ (s, e)
141
+ }
142
+
143
+ /// Rust's `{:e}` writes a bare positive exponent (`1.23e4`); JS/V8 always signs it (`1.23e+4`). Negative
144
+ /// exponents already carry `-`.
145
+ fn fix_exponent_sign(s: &str) -> String {
146
+ match s.split_once('e') {
147
+ Some((mantissa, exp)) if !exp.starts_with('-') && !exp.starts_with('+') => {
148
+ format!("{}e+{}", mantissa, exp)
149
+ }
150
+ _ => s.to_string(),
151
+ }
152
+ }
153
+
154
+ /// `Number.prototype.toPrecision(precision?)` — ECMA-262 §21.1.3.5. With `precision` significant digits,
155
+ /// choosing fixed or exponential per the spec; omitted precision falls back to `ToString`. An
156
+ /// out-of-range precision (`<1` or `>100`) parks a catchable `RangeError`.
157
+ pub fn to_precision(n: &Value, precision: &Value) -> Value {
158
+ let num = match n {
159
+ Value::Number(x) => *x,
160
+ _ => f64::NAN,
161
+ };
162
+ match precision {
163
+ Value::Number(p) => {
164
+ let p = *p as i32;
165
+ if !(1..=100).contains(&p) && num.is_finite() {
166
+ tishlang_core::set_pending_throw(tishlang_core::range_error(
167
+ "toPrecision() argument must be between 1 and 100",
168
+ ));
169
+ return Value::Null;
170
+ }
171
+ Value::String(to_precision_str(num, p).into())
172
+ }
173
+ // precision undefined → behave like ToString(number)
174
+ _ => Value::String(tishlang_core::js_number_to_string(num).into()),
175
+ }
176
+ }
177
+
178
+ /// f64-domain core of `toPrecision`, shared with the tree-walk interpreter. Assumes `precision` is in
179
+ /// range (the `&Value` entry point validates and parks a RangeError otherwise).
180
+ pub fn to_precision_str(num: f64, precision: i32) -> String {
181
+ if num.is_nan() {
182
+ return "NaN".to_string();
183
+ }
184
+ if num.is_infinite() {
185
+ return if num < 0.0 { "-Infinity" } else { "Infinity" }.to_string();
186
+ }
187
+ let p = precision.clamp(1, 100) as usize;
188
+ if num == 0.0 {
189
+ if p == 1 {
190
+ return "0".to_string();
191
+ }
192
+ return format!("0.{}", "0".repeat(p - 1));
193
+ }
194
+ let sign = if num < 0.0 { "-" } else { "" };
195
+ // `p` significant digits + base-10 exponent, half-away rounded (shared with toExponential).
196
+ let (digits, e) = sig_digits(num.abs(), p);
197
+ let body = if e < -6 || e >= p as i32 {
198
+ // exponential form
199
+ let mut m = String::new();
200
+ m.push(digits.as_bytes()[0] as char);
201
+ if p > 1 {
202
+ m.push('.');
203
+ m.push_str(&digits[1..]);
204
+ }
205
+ let esign = if e >= 0 { "+" } else { "-" };
206
+ format!("{}e{}{}", m, esign, e.abs())
207
+ } else if e == p as i32 - 1 {
208
+ digits
209
+ } else if e >= 0 {
210
+ let split = (e + 1) as usize;
211
+ format!("{}.{}", &digits[..split], &digits[split..])
212
+ } else {
213
+ let zeros = (-e - 1) as usize;
214
+ format!("0.{}{}", "0".repeat(zeros), digits)
215
+ };
216
+ format!("{}{}", sign, body)
217
+ }
218
+
46
219
  /// `Number.prototype.toString([radix])` — ECMA-262 §21.1.3.6.
47
220
  ///
48
221
  /// Radix defaults to 10 (canonical JS number formatting). For radix 2–36 the value is
@@ -317,6 +317,59 @@ pub fn trim(s: &Value) -> Value {
317
317
  }
318
318
  }
319
319
 
320
+ pub fn trim_start(s: &Value) -> Value {
321
+ if let Value::String(s) = s {
322
+ Value::String(s.trim_start().into())
323
+ } else {
324
+ Value::Null
325
+ }
326
+ }
327
+
328
+ /// Pure `String.prototype.normalize` core (ECMA-262 §22.1.3.13). `form` is one of NFC/NFD/NFKC/NFKD;
329
+ /// returns `None` for an unrecognized form so the caller can surface a `RangeError`. Shared by every
330
+ /// backend so interp/vm/native stay byte-identical.
331
+ pub fn normalize_form(s: &str, form: &str) -> Option<String> {
332
+ use unicode_normalization::UnicodeNormalization;
333
+ match form {
334
+ "NFC" => Some(s.nfc().collect()),
335
+ "NFD" => Some(s.nfd().collect()),
336
+ "NFKC" => Some(s.nfkc().collect()),
337
+ "NFKD" => Some(s.nfkd().collect()),
338
+ _ => None,
339
+ }
340
+ }
341
+
342
+ /// `String.prototype.normalize(form?)` — core-`Value` entry (vm/native). An omitted form defaults to
343
+ /// NFC; an invalid form parks a catchable `RangeError`.
344
+ pub fn normalize(s: &Value, form: &Value) -> Value {
345
+ let input = match s {
346
+ Value::String(s) => s.as_ref(),
347
+ _ => return Value::Null,
348
+ };
349
+ let f: String = match form {
350
+ Value::Null => "NFC".to_string(),
351
+ Value::String(f) => f.to_string(),
352
+ v => v.to_display_string(),
353
+ };
354
+ match normalize_form(input, &f) {
355
+ Some(out) => Value::String(out.into()),
356
+ None => {
357
+ tishlang_core::set_pending_throw(tishlang_core::range_error(
358
+ "The normalization form should be one of NFC, NFD, NFKC, NFKD.",
359
+ ));
360
+ Value::Null
361
+ }
362
+ }
363
+ }
364
+
365
+ pub fn trim_end(s: &Value) -> Value {
366
+ if let Value::String(s) = s {
367
+ Value::String(s.trim_end().into())
368
+ } else {
369
+ Value::Null
370
+ }
371
+ }
372
+
320
373
  pub fn to_upper_case(s: &Value) -> Value {
321
374
  if let Value::String(s) = s {
322
375
  Value::String(s.to_uppercase().into())
@@ -333,22 +386,74 @@ pub fn to_lower_case(s: &Value) -> Value {
333
386
  }
334
387
  }
335
388
 
336
- pub fn starts_with(s: &Value, search: &Value) -> Value {
389
+ pub fn starts_with(s: &Value, search: &Value, position: Option<&Value>) -> Value {
337
390
  if let (Value::String(s), Value::String(search)) = (s, search) {
338
- Value::Bool(s.starts_with(search.as_str()))
391
+ // `position`: test as if the string began at char `position` (clamped to [0, len]). Absent → 0.
392
+ let pos = match position {
393
+ Some(Value::Number(n)) if *n > 0.0 => *n as usize,
394
+ _ => 0,
395
+ };
396
+ let byte_start = s.char_indices().nth(pos).map(|(b, _)| b).unwrap_or(s.len());
397
+ Value::Bool(s[byte_start..].starts_with(search.as_str()))
339
398
  } else {
340
399
  Value::Bool(false)
341
400
  }
342
401
  }
343
402
 
344
- pub fn ends_with(s: &Value, search: &Value) -> Value {
403
+ pub fn ends_with(s: &Value, search: &Value, end_position: Option<&Value>) -> Value {
345
404
  if let (Value::String(s), Value::String(search)) = (s, search) {
346
- Value::Bool(s.ends_with(search.as_str()))
405
+ // `endPosition`: test as if the string ended at char `endPosition` (clamped to [0, len]).
406
+ // Absent → the full length.
407
+ let char_count = s.chars().count();
408
+ let end = match end_position {
409
+ Some(Value::Number(n)) if *n >= 0.0 => (*n as usize).min(char_count),
410
+ Some(Value::Number(_)) => 0,
411
+ _ => char_count,
412
+ };
413
+ let byte_end = s.char_indices().nth(end).map(|(b, _)| b).unwrap_or(s.len());
414
+ Value::Bool(s[..byte_end].ends_with(search.as_str()))
347
415
  } else {
348
416
  Value::Bool(false)
349
417
  }
350
418
  }
351
419
 
420
+ /// Expand a string replacement's `$` patterns for one match: `$$`→`$`, `$&`→the match, `` $` ``→the
421
+ /// text before it, `$'`→the text after it. Any other `$x` is literal. (String-search `replace`; the
422
+ /// numbered `$1` captures only apply to regex replacements.)
423
+ pub fn expand_replacement(repl: &str, matched: &str, before: &str, after: &str) -> String {
424
+ if !repl.contains('$') {
425
+ return repl.to_string();
426
+ }
427
+ let mut out = String::with_capacity(repl.len());
428
+ let mut chars = repl.chars().peekable();
429
+ while let Some(c) = chars.next() {
430
+ if c == '$' {
431
+ match chars.peek() {
432
+ Some('$') => {
433
+ out.push('$');
434
+ chars.next();
435
+ }
436
+ Some('&') => {
437
+ out.push_str(matched);
438
+ chars.next();
439
+ }
440
+ Some('`') => {
441
+ out.push_str(before);
442
+ chars.next();
443
+ }
444
+ Some('\'') => {
445
+ out.push_str(after);
446
+ chars.next();
447
+ }
448
+ _ => out.push('$'),
449
+ }
450
+ } else {
451
+ out.push(c);
452
+ }
453
+ }
454
+ out
455
+ }
456
+
352
457
  fn replace_impl(s: &Value, search: &Value, replacement: &Value, all: bool) -> Value {
353
458
  if let Value::String(s) = s {
354
459
  let search_str = match search {
@@ -359,12 +464,37 @@ fn replace_impl(s: &Value, search: &Value, replacement: &Value, all: bool) -> Va
359
464
  Value::String(ss) => ss.as_str(),
360
465
  _ => "",
361
466
  };
362
- let result = if all {
363
- s.replace(search_str, repl_str)
364
- } else {
365
- s.replacen(search_str, repl_str, 1)
366
- };
367
- Value::String(result.into())
467
+ // Fast path: no `$` in the replacement (and non-empty search) → the library replace.
468
+ if !repl_str.contains('$') || search_str.is_empty() {
469
+ let result = if all {
470
+ s.replace(search_str, repl_str)
471
+ } else {
472
+ s.replacen(search_str, repl_str, 1)
473
+ };
474
+ return Value::String(result.into());
475
+ }
476
+ // `$`-expansion path: expand per match against the original string's before/after context.
477
+ let sref = s.as_str();
478
+ let mut out = String::with_capacity(sref.len());
479
+ let mut last = 0usize;
480
+ let mut start = 0usize;
481
+ while let Some(pos) = sref[start..].find(search_str) {
482
+ let m = start + pos;
483
+ out.push_str(&sref[last..m]);
484
+ out.push_str(&expand_replacement(
485
+ repl_str,
486
+ search_str,
487
+ &sref[..m],
488
+ &sref[m + search_str.len()..],
489
+ ));
490
+ last = m + search_str.len();
491
+ start = last;
492
+ if !all {
493
+ break;
494
+ }
495
+ }
496
+ out.push_str(&sref[last..]);
497
+ Value::String(out.into())
368
498
  } else {
369
499
  Value::Null
370
500
  }
@@ -507,6 +637,23 @@ pub fn char_code_at(s: &Value, idx: &Value) -> Value {
507
637
  }
508
638
  }
509
639
 
640
+ /// `codePointAt(i)` — the Unicode code point at char index `i`, or `null` (JS `undefined`) if out of
641
+ /// range. tish strings are code-point sequences, so this returns the same value as `charCodeAt` for
642
+ /// an in-range index, but reads `null` (not `NaN`) past the end.
643
+ pub fn code_point_at(s: &Value, idx: &Value) -> Value {
644
+ if let Value::String(s) = s {
645
+ let idx = match idx {
646
+ Value::Number(n) if *n >= 0.0 => *n as usize,
647
+ _ => return Value::Null,
648
+ };
649
+ char_at_idx(s, idx)
650
+ .map(|c| Value::Number(c as u32 as f64))
651
+ .unwrap_or(Value::Null)
652
+ } else {
653
+ Value::Null
654
+ }
655
+ }
656
+
510
657
  pub fn repeat(s: &Value, count: &Value) -> Value {
511
658
  if let Value::String(s) = s {
512
659
  let count = match count {
@@ -525,12 +672,15 @@ fn pad_impl(s: &Value, target_len: &Value, pad: &Value, at_start: bool) -> Value
525
672
  Value::Number(n) => *n as usize,
526
673
  _ => return Value::String(s.clone()),
527
674
  };
675
+ // An *explicit* empty fill string means "no padding" (spec: `padStart(n, "")` → original,
676
+ // unchanged); only an ABSENT pad arg (`Value::Null`) defaults to a space. The old
677
+ // `if !p.is_empty()` guard conflated the two, space-padding on an explicit `""`.
528
678
  let pad_str = match pad {
529
- Value::String(p) if !p.is_empty() => p.as_str(),
679
+ Value::String(p) => p.as_str(),
530
680
  _ => " ",
531
681
  };
532
682
  let char_count = s.chars().count();
533
- if char_count >= target_len {
683
+ if char_count >= target_len || pad_str.is_empty() {
534
684
  return Value::String(s.clone());
535
685
  }
536
686
  let needed = target_len - char_count;
@@ -696,9 +846,12 @@ mod tests {
696
846
  fn case_and_prefix_suffix() {
697
847
  assert_same!(to_upper_case(&s("aB")), s("AB"));
698
848
  assert_same!(to_lower_case(&s("aB")), s("ab"));
699
- assert_same!(starts_with(&s("/api"), &s("/api")), Value::Bool(true));
700
- assert_same!(ends_with(&s("x.js"), &s(".js")), Value::Bool(true));
701
- assert_same!(starts_with(&n(1.0), &s("")), Value::Bool(false));
849
+ assert_same!(starts_with(&s("/api"), &s("/api"), None), Value::Bool(true));
850
+ assert_same!(ends_with(&s("x.js"), &s(".js"), None), Value::Bool(true));
851
+ assert_same!(starts_with(&n(1.0), &s(""), None), Value::Bool(false));
852
+ // 2nd-arg: position / endPosition.
853
+ assert_same!(starts_with(&s("abc"), &s("bc"), Some(&n(1.0))), Value::Bool(true));
854
+ assert_same!(ends_with(&s("abc"), &s("ab"), Some(&n(2.0))), Value::Bool(true));
702
855
  }
703
856
 
704
857
  #[test]
@@ -334,10 +334,16 @@ fn stmt_rebinds(s: &Statement, name: &str) -> bool {
334
334
  }
335
335
  Statement::ForOf {
336
336
  name: n,
337
- iterable,
337
+ iterable: e,
338
338
  body,
339
339
  ..
340
- } => n.as_ref() == name || expr_rebinds(iterable, name) || stmt_rebinds(body, name),
340
+ }
341
+ | Statement::ForIn {
342
+ name: n,
343
+ object: e,
344
+ body,
345
+ ..
346
+ } => n.as_ref() == name || expr_rebinds(e, name) || stmt_rebinds(body, name),
341
347
  Statement::Switch {
342
348
  expr,
343
349
  cases,
@@ -504,10 +510,16 @@ fn name_rebinds_in_stmt(s: &Statement, name: &str) -> bool {
504
510
  }
505
511
  Statement::ForOf {
506
512
  name: n,
507
- iterable,
513
+ iterable: e,
508
514
  body,
509
515
  ..
510
- } => n.as_ref() == name || expr_rebinds(iterable, name) || name_rebinds_in_stmt(body, name),
516
+ }
517
+ | Statement::ForIn {
518
+ name: n,
519
+ object: e,
520
+ body,
521
+ ..
522
+ } => n.as_ref() == name || expr_rebinds(e, name) || name_rebinds_in_stmt(body, name),
511
523
  Statement::Switch {
512
524
  expr,
513
525
  cases,
@@ -1803,6 +1815,46 @@ impl<'a> Compiler<'a> {
1803
1815
  self.emit(Opcode::LoopVarsEnd);
1804
1816
  self.exit_block_scope();
1805
1817
  }
1818
+ Statement::ForIn {
1819
+ name,
1820
+ name_span,
1821
+ object,
1822
+ body,
1823
+ span,
1824
+ } => {
1825
+ // Lower `for (let k in obj)` to `for (let k of Object.keys(obj))` (#413): Object.keys
1826
+ // yields exactly the own enumerable keys the interpreter enumerates — insertion order
1827
+ // for objects, index strings for arrays, and `[]` for a non-object (so `for (k in
1828
+ // null)` no-ops). Reuses the whole for-of iteration path.
1829
+ let dummy = Span {
1830
+ start: (0, 0),
1831
+ end: (0, 0),
1832
+ };
1833
+ let keys_call = Expr::Call {
1834
+ callee: Box::new(Expr::Member {
1835
+ object: Box::new(Expr::Ident {
1836
+ name: Arc::from("Object"),
1837
+ span: dummy,
1838
+ }),
1839
+ prop: MemberProp::Name {
1840
+ name: Arc::from("keys"),
1841
+ span: dummy,
1842
+ },
1843
+ optional: false,
1844
+ span: dummy,
1845
+ }),
1846
+ args: vec![CallArg::Expr(object.clone())],
1847
+ span: dummy,
1848
+ };
1849
+ let lowered = Statement::ForOf {
1850
+ name: Arc::clone(name),
1851
+ name_span: *name_span,
1852
+ iterable: keys_call,
1853
+ body: body.clone(),
1854
+ span: *span,
1855
+ };
1856
+ self.compile_statement(&lowered)?;
1857
+ }
1806
1858
  Statement::Return { value, .. } => {
1807
1859
  // Evaluate the return value first (JS order), then run any enclosing `finally`
1808
1860
  // blocks (they're stack-neutral, so the value stays on top), then return.
@@ -258,7 +258,9 @@ impl MathUnaryFn {
258
258
  }
259
259
  }
260
260
  MathUnaryFn::Trunc => x.trunc(),
261
- // JS `Math.sign` (matches `tishlang_builtins::math::sign`): NaN→NaN, else ±1, and 0/-0→+0.
261
+ // JS `Math.sign` (matches `tishlang_builtins::math::sign`): NaN→NaN, else ±1; and for
262
+ // ±0 return `x` unchanged so `Math.sign(-0)` is `-0` (`x < 0.0` is false for -0, so it
263
+ // lands in the else — bare `0.0` would drop the sign).
262
264
  MathUnaryFn::Sign => {
263
265
  if x.is_nan() {
264
266
  f64::NAN
@@ -267,7 +269,7 @@ impl MathUnaryFn {
267
269
  } else if x < 0.0 {
268
270
  -1.0
269
271
  } else {
270
- 0.0
272
+ x
271
273
  }
272
274
  }
273
275
  MathUnaryFn::Sin => x.sin(),