@tishlang/tish 2.2.5 → 2.8.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 (53) hide show
  1. package/bin/tish +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +5 -1
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/src/cli_help.rs +12 -0
  5. package/crates/tish/src/main.rs +69 -11
  6. package/crates/tish_ast/src/ast.rs +12 -5
  7. package/crates/tish_build_utils/src/lib.rs +37 -0
  8. package/crates/tish_builtins/src/array.rs +82 -1
  9. package/crates/tish_builtins/src/globals.rs +50 -16
  10. package/crates/tish_builtins/src/math.rs +63 -9
  11. package/crates/tish_builtins/src/number.rs +20 -1
  12. package/crates/tish_builtins/src/string.rs +68 -4
  13. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  14. package/crates/tish_bytecode/src/compiler.rs +94 -28
  15. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  16. package/crates/tish_compile/src/check.rs +11 -10
  17. package/crates/tish_compile/src/codegen.rs +1386 -42
  18. package/crates/tish_compile/src/infer.rs +16 -16
  19. package/crates/tish_compile/src/lib.rs +38 -0
  20. package/crates/tish_compile/src/resolve.rs +3 -2
  21. package/crates/tish_compile/src/types.rs +26 -9
  22. package/crates/tish_compile_js/src/codegen.rs +55 -4
  23. package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
  24. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  25. package/crates/tish_core/Cargo.toml +2 -0
  26. package/crates/tish_core/src/json.rs +135 -34
  27. package/crates/tish_core/src/lib.rs +23 -1
  28. package/crates/tish_core/src/value.rs +64 -11
  29. package/crates/tish_eval/src/eval.rs +144 -197
  30. package/crates/tish_eval/src/natives.rs +45 -45
  31. package/crates/tish_eval/src/regex.rs +35 -27
  32. package/crates/tish_eval/src/value.rs +8 -0
  33. package/crates/tish_fmt/src/lib.rs +46 -2
  34. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  35. package/crates/tish_lint/src/lib.rs +197 -21
  36. package/crates/tish_lsp/Cargo.toml +7 -1
  37. package/crates/tish_lsp/src/import_goto.rs +52 -7
  38. package/crates/tish_lsp/src/main.rs +913 -145
  39. package/crates/tish_opt/src/lib.rs +5 -3
  40. package/crates/tish_parser/src/lib.rs +23 -5
  41. package/crates/tish_parser/src/parser.rs +35 -35
  42. package/crates/tish_resolve/src/lib.rs +567 -17
  43. package/crates/tish_runtime/src/lib.rs +58 -18
  44. package/crates/tish_ui/src/jsx.rs +2 -2
  45. package/crates/tish_vm/src/jit.rs +212 -10
  46. package/crates/tish_vm/src/vm.rs +39 -18
  47. package/crates/tish_wasm/src/lib.rs +116 -22
  48. package/package.json +1 -1
  49. package/platform/darwin-arm64/tish +0 -0
  50. package/platform/darwin-x64/tish +0 -0
  51. package/platform/linux-arm64/tish +0 -0
  52. package/platform/linux-x64/tish +0 -0
  53. package/platform/win32-x64/tish.exe +0 -0
package/bin/tish CHANGED
Binary file
@@ -350,7 +350,11 @@ fn convert_object_prop(
350
350
  }
351
351
  });
352
352
  let value = convert_expr(&prop.value, ctx)?;
353
- Ok(ObjectProp::KeyValue(Arc::from(key.as_str()), value))
353
+ Ok(ObjectProp::KeyValue(
354
+ Arc::from(key.as_str()),
355
+ value,
356
+ tishlang_ast::Span::default(),
357
+ ))
354
358
  }
355
359
  }
356
360
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang"
3
- version = "2.2.5"
3
+ version = "2.8.0"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -406,6 +406,9 @@ pub fn build_after_help() -> String {
406
406
  JavaScript bundle
407
407
  {t}wasm{r}
408
408
  WebAssembly (.tish project; .js source supported on some paths)
409
+ {t}wasm-gpu{r}
410
+ WebAssembly + the WebGPU reflection bridge — emits a start(chunk, host) loader that
411
+ bootstraps WebGPU (device/queue/context/canvas) into the `host` global your program reads
409
412
  {t}wasi{r}
410
413
  WASI WebAssembly
411
414
  {t}bytecode{r}
@@ -479,6 +482,15 @@ pub(crate) struct RunArgs {
479
482
  /// Disable AST and bytecode optimizations (for debugging).
480
483
  #[arg(long, help_heading = "Options")]
481
484
  pub no_optimize: bool,
485
+ /// Arguments passed through to the script as `process.argv` (after the file). Like
486
+ /// `node main.js a b`, options for `tish` itself must come BEFORE the file. #88
487
+ #[arg(
488
+ trailing_var_arg = true,
489
+ allow_hyphen_values = true,
490
+ value_name = "ARGS",
491
+ help_heading = "Arguments"
492
+ )]
493
+ pub script_args: Vec<String>,
482
494
  }
483
495
 
484
496
  #[derive(Parser)]
@@ -107,12 +107,18 @@ fn main() {
107
107
  let matches = cli_help::build_command().get_matches_from(&argv);
108
108
  let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit());
109
109
  let result = match cli.command {
110
- Some(Commands::Run(a)) => run_file(
111
- &a.file,
112
- &a.backend,
113
- &a.features,
114
- a.no_optimize || no_opt_env,
115
- ),
110
+ Some(Commands::Run(a)) => {
111
+ // Expose a node-shaped `process.argv` to the script: `[tish-exe, <file>, args...]`,
112
+ // so `tish run main.tish a b` gives the script `["…/tish", "main.tish", "a", "b"]`
113
+ // (not the `run` subcommand). Interp/VM read this via `tishlang_core::process_argv`. #88
114
+ let exe = std::env::args().next().unwrap_or_else(|| "tish".to_string());
115
+ let mut argv = Vec::with_capacity(a.script_args.len() + 2);
116
+ argv.push(exe);
117
+ argv.push(a.file.clone());
118
+ argv.extend(a.script_args.iter().cloned());
119
+ tishlang_core::set_process_argv(argv);
120
+ run_file(&a.file, &a.backend, &a.features, a.no_optimize || no_opt_env)
121
+ }
116
122
  Some(Commands::Repl(a)) => run_repl(&a.backend, a.no_optimize || no_opt_env, &a.features),
117
123
  Some(Commands::Build(a)) => {
118
124
  // `--check warn|error` drives the gradual type checker via the same channel as the
@@ -652,14 +658,23 @@ fn build_file(
652
658
  return compile_to_js(&input_path, output_path, optimize, source_map);
653
659
  }
654
660
 
655
- if target == "wasm" && is_js {
661
+ // `wasm-gpu` (#277): same pipeline as `wasm` but builds the `--features gpu` WebGPU runtime and
662
+ // emits a `start(chunk, host)` loader that bootstraps WebGPU into the `host` global.
663
+ let wasm_gpu = target == "wasm-gpu";
664
+
665
+ if (target == "wasm" || wasm_gpu) && is_js {
656
666
  let source = fs::read_to_string(&input_path).map_err(|e| format!("{}", e))?;
657
667
  let program = tishlang_js_to_tish::convert(&source).map_err(|e| format!("{}", e))?;
658
- return tishlang_wasm::compile_program_to_wasm(&program, Path::new(output_path), optimize)
659
- .map_err(|e| format!("{}", e));
668
+ return tishlang_wasm::compile_program_to_wasm(
669
+ &program,
670
+ Path::new(output_path),
671
+ optimize,
672
+ wasm_gpu,
673
+ )
674
+ .map_err(|e| format!("{}", e));
660
675
  }
661
676
 
662
- if target == "wasm" {
677
+ if target == "wasm" || wasm_gpu {
663
678
  let project_root = input_path.parent().and_then(|p| {
664
679
  if p.file_name().and_then(|n| n.to_str()) == Some("src") {
665
680
  p.parent()
@@ -672,6 +687,7 @@ fn build_file(
672
687
  project_root,
673
688
  Path::new(output_path),
674
689
  optimize,
690
+ wasm_gpu,
675
691
  )
676
692
  .map_err(|e| e.to_string());
677
693
  }
@@ -714,7 +730,7 @@ fn build_file(
714
730
 
715
731
  if target != "native" {
716
732
  return Err(format!(
717
- "Unknown target: {}. Use 'native', 'js', 'wasm', 'wasi', or 'bytecode'.",
733
+ "Unknown target: {}. Use 'native', 'js', 'wasm', 'wasm-gpu', 'wasi', or 'bytecode'.",
718
734
  target
719
735
  ));
720
736
  }
@@ -814,6 +830,48 @@ mod cli_tests {
814
830
  }
815
831
  }
816
832
 
833
+ #[test]
834
+ fn run_collects_trailing_script_args() {
835
+ // `tish run main.tish a --flag` → file=main.tish, script_args=[a, --flag] (#88).
836
+ let cli = Cli::try_parse_from(vec![
837
+ "tish".to_string(),
838
+ "run".to_string(),
839
+ "main.tish".to_string(),
840
+ "a".to_string(),
841
+ "--flag".to_string(),
842
+ ])
843
+ .unwrap();
844
+ match cli.command {
845
+ Some(Commands::Run(a)) => {
846
+ assert_eq!(a.file, "main.tish");
847
+ assert_eq!(a.script_args, vec!["a".to_string(), "--flag".to_string()]);
848
+ }
849
+ _ => panic!("expected Run"),
850
+ }
851
+ }
852
+
853
+ #[test]
854
+ fn run_options_before_file_still_parse() {
855
+ // tish options come BEFORE the file; everything after the file is the script's. (#88)
856
+ let cli = Cli::try_parse_from(vec![
857
+ "tish".to_string(),
858
+ "run".to_string(),
859
+ "--backend".to_string(),
860
+ "interp".to_string(),
861
+ "main.tish".to_string(),
862
+ "x".to_string(),
863
+ ])
864
+ .unwrap();
865
+ match cli.command {
866
+ Some(Commands::Run(a)) => {
867
+ assert_eq!(a.backend, "interp");
868
+ assert_eq!(a.file, "main.tish");
869
+ assert_eq!(a.script_args, vec!["x".to_string()]);
870
+ }
871
+ _ => panic!("expected Run"),
872
+ }
873
+ }
874
+
817
875
  #[test]
818
876
  fn explicit_subcommand_not_treated_as_file() {
819
877
  let argv = argv_with_implicit_run(vec!["tish".to_string(), "repl".to_string()]);
@@ -2,7 +2,7 @@
2
2
 
3
3
  use std::sync::Arc;
4
4
 
5
- #[derive(Debug, Clone, Copy, PartialEq)]
5
+ #[derive(Debug, Clone, Copy, PartialEq, Default)]
6
6
  pub struct Span {
7
7
  pub start: (usize, usize), // line, col
8
8
  pub end: (usize, usize),
@@ -11,8 +11,11 @@ pub struct Span {
11
11
  /// Type annotation for variables, parameters, and return types.
12
12
  #[derive(Debug, Clone, PartialEq)]
13
13
  pub enum TypeAnnotation {
14
- /// Primitive types: number, string, boolean, null
15
- Simple(Arc<str>),
14
+ /// Primitive types and type-name references: `number`, `string`, `MyAlias`. The span locates
15
+ /// the name token in source so type-alias rename / find-references can edit every `: T` use
16
+ /// (synthesized types — monomorphized generics, builtins, postfix `?` null — use a zero span
17
+ /// and never match a user alias). Per AST convention, spans participate in PartialEq.
18
+ Simple(Arc<str>, Span),
16
19
  /// Array type: T[]
17
20
  Array(Box<TypeAnnotation>),
18
21
  /// Object type: { key: Type, ... }
@@ -538,10 +541,14 @@ pub enum ArrayElement {
538
541
  Spread(Expr),
539
542
  }
540
543
 
541
- /// Object property: either a regular key-value pair or spread
544
+ /// Object property: either a regular key-value pair or spread.
545
+ ///
546
+ /// The `Span` on `KeyValue` is the key token's source span, so diagnostics (e.g. duplicate-key)
547
+ /// can point at the key rather than the enclosing `{` (#143). Synthesized object literals use a
548
+ /// zero span. Per AST convention the span participates in PartialEq.
542
549
  #[derive(Debug, Clone, PartialEq)]
543
550
  pub enum ObjectProp {
544
- KeyValue(Arc<str>, Expr),
551
+ KeyValue(Arc<str>, Expr, Span),
545
552
  Spread(Expr),
546
553
  }
547
554
 
@@ -490,9 +490,46 @@ pub fn copy_binary_to_output(binary: &Path, output_path: &Path) -> Result<(), St
490
490
  }
491
491
  fs::copy(binary, output_path)
492
492
  .map_err(|e| format!("Cannot copy to {}: {}", output_path.display(), e))?;
493
+ resign_macos_adhoc(output_path);
493
494
  Ok(())
494
495
  }
495
496
 
497
+ /// Replace the linker-produced ad-hoc signature with a plain ad-hoc one on macOS.
498
+ ///
499
+ /// On Apple Silicon, AMFI SIGKILLs a freshly linked binary whose ad-hoc signature carries the
500
+ /// `linker-signed` flag (instant exit 137, no output — looks like a crash/hang), even though
501
+ /// `codesign -v` reports it valid (#219). A standard `codesign --sign - --force` replaces it with a
502
+ /// kernel-acceptable signature. Best-effort: static archives are skipped, and a failure (codesign
503
+ /// absent, or a non-Mach-O output) is non-fatal — the binary is then no worse off than before.
504
+ #[cfg(target_os = "macos")]
505
+ fn resign_macos_adhoc(output_path: &Path) {
506
+ // Static archives (`.a`) are not code-signed.
507
+ if output_path.extension().is_some_and(|e| e == "a") {
508
+ return;
509
+ }
510
+ match Command::new("codesign")
511
+ .args(["--sign", "-", "--force"])
512
+ .arg(output_path)
513
+ .output()
514
+ {
515
+ Ok(out) if out.status.success() => {}
516
+ Ok(out) => eprintln!(
517
+ "warning: codesign --sign - failed for {} ({}); the binary may be SIGKILLed at launch on Apple Silicon — re-sign with `codesign -s - -f {}`",
518
+ output_path.display(),
519
+ String::from_utf8_lossy(&out.stderr).trim(),
520
+ output_path.display()
521
+ ),
522
+ Err(e) => eprintln!(
523
+ "warning: could not run codesign for {} ({e}); if the binary is killed at launch, re-sign with `codesign -s - -f {}`",
524
+ output_path.display(),
525
+ output_path.display()
526
+ ),
527
+ }
528
+ }
529
+
530
+ #[cfg(not(target_os = "macos"))]
531
+ fn resign_macos_adhoc(_output_path: &Path) {}
532
+
496
533
  #[cfg(test)]
497
534
  mod tests {
498
535
  use super::*;
@@ -135,7 +135,11 @@ pub fn includes(arr: &Value, search: &Value, from: Option<&Value>) -> Value {
135
135
  _ => 0,
136
136
  };
137
137
  for v in arr_borrow.iter().skip(start) {
138
- if v.strict_eq(search) {
138
+ // SameValueZero: like `===` but NaN matches NaN (JS `Array.prototype.includes`, unlike
139
+ // `indexOf` which stays strict). #247
140
+ if v.strict_eq(search)
141
+ || matches!((v, search), (Value::Number(a), Value::Number(b)) if a.is_nan() && b.is_nan())
142
+ {
139
143
  return Value::Bool(true);
140
144
  }
141
145
  }
@@ -462,6 +466,83 @@ pub fn find_index(arr: &Value, callback: &Value) -> Value {
462
466
  Value::Number(-1.0)
463
467
  }
464
468
 
469
+ /// `Array.prototype.findLast` — like [`find`] but iterates from the end; the callback still receives
470
+ /// the original index. Returns `null` (JS `undefined`) when nothing matches. #247
471
+ pub fn find_last(arr: &Value, callback: &Value) -> Value {
472
+ if let Some((data, cb)) = packed_snapshot(arr, callback) {
473
+ for i in (0..data.len()).rev() {
474
+ if cb
475
+ .call(&[Value::Number(data[i]), Value::Number(i as f64)])
476
+ .is_truthy()
477
+ {
478
+ return Value::Number(data[i]);
479
+ }
480
+ }
481
+ return Value::Null;
482
+ }
483
+ let arr = as_boxed_array(arr);
484
+ let arr = &*arr;
485
+ if let (Value::Array(arr), Value::Function(cb)) = (arr, callback) {
486
+ let arr_borrow = arr.borrow();
487
+ for i in (0..arr_borrow.len()).rev() {
488
+ let v = &arr_borrow[i];
489
+ if cb.call(&[v.clone(), Value::Number(i as f64)]).is_truthy() {
490
+ return v.clone();
491
+ }
492
+ }
493
+ }
494
+ Value::Null
495
+ }
496
+
497
+ /// `Array.prototype.findLastIndex` — like [`find_index`] but from the end; `-1` if no match. #247
498
+ pub fn find_last_index(arr: &Value, callback: &Value) -> Value {
499
+ if let Some((data, cb)) = packed_snapshot(arr, callback) {
500
+ for i in (0..data.len()).rev() {
501
+ if cb
502
+ .call(&[Value::Number(data[i]), Value::Number(i as f64)])
503
+ .is_truthy()
504
+ {
505
+ return Value::Number(i as f64);
506
+ }
507
+ }
508
+ return Value::Number(-1.0);
509
+ }
510
+ let arr = as_boxed_array(arr);
511
+ let arr = &*arr;
512
+ if let (Value::Array(arr), Value::Function(cb)) = (arr, callback) {
513
+ let arr_borrow = arr.borrow();
514
+ for i in (0..arr_borrow.len()).rev() {
515
+ if cb
516
+ .call(&[arr_borrow[i].clone(), Value::Number(i as f64)])
517
+ .is_truthy()
518
+ {
519
+ return Value::Number(i as f64);
520
+ }
521
+ }
522
+ }
523
+ Value::Number(-1.0)
524
+ }
525
+
526
+ /// `Array.prototype.at(index)` — negative `index` counts from the end; out of range → `null`
527
+ /// (JS `undefined`). A non-numeric index is `ToInteger`'d to 0, like JS. #247
528
+ pub fn at(arr: &Value, index: &Value) -> Value {
529
+ let i = match index {
530
+ Value::Number(n) => *n as i64,
531
+ _ => 0,
532
+ };
533
+ let arr = as_boxed_array(arr);
534
+ let arr = &*arr;
535
+ if let Value::Array(arr) = arr {
536
+ let arr_borrow = arr.borrow();
537
+ let len = arr_borrow.len() as i64;
538
+ let idx = if i < 0 { len + i } else { i };
539
+ if idx >= 0 && idx < len {
540
+ return arr_borrow[idx as usize].clone();
541
+ }
542
+ }
543
+ Value::Null
544
+ }
545
+
465
546
  pub fn some(arr: &Value, callback: &Value) -> Value {
466
547
  if let Some((data, cb)) = packed_snapshot(arr, callback) {
467
548
  for (i, &n) in data.iter().enumerate() {
@@ -206,25 +206,59 @@ pub fn parse_int(args: &[Value]) -> Value {
206
206
  .first()
207
207
  .map(Value::to_display_string)
208
208
  .unwrap_or_default();
209
- let s = s.trim();
210
- let radix = args
211
- .get(1)
212
- .and_then(|v| match v {
213
- Value::Number(n) => Some(*n as i32),
214
- _ => None,
215
- })
216
- .unwrap_or(10);
209
+ let radix = args.get(1).and_then(|v| match v {
210
+ Value::Number(n) => Some(*n as i32),
211
+ _ => None,
212
+ });
213
+ Value::Number(js_parse_int(&s, radix))
214
+ }
217
215
 
218
- if (2..=36).contains(&radix) {
219
- let prefix: String = s
220
- .chars()
221
- .take_while(|c| *c == '-' || *c == '+' || c.is_digit(radix as u32))
222
- .collect();
223
- if let Ok(n) = i64::from_str_radix(&prefix, radix as u32) {
224
- return Value::Number(n as f64);
216
+ /// JS `parseInt(string, radix)` semantics — shared so the tree-walk interpreter (distinct `Value`)
217
+ /// matches the vm/native exactly (#247). Skips leading whitespace + an optional sign, then strips a
218
+ /// leading `0x`/`0X` when radix is 16 (or omitted and the string is hex-prefixed); an omitted radix
219
+ /// otherwise defaults to 10. Reads the longest valid digit prefix (`parseInt("12px") === 12`),
220
+ /// accumulating in f64 to avoid the old i64-overflow `NaN`. `parseInt("0x1F", 16) === 31`.
221
+ pub fn js_parse_int(input: &str, radix_arg: Option<i32>) -> f64 {
222
+ let s = input.trim_start();
223
+ let (neg, rest) = if let Some(r) = s.strip_prefix('-') {
224
+ (true, r)
225
+ } else if let Some(r) = s.strip_prefix('+') {
226
+ (false, r)
227
+ } else {
228
+ (false, s)
229
+ };
230
+ let mut radix = radix_arg.unwrap_or(0);
231
+ let mut digits = rest;
232
+ if radix == 16 || radix == 0 {
233
+ if let Some(r) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
234
+ radix = 16;
235
+ digits = r;
225
236
  }
226
237
  }
227
- Value::Number(f64::NAN)
238
+ if radix == 0 {
239
+ radix = 10;
240
+ }
241
+ if !(2..=36).contains(&radix) {
242
+ return f64::NAN;
243
+ }
244
+ let mut acc = 0.0_f64;
245
+ let mut any = false;
246
+ for c in digits.chars() {
247
+ match c.to_digit(radix as u32) {
248
+ Some(d) => {
249
+ acc = acc * radix as f64 + d as f64;
250
+ any = true;
251
+ }
252
+ None => break,
253
+ }
254
+ }
255
+ if !any {
256
+ f64::NAN
257
+ } else if neg {
258
+ -acc
259
+ } else {
260
+ acc
261
+ }
228
262
  }
229
263
 
230
264
  /// parseFloat(string)
@@ -16,7 +16,6 @@ math_unary!(abs, abs);
16
16
  math_unary!(sqrt, sqrt);
17
17
  math_unary!(floor, floor);
18
18
  math_unary!(ceil, ceil);
19
- math_unary!(round, round);
20
19
  math_unary!(sin, sin);
21
20
  math_unary!(cos, cos);
22
21
  math_unary!(tan, tan);
@@ -30,20 +29,75 @@ math_unary!(exp, exp);
30
29
  math_unary!(trunc, trunc);
31
30
  math_unary!(cbrt, cbrt);
32
31
 
32
+ // --- f64-domain semantics (single source of truth) -------------------------------------------------
33
+ // These hold the JS-specific Math rules so every backend agrees: the vm/native paths call the
34
+ // `&[Value]` wrappers below, and the interpreter (which uses a different `Value` type) calls these f64
35
+ // helpers directly after extracting its own args. Keeping the rules here means they can't drift. #247
36
+
37
+ /// JS `Math.round`: ties round toward +∞ (not Rust's `.round()`, which rounds half away from zero),
38
+ /// and values in `[-0.5, 0)` return `-0`. `floor(n + 0.5)` gives the +∞-tie behavior; the guards keep
39
+ /// NaN/±∞ and the sign-of-zero edges correct (`Math.round(-2.5) === -2`, `Math.round(-0.5) === -0`).
40
+ pub fn round_f64(n: f64) -> f64 {
41
+ if n.is_nan() || n.is_infinite() || n == 0.0 {
42
+ n
43
+ } else if (-0.5..0.5).contains(&n) {
44
+ if n < 0.0 {
45
+ -0.0
46
+ } else {
47
+ 0.0
48
+ }
49
+ } else {
50
+ (n + 0.5).floor()
51
+ }
52
+ }
53
+
54
+ /// JS `Math.min` over already-extracted f64s: empty → `+∞`, any `NaN` → `NaN` (unlike `f64::min`,
55
+ /// which ignores NaN), and `-0` is preferred over `+0`.
56
+ pub fn min_f64(nums: &[f64]) -> f64 {
57
+ let mut acc = f64::INFINITY;
58
+ for &n in nums {
59
+ if n.is_nan() {
60
+ return f64::NAN;
61
+ }
62
+ if n < acc || (n == 0.0 && acc == 0.0 && n.is_sign_negative()) {
63
+ acc = n;
64
+ }
65
+ }
66
+ acc
67
+ }
68
+
69
+ /// JS `Math.max` over already-extracted f64s: empty → `-∞`, any `NaN` → `NaN`, `+0` preferred over `-0`.
70
+ pub fn max_f64(nums: &[f64]) -> f64 {
71
+ let mut acc = f64::NEG_INFINITY;
72
+ for &n in nums {
73
+ if n.is_nan() {
74
+ return f64::NAN;
75
+ }
76
+ if n > acc || (n == 0.0 && acc == 0.0 && n.is_sign_positive()) {
77
+ acc = n;
78
+ }
79
+ }
80
+ acc
81
+ }
82
+
83
+ pub fn round(args: &[Value]) -> Value {
84
+ Value::Number(round_f64(extract_num(args.first()).unwrap_or(f64::NAN)))
85
+ }
86
+
33
87
  pub fn min(args: &[Value]) -> Value {
34
- let n = args
88
+ let nums: Vec<f64> = args
35
89
  .iter()
36
- .filter_map(|v| extract_num(Some(v)))
37
- .fold(f64::INFINITY, f64::min);
38
- Value::Number(if n == f64::INFINITY { f64::NAN } else { n })
90
+ .map(|v| extract_num(Some(v)).unwrap_or(f64::NAN))
91
+ .collect();
92
+ Value::Number(min_f64(&nums))
39
93
  }
40
94
 
41
95
  pub fn max(args: &[Value]) -> Value {
42
- let n = args
96
+ let nums: Vec<f64> = args
43
97
  .iter()
44
- .filter_map(|v| extract_num(Some(v)))
45
- .fold(f64::NEG_INFINITY, f64::max);
46
- Value::Number(if n == f64::NEG_INFINITY { f64::NAN } else { n })
98
+ .map(|v| extract_num(Some(v)).unwrap_or(f64::NAN))
99
+ .collect();
100
+ Value::Number(max_f64(&nums))
47
101
  }
48
102
 
49
103
  pub fn pow(args: &[Value]) -> Value {
@@ -21,7 +21,26 @@ pub fn to_fixed(n: &Value, digits: &Value) -> Value {
21
21
  Value::Number(x) => (*x as i32).clamp(0, 20),
22
22
  _ => 0,
23
23
  } as usize;
24
- Value::String(format!("{:.*}", d, num).into())
24
+ Value::String(to_fixed_str(num, d).into())
25
+ }
26
+
27
+ /// f64-domain `Number.prototype.toFixed` so the tree-walk interpreter (distinct `Value`) shares the
28
+ /// exact behavior. Rust's `{:.*}` rounds half-to-even (`(2.5).toFixed(0)` → "2"); JS rounds half away
29
+ /// from zero (→ "3"), so pre-round the scaled value with `.round()` (half-away) before formatting. #247
30
+ pub fn to_fixed_str(num: f64, digits: usize) -> String {
31
+ if num.is_nan() {
32
+ return "NaN".to_string();
33
+ }
34
+ if num.is_infinite() {
35
+ return if num < 0.0 { "-Infinity" } else { "Infinity" }.to_string();
36
+ }
37
+ // JS switches to exponential form for |num| >= 1e21; defer to default formatting there.
38
+ if num.abs() >= 1e21 {
39
+ return format!("{}", num);
40
+ }
41
+ let factor = 10f64.powi(digits as i32);
42
+ let rounded = (num * factor).round() / factor;
43
+ format!("{:.*}", digits, rounded)
25
44
  }
26
45
 
27
46
  /// `Number.prototype.toString([radix])` — ECMA-262 §21.1.3.6.
@@ -215,15 +215,39 @@ pub fn substr(s: &Value, start: &Value, length: &Value) -> Value {
215
215
  }
216
216
 
217
217
  pub fn split(s: &Value, sep: &Value) -> Value {
218
+ split_limit(s, sep, None)
219
+ }
220
+
221
+ /// `String.prototype.split(sep, limit)` for a string separator. JS semantics: the string is split
222
+ /// completely on `sep`, then the result is truncated to `limit` elements (it does NOT keep the
223
+ /// unsplit remainder in the last slot, which is what Rust's `splitn` would do). `limit == 0` yields
224
+ /// an empty array. This is the single source of truth shared by the VM and rust/cranelift/wasi
225
+ /// backends; the interpreter mirrors it in `tish_eval::regex::string_split`.
226
+ pub fn split_limit(s: &Value, sep: &Value, limit: Option<usize>) -> Value {
218
227
  if let Value::String(s) = s {
219
228
  let separator = match sep {
220
229
  Value::String(ss) => ss.as_str(),
221
230
  _ => return Value::Array(VmRef::new(vec![Value::String(s.clone())])),
222
231
  };
223
- let parts: Vec<Value> = s
224
- .split(separator)
225
- .map(|p| Value::String(p.into()))
226
- .collect();
232
+ if limit == Some(0) {
233
+ return Value::Array(VmRef::new(Vec::new()));
234
+ }
235
+ // JS `split("")` is special: it yields the string's characters with NO surrounding empties
236
+ // (`"xyz".split("")` → `["x","y","z"]`, `"".split("")` → `[]`), unlike Rust's `str::split("")`
237
+ // which emits leading/trailing `""`. (tish splits on `char`s; lone-surrogate behavior for
238
+ // astral code points is out of scope.) #247
239
+ if separator.is_empty() {
240
+ let mut parts: Vec<Value> =
241
+ s.chars().map(|c| Value::String(c.to_string().into())).collect();
242
+ if let Some(max) = limit {
243
+ parts.truncate(max);
244
+ }
245
+ return Value::Array(VmRef::new(parts));
246
+ }
247
+ let mut parts: Vec<Value> = s.split(separator).map(|p| Value::String(p.into())).collect();
248
+ if let Some(max) = limit {
249
+ parts.truncate(max);
250
+ }
227
251
  Value::Array(VmRef::new(parts))
228
252
  } else {
229
253
  Value::Null
@@ -364,6 +388,24 @@ pub fn char_at(s: &Value, idx: &Value) -> Value {
364
388
  }
365
389
  }
366
390
 
391
+ /// `String.prototype.at(index)` — like `charAt` but negative `index` counts from the end and an
392
+ /// out-of-range index yields `null` (JS `undefined`), not `""`. #247
393
+ pub fn at(s: &Value, index: &Value) -> Value {
394
+ if let Value::String(s) = s {
395
+ let i = match index {
396
+ Value::Number(n) => *n as i64,
397
+ _ => 0,
398
+ };
399
+ let chars: Vec<char> = s.chars().collect();
400
+ let len = chars.len() as i64;
401
+ let idx = if i < 0 { len + i } else { i };
402
+ if idx >= 0 && idx < len {
403
+ return Value::String(chars[idx as usize].to_string().into());
404
+ }
405
+ }
406
+ Value::Null
407
+ }
408
+
367
409
  pub fn char_code_at(s: &Value, idx: &Value) -> Value {
368
410
  if let Value::String(s) = s {
369
411
  let idx = match idx {
@@ -541,6 +583,28 @@ mod tests {
541
583
  assert_same!(trim(&n(1.0)), Value::Null);
542
584
  }
543
585
 
586
+ #[test]
587
+ fn split_limit_js_semantics() {
588
+ let parts = |v: &Value| -> Vec<String> {
589
+ let Value::Array(a) = v else { panic!("not array") };
590
+ a.borrow()
591
+ .iter()
592
+ .map(|x| match x {
593
+ Value::String(s) => s.to_string(),
594
+ _ => panic!("not string"),
595
+ })
596
+ .collect()
597
+ };
598
+ // limit truncates to the first N pieces (does NOT keep the remainder, unlike `splitn`)
599
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), Some(2))), ["a", "b"]);
600
+ // limit 0 -> empty; limit beyond piece count -> full split; no limit -> full split
601
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), Some(0))).len(), 0);
602
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), Some(10))), ["a", "b", "c", "d"]);
603
+ assert_eq!(parts(&split_limit(&s("a,b,c,d"), &s(","), None)), ["a", "b", "c", "d"]);
604
+ // split() delegates with no limit
605
+ assert_eq!(parts(&split(&s("one two"), &s(" "))), ["one", "two"]);
606
+ }
607
+
544
608
  #[test]
545
609
  fn case_and_prefix_suffix() {
546
610
  assert_same!(to_upper_case(&s("aB")), s("AB"));