@tishlang/tish-format 3.3.3 → 3.7.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 (42) hide show
  1. package/Cargo.toml +1 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/tish/Cargo.toml +4 -1
  4. package/crates/tish/src/cli_help.rs +84 -0
  5. package/crates/tish/src/main.rs +93 -1
  6. package/crates/tish/tests/scope_merge.rs +90 -0
  7. package/crates/tish/tests/test_runner.rs +673 -0
  8. package/crates/tish_bytecode/src/compiler.rs +25 -4
  9. package/crates/tish_compile/src/check.rs +178 -0
  10. package/crates/tish_compile/src/codegen.rs +383 -63
  11. package/crates/tish_compile/src/lib.rs +33 -1
  12. package/crates/tish_compile/src/resolve.rs +121 -19
  13. package/crates/tish_compile/src/types.rs +35 -1
  14. package/crates/tish_compile/tests/call_arity_641.rs +104 -0
  15. package/crates/tish_core/src/lib.rs +103 -11
  16. package/crates/tish_eval/src/eval.rs +16 -0
  17. package/crates/tish_eval/src/value_convert.rs +10 -0
  18. package/crates/tish_fmt/Cargo.toml +1 -1
  19. package/crates/tish_test/Cargo.toml +47 -0
  20. package/crates/tish_test/src/assert.rs +435 -0
  21. package/crates/tish_test/src/config.rs +85 -0
  22. package/crates/tish_test/src/coverage.rs +237 -0
  23. package/crates/tish_test/src/deep_equal.rs +261 -0
  24. package/crates/tish_test/src/discovery.rs +70 -0
  25. package/crates/tish_test/src/expect.rs +977 -0
  26. package/crates/tish_test/src/instrument.rs +457 -0
  27. package/crates/tish_test/src/isolation.rs +14 -0
  28. package/crates/tish_test/src/lib.rs +22 -0
  29. package/crates/tish_test/src/load.rs +152 -0
  30. package/crates/tish_test/src/mocks.rs +270 -0
  31. package/crates/tish_test/src/module.rs +318 -0
  32. package/crates/tish_test/src/registry.rs +235 -0
  33. package/crates/tish_test/src/report.rs +312 -0
  34. package/crates/tish_test/src/runner.rs +797 -0
  35. package/crates/tish_test/src/snapshots.rs +197 -0
  36. package/crates/tish_vm/src/vm.rs +17 -14
  37. package/package.json +1 -1
  38. package/platform/darwin-arm64/tish-fmt +0 -0
  39. package/platform/darwin-x64/tish-fmt +0 -0
  40. package/platform/linux-arm64/tish-fmt +0 -0
  41. package/platform/linux-x64/tish-fmt +0 -0
  42. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -12,7 +12,7 @@ mod types;
12
12
 
13
13
  pub use schemes::{set_active as set_scheme_registry, SchemeRegistry};
14
14
 
15
- pub use check::{check_program, TypeDiagnostic};
15
+ pub use check::{check_call_arity, check_program, TypeDiagnostic};
16
16
 
17
17
  /// The native typed-codegen optimizations — numeric param inference, struct/aggregate inference,
18
18
  /// native (monomorphic) free fns, native-vec params, recursive-struct arena lowering, fused/native
@@ -291,4 +291,36 @@ mod monomorphization_tests {
291
291
  assert!(rust.contains("value: f64"), "expected native f64 field; got:\n{}",
292
292
  rust.lines().filter(|l| l.contains("struct") || l.contains("value")).take(6).collect::<Vec<_>>().join("\n"));
293
293
  }
294
+
295
+ /// #614: a module-level binding demoted to boxed `Value` must NOT drag down a function-local
296
+ /// that merely shares its name. Both lowerings have to appear in one program — under the old
297
+ /// bare-name keying that was impossible, since one name got one verdict bundle-wide.
298
+ #[test]
299
+ fn module_demotion_does_not_box_a_same_named_local() {
300
+ let src = r#"
301
+ let i = 0
302
+ i = i + "x"
303
+ function modern(n: number) {
304
+ let i = 0
305
+ let t = 0
306
+ while (i < n) { t = t + i * 2; i = i + 1 }
307
+ return t
308
+ }
309
+ console.log(i)
310
+ console.log(modern(5))
311
+ "#;
312
+ let rust = compile(&parse(src).unwrap()).unwrap();
313
+ // The module `i` really can hold a String (`i + "x"`), so it must stay boxed.
314
+ assert!(
315
+ rust.contains("let mut i = Value::Number(0_f64)"),
316
+ "module-level `i` must stay boxed; got:\n{}",
317
+ rust.lines().filter(|l| l.contains("let mut i")).collect::<Vec<_>>().join("\n")
318
+ );
319
+ // `modern`'s `i` is a different variable, provably numeric, and must lower natively.
320
+ assert!(
321
+ rust.contains("let mut i: f64 = 0_f64"),
322
+ "fn-local `i` must stay native f64; got:\n{}",
323
+ rust.lines().filter(|l| l.contains("let mut i")).collect::<Vec<_>>().join("\n")
324
+ );
325
+ }
294
326
  }
@@ -81,13 +81,22 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
81
81
  ("encoding", "tish:encoding"),
82
82
  ("crypto", "tish:crypto"),
83
83
  ("zip", "tish:zip"),
84
+ ("test", "tish:test"),
85
+ ("assert", "tish:assert"),
86
+ ("assert/strict", "tish:assert"),
84
87
  ];
85
88
 
86
89
  /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
87
- /// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises`) and bare aliases.
90
+ /// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises`,
91
+ /// `node:test` -> `tish:test`, `node:assert` / `node:assert/strict` -> `tish:assert`)
92
+ /// and bare aliases.
88
93
  pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
89
94
  // Strip a leading `node:` so `node:fs` resolves the same as `fs`.
90
95
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
96
+ // `node:assert/strict` (and bare `assert/strict`) → `tish:assert`.
97
+ if spec == "assert/strict" {
98
+ return Some("tish:assert".to_string());
99
+ }
91
100
  if spec.starts_with("tish:") {
92
101
  return Some(spec.to_string());
93
102
  }
@@ -97,7 +106,7 @@ pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
97
106
  .map(|(_, canonical)| (*canonical).to_string())
98
107
  }
99
108
 
100
- /// Built-in modules that come from tishlang_runtime, not from package.json.
109
+ /// Built-in modules that come from tishlang_runtime / tishlang_test, not from package.json.
101
110
  pub fn is_builtin_native_spec(spec: &str) -> bool {
102
111
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
103
112
  matches!(
@@ -114,10 +123,12 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
114
123
  | "tish:encoding"
115
124
  | "tish:crypto"
116
125
  | "tish:zip"
126
+ | "tish:test"
127
+ | "tish:assert"
117
128
  ) || matches!(
118
129
  spec,
119
130
  "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty" | "net"
120
- | "encoding" | "crypto" | "zip"
131
+ | "encoding" | "crypto" | "zip" | "test" | "assert" | "assert/strict"
121
132
  )
122
133
  }
123
134
 
@@ -1031,7 +1042,7 @@ fn load_module_recursive(
1031
1042
  /// resolution for `import … from "@scope/pkg"` is interop, and the native-vs-merged axis is the one
1032
1043
  /// piece tish adds on top, keyed off the native markers rather than mere presence of `tish.module`.
1033
1044
  pub fn is_native_import(spec: &str) -> bool {
1034
- // A leading `node:` (node:fs, node:fs/promises) resolves the same as the bare form.
1045
+ // A leading `node:` (node:fs, node:fs/promises, node:assert/strict) resolves like the bare form.
1035
1046
  let spec = spec.strip_prefix("node:").unwrap_or(spec);
1036
1047
  spec.starts_with("tish:")
1037
1048
  || spec.starts_with("cargo:")
@@ -1039,7 +1050,7 @@ pub fn is_native_import(spec: &str) -> bool {
1039
1050
  || matches!(
1040
1051
  spec,
1041
1052
  "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "net" | "encoding"
1042
- | "crypto" | "zip"
1053
+ | "crypto" | "zip" | "test" | "assert" | "assert/strict"
1043
1054
  )
1044
1055
  }
1045
1056
 
@@ -1231,6 +1242,29 @@ fn resolve_import_path(
1231
1242
  spec
1232
1243
  ));
1233
1244
  }
1245
+ // #618: build-time package-variant selection. `package.json` may remap an import specifier to
1246
+ // another module, which is tish's equivalent of a cargo feature swapping one implementation for
1247
+ // another:
1248
+ //
1249
+ // "tish": { "packageOverrides": { "./drop_tables": "./drop_tables_pocket" } }
1250
+ //
1251
+ // Applied to the SPECIFIER before any resolution, so it works for relative paths and package
1252
+ // names alike, and the replacement is then resolved by the ordinary rules (relative to the
1253
+ // importing file, as the original would have been). Matching is exact on the written
1254
+ // specifier — no prefix or glob matching, so an override cannot silently capture an import the
1255
+ // author did not mean to redirect.
1256
+ //
1257
+ // A remapped specifier is NOT re-checked against the override table: one substitution per
1258
+ // import, so a table that maps A->B and B->A terminates instead of looping.
1259
+ let overridden = {
1260
+ let cfg = read_project_tish_config(project_root);
1261
+ cfg.get("packageOverrides")
1262
+ .and_then(|o| o.as_object())
1263
+ .and_then(|o| o.get(spec))
1264
+ .and_then(|v| v.as_str())
1265
+ .map(|s| s.to_string())
1266
+ };
1267
+ let spec: &str = overridden.as_deref().unwrap_or(spec);
1234
1268
  if !spec.starts_with("./") && !spec.starts_with("../") {
1235
1269
  if let Some(path) = resolve_bare_spec(spec, from_dir, project_root) {
1236
1270
  return Ok(path);
@@ -1445,10 +1479,26 @@ fn collect_destructure_names(pattern: &DestructPattern, out: &mut HashSet<String
1445
1479
 
1446
1480
  /// Rename each module's non-exported top-level bindings whose name also occurs as a top-level
1447
1481
  /// name in another module, isolating module-private declarations (#97).
1448
- fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
1482
+ ///
1483
+ /// #587: this ALSO covers colliding EXPORTED names in non-entry modules, returning
1484
+ /// `renamed symbol -> original export name` per module so the export table can still be keyed by the
1485
+ /// name importers ask for. The original carve-out ("exported names stay stable so imports keep
1486
+ /// resolving") was stricter than necessary: `module_exports` is already an INDIRECTION from export
1487
+ /// name to source symbol, so the symbol is free to move as long as the key does not.
1488
+ ///
1489
+ /// Leaving exported collisions alone was not merely a missed optimisation — it silently produced a
1490
+ /// wrong program. Two modules exporting `greet`, flattened into one scope, emit two
1491
+ /// `function greet` declarations; JS function declarations HOIST, so the second overwrote the first
1492
+ /// before `const _greet = greet` ran, and a barrel that re-wrapped its own import recursed until the
1493
+ /// stack blew.
1494
+ ///
1495
+ /// The ENTRY module is deliberately excluded: its exports are the bundle's public surface, so
1496
+ /// renaming them would change the API the caller sees.
1497
+ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) -> Vec<HashMap<String, String>> {
1449
1498
  let n = modules.len();
1499
+ let mut export_renames: Vec<HashMap<String, String>> = vec![HashMap::new(); n];
1450
1500
  if n < 2 {
1451
- return; // a single module cannot collide with another
1501
+ return export_renames; // a single module cannot collide with another
1452
1502
  }
1453
1503
  let mut decls: Vec<HashSet<String>> = vec![HashSet::new(); n];
1454
1504
  let mut exported: Vec<HashSet<String>> = vec![HashSet::new(); n];
@@ -1471,13 +1521,35 @@ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
1471
1521
  *count.entry(name.as_str()).or_insert(0) += 1;
1472
1522
  }
1473
1523
  }
1524
+ // #587: for EXPORTED names the question is different, and `occupancy` answers the wrong one.
1525
+ // It counts import bindings, so a dep's exported `greet` looks like it collides with the
1526
+ // importer's binding of that same `greet` — but those are one thing, not two, and renaming on
1527
+ // that basis breaks nothing yet churns every ordinary import. A real collision is another module
1528
+ // DECLARING the name, so exported renames key off declarations only.
1529
+ let mut decl_count: HashMap<&str, usize> = HashMap::new();
1530
+ for d in &decls {
1531
+ for name in d {
1532
+ *decl_count.entry(name.as_str()).or_insert(0) += 1;
1533
+ }
1534
+ }
1474
1535
  for (i, m) in modules.iter_mut().enumerate() {
1536
+ let is_entry = i + 1 == n; // the entry module is last — its exports are the public surface
1475
1537
  let mut renames: HashMap<String, Arc<str>> = HashMap::new();
1476
1538
  for name in &decls[i] {
1477
- if exported[i].contains(name) {
1478
- continue; // exported names stay stable so imports keep resolving
1539
+ if count.get(name.as_str()).copied().unwrap_or(0) <= 1 {
1540
+ continue; // no collision — leave it exactly as it is (zero blast radius)
1479
1541
  }
1480
- if count.get(name.as_str()).copied().unwrap_or(0) > 1 {
1542
+ if exported[i].contains(name) {
1543
+ // #587: an exported collision in a NON-ENTRY module is renamed too, and the new
1544
+ // symbol is recorded so `module_exports` can still answer to the original name.
1545
+ // Gated on DECLARATION count — see the note above.
1546
+ if is_entry || decl_count.get(name.as_str()).copied().unwrap_or(0) <= 1 {
1547
+ continue;
1548
+ }
1549
+ let renamed = format!("{name}__m{i}");
1550
+ export_renames[i].insert(renamed.clone(), name.clone());
1551
+ renames.insert(name.clone(), Arc::from(renamed));
1552
+ } else {
1481
1553
  renames.insert(name.clone(), Arc::from(format!("{name}__m{i}")));
1482
1554
  }
1483
1555
  }
@@ -1491,6 +1563,7 @@ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
1491
1563
  rewrite_stmt_scope(stmt, &mut active, true);
1492
1564
  }
1493
1565
  }
1566
+ export_renames
1494
1567
  }
1495
1568
 
1496
1569
  /// Apply the rename for a *declared* name. At module top level the binding is the canonical
@@ -1896,7 +1969,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1896
1969
  }
1897
1970
 
1898
1971
  // #97: isolate module-private top-level bindings before they are flattened together.
1899
- isolate_private_top_level_bindings(&mut modules);
1972
+ let export_renames = isolate_private_top_level_bindings(&mut modules);
1900
1973
 
1901
1974
  let path_to_idx: HashMap<PathBuf, usize> = modules
1902
1975
  .iter()
@@ -1916,7 +1989,14 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1916
1989
  }
1917
1990
  _ => continue,
1918
1991
  };
1919
- module_exports[idx].insert(name.clone(), name);
1992
+ // #587: if this symbol was renamed for an exported collision, the export
1993
+ // table must still answer to the name importers ask for — key by the
1994
+ // ORIGINAL, point at the renamed symbol.
1995
+ let key = export_renames[idx]
1996
+ .get(&name)
1997
+ .cloned()
1998
+ .unwrap_or_else(|| name.clone());
1999
+ module_exports[idx].insert(key, name);
1920
2000
  }
1921
2001
  ExportDeclaration::Default(_) => {
1922
2002
  let default_name = format!("__default_{}", idx);
@@ -2041,13 +2121,35 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
2041
2121
  from.as_ref()
2042
2122
  ));
2043
2123
  }
2044
- ImportSpecifier::Default { name, .. } => {
2045
- return Err(format!(
2046
- "Default import '{}' not supported for native module '{}'. Use named import, e.g. import {{ egui }} from '{}'",
2047
- name.as_ref(),
2048
- from.as_ref(),
2049
- from.as_ref()
2050
- ));
2124
+ ImportSpecifier::Default { name, name_span } => {
2125
+ // Only `tish:assert` (and aliases) expose a callable default.
2126
+ // Other natives still require named imports.
2127
+ if canonical_spec != "tish:assert" {
2128
+ return Err(format!(
2129
+ "Default import '{}' not supported for native module '{}'. Use named import, e.g. import {{ egui }} from '{}'",
2130
+ name.as_ref(),
2131
+ from.as_ref(),
2132
+ from.as_ref()
2133
+ ));
2134
+ }
2135
+ let init = Expr::NativeModuleLoad {
2136
+ spec: Arc::from(canonical_spec.clone()),
2137
+ export_name: Arc::from("default"),
2138
+ span: *span,
2139
+ };
2140
+ merge_push(
2141
+ &mut statements,
2142
+ &mut statement_sources,
2143
+ Statement::VarDecl {
2144
+ name: name.clone(),
2145
+ name_span: *name_span,
2146
+ mutable: false,
2147
+ type_ann: None,
2148
+ init: Some(init),
2149
+ span: *span,
2150
+ },
2151
+ src_path.clone(),
2152
+ );
2051
2153
  }
2052
2154
  }
2053
2155
  }
@@ -327,6 +327,27 @@ impl RustType {
327
327
  | BinOp::StrictNe => Some(RustType::Bool),
328
328
  _ => None,
329
329
  }
330
+ } else if (lhs == &RustType::Fixed && rhs == &RustType::F64)
331
+ || (lhs == &RustType::F64 && rhs == &RustType::Fixed)
332
+ {
333
+ // #606: a MIXED `fixed`/`f64` operand pair. Previously this fell through to `None`, so
334
+ // the whole expression dropped to the boxed `ops::*` path — and on an FPU-less
335
+ // ARM7TDMI that path also routes through SOFT-FLOAT f64, which is the expensive part.
336
+ //
337
+ // The f64 side is lifted to `Fixed` at the operator, with the SAME truncating semantics
338
+ // the literal fold already applies (`Fixed::from_raw((x * 256.0) as i32)`), so a mixed
339
+ // expression now agrees with the equivalent all-literal one instead of disagreeing with
340
+ // it. `%`/`**`/bitwise stay boxed, exactly as they do for `Fixed × Fixed`.
341
+ match op {
342
+ BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div => Some(RustType::Fixed),
343
+ BinOp::Lt
344
+ | BinOp::Le
345
+ | BinOp::Gt
346
+ | BinOp::Ge
347
+ | BinOp::StrictEq
348
+ | BinOp::StrictNe => Some(RustType::Bool),
349
+ _ => None,
350
+ }
330
351
  } else if lhs == &RustType::Fixed && rhs == &RustType::Fixed {
331
352
  // agb `Num<i32,8>` overloads `+ - * /` (Mul/Div apply the Q24.8 shift
332
353
  // correction) and `PartialOrd`/`PartialEq`, so these lower to native
@@ -512,8 +533,21 @@ impl RustType {
512
533
  "match &{} {{ Value::String(s) => s.to_string(), _ => panic!(\"expected string\") }}",
513
534
  value_expr
514
535
  ),
536
+ // #610: A NUMBER REACHING A BOOL BOUNDARY MUST NOT SHIP A PANIC. This arm accepted only
537
+ // `Value::Bool`, so passing `1` where a typed extern — or any bool-typed boundary —
538
+ // declares `bool` compiled clean, emitted no coercion, and panicked on the first call.
539
+ // The failure is latent until that exact line runs, which is how tish-gba's space-goo
540
+ // shipped it.
541
+ //
542
+ // Numbers coerce as `n != 0`. This does NOT reintroduce loose equality: `bool === 1` is
543
+ // still false, because that is a question about the COMPARISON operator. This is a
544
+ // boundary CONVERSION — the signature has already said `bool`, the way an `as` cast
545
+ // would — and a total conversion there beats a guaranteed abort.
546
+ //
547
+ // Anything genuinely unconvertible still panics, so a string or object reaching a bool
548
+ // parameter stays exactly as loud as before.
515
549
  RustType::Bool => format!(
516
- "match &{} {{ Value::Bool(b) => *b, _ => panic!(\"expected boolean\") }}",
550
+ "match &{} {{ Value::Bool(b) => *b, Value::Number(n) => *n != 0.0, _ => panic!(\"expected boolean\") }}",
517
551
  value_expr
518
552
  ),
519
553
  RustType::Unit => "()".to_string(),
@@ -0,0 +1,104 @@
1
+ //! #641 — call arity. A call with too few arguments used to compile, bind the missing parameter to
2
+ //! `Value::Null`, and then either panic at runtime (boxed callee, typed params), fail in rustc
3
+ //! (direct native callee), or silently yield null/NaN (interpreter) — depending on which lowering
4
+ //! the CALLEE happened to get. These pin the rule and, just as importantly, the non-rule: omitting
5
+ //! an argument bound to an UNTYPED parameter stays legal, because shipping packages rely on it.
6
+
7
+ use tishlang_compile::check_call_arity;
8
+ use tishlang_parser::parse;
9
+
10
+ fn diags(src: &str) -> Vec<String> {
11
+ let p = parse(src).expect("parse");
12
+ check_call_arity(&p).into_iter().map(|d| d.message).collect()
13
+ }
14
+
15
+ #[test]
16
+ fn omitting_a_typed_parameter_is_an_error() {
17
+ let d = diags(
18
+ r#"
19
+ function paintTyped(slot: i32, rv: i32): i32 { return slot + rv }
20
+ paintTyped(1)
21
+ "#,
22
+ );
23
+ assert_eq!(d.len(), 1, "expected exactly one diagnostic, got {:?}", d);
24
+ assert!(d[0].contains("paintTyped"), "names the callee: {}", d[0]);
25
+ assert!(d[0].contains("needs 2"), "states the requirement: {}", d[0]);
26
+ }
27
+
28
+ #[test]
29
+ fn omitting_an_untyped_parameter_is_allowed() {
30
+ // The established idiom: `packages/shop.tish` has six of these, `packages/ui.tish` one.
31
+ // Enforcing arity here would break shipping code that is behaving exactly as intended.
32
+ let d = diags(
33
+ r#"
34
+ function renderTab(defer, stream) { return 1 }
35
+ renderTab()
36
+ renderTab(1)
37
+ renderTab(1, 1)
38
+ "#,
39
+ );
40
+ assert!(d.is_empty(), "untyped params must stay optional, got {:?}", d);
41
+ }
42
+
43
+ #[test]
44
+ fn a_defaulted_parameter_imposes_no_minimum() {
45
+ // The default is what fills the slot, so nothing arrives as null.
46
+ let d = diags(
47
+ r#"
48
+ function f(a: i32, b: i32 = 7): i32 { return a + b }
49
+ f(1)
50
+ "#,
51
+ );
52
+ assert!(d.is_empty(), "a default supplies the argument, got {:?}", d);
53
+ }
54
+
55
+ #[test]
56
+ fn a_typed_parameter_after_untyped_ones_still_forces_its_position() {
57
+ // A call cannot skip positions to reach `c`, so the minimum is 3 even though 0 and 1 are
58
+ // untyped — the minimum is one past the LAST typed parameter, not a count of typed ones.
59
+ let d = diags(
60
+ r#"
61
+ function g(a, b, c: i32): i32 { return c }
62
+ g(1, 2)
63
+ "#,
64
+ );
65
+ assert_eq!(d.len(), 1, "expected one diagnostic, got {:?}", d);
66
+ assert!(d[0].contains("needs 3"), "minimum is positional: {}", d[0]);
67
+ }
68
+
69
+ #[test]
70
+ fn a_spread_argument_is_not_second_guessed() {
71
+ // The count is unknowable at compile time; saying nothing beats guessing wrong.
72
+ let d = diags(
73
+ r#"
74
+ function h(a: i32, b: i32): i32 { return a + b }
75
+ let xs = [1, 2]
76
+ h(...xs)
77
+ "#,
78
+ );
79
+ assert!(d.is_empty(), "spread must not be flagged, got {:?}", d);
80
+ }
81
+
82
+ #[test]
83
+ fn a_nested_call_is_still_seen() {
84
+ // The reported shape. `for_each_stmt_expr` hands out only a statement's ROOT expression, so
85
+ // without full sub-expression walking this exact case — the one in the issue — is missed.
86
+ let d = diags(
87
+ r#"
88
+ function paintTyped(slot: i32, rv: i32): i32 { return slot + rv }
89
+ console.log("v = " + paintTyped(1))
90
+ "#,
91
+ );
92
+ assert_eq!(d.len(), 1, "nested call must be checked, got {:?}", d);
93
+ }
94
+
95
+ #[test]
96
+ fn a_correct_call_is_silent() {
97
+ let d = diags(
98
+ r#"
99
+ function paintTyped(slot: i32, rv: i32): i32 { return slot + rv }
100
+ paintTyped(1, 2)
101
+ "#,
102
+ );
103
+ assert!(d.is_empty(), "correct arity must not warn, got {:?}", d);
104
+ }
@@ -109,17 +109,17 @@ pub fn process_argv() -> Vec<String> {
109
109
  }
110
110
  }
111
111
 
112
- /// #303 — a thrown JS value parked while it unwinds across a boundary that can't carry a `Result`.
113
- ///
114
- /// The native value-fn ABI is `Fn(&[Value]) -> Value`, and `Callable::call` likewise returns a bare
115
- /// `Value`, so a `throw` crossing such a boundary can't ride a `Result`. It is parked here and picked
116
- /// up at the next checkpoint: native codegen checks it after each call; the VM checks it after each
117
- /// `Callable::call`; and the shared array builtins (`forEach`/`map`/`sort`/…) check it between
118
- /// elements so they stop iterating promptly instead of running the callback for the rest of the
119
- /// array. The slot lives in `tishlang_core` (rather than `tishlang_runtime`) so `tishlang_builtins`
120
- /// can poll it without a `builtins -> runtime` dependency cycle; `tishlang_runtime` and `tishlang_vm`
121
- /// share this one slot. First-throw-wins; drained exactly once by [`take_pending_throw`] at the frame
122
- /// that converts it back into a `Result`.
112
+ // #303 — a thrown JS value parked while it unwinds across a boundary that can't carry a `Result`.
113
+ //
114
+ // The native value-fn ABI is `Fn(&[Value]) -> Value`, and `Callable::call` likewise returns a bare
115
+ // `Value`, so a `throw` crossing such a boundary can't ride a `Result`. It is parked here and picked
116
+ // up at the next checkpoint: native codegen checks it after each call; the VM checks it after each
117
+ // `Callable::call`; and the shared array builtins (`forEach`/`map`/`sort`/…) check it between
118
+ // elements so they stop iterating promptly instead of running the callback for the rest of the
119
+ // array. The slot lives in `tishlang_core` (rather than `tishlang_runtime`) so `tishlang_builtins`
120
+ // can poll it without a `builtins -> runtime` dependency cycle; `tishlang_runtime` and `tishlang_vm`
121
+ // share this one slot. First-throw-wins; drained exactly once by [`take_pending_throw`] at the frame
122
+ // that converts it back into a `Result`.
123
123
  #[cfg(not(feature = "portable"))]
124
124
  thread_local! {
125
125
  static PENDING_THROW: core::cell::RefCell<Option<Value>> = const { core::cell::RefCell::new(None) };
@@ -151,6 +151,98 @@ pub fn take_pending_throw() -> Option<Value> {
151
151
  PENDING_THROW.with(|c| c.borrow_mut().take())
152
152
  }
153
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // Cooperative execution deadline (`tish test --timeout`).
156
+ //
157
+ // Without this a runaway loop is uninterruptible: the runner can only notice a blown
158
+ // deadline *after* the body returns, which never happens. Backends poll
159
+ // [`execution_deadline_exceeded`] on loop back-edges and abort with a catchable error.
160
+ //
161
+ // Disarmed is the default and costs one relaxed load — `tish run` never arms it. Armed,
162
+ // the clock is read once every `DEADLINE_POLL_MASK + 1` back-edges so a hot loop is not
163
+ // paying a `now()` per iteration.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ #[cfg(not(feature = "portable"))]
167
+ mod deadline {
168
+ use core::sync::atomic::{AtomicU64, Ordering};
169
+ use std::sync::LazyLock;
170
+ use std::time::Instant;
171
+
172
+ /// Nanoseconds since [`START`] at which execution must stop. 0 = disarmed.
173
+ static DEADLINE_NS: AtomicU64 = AtomicU64::new(0);
174
+ static START: LazyLock<Instant> = LazyLock::new(Instant::now);
175
+
176
+ const DEADLINE_POLL_MASK: u32 = 0x3FF;
177
+
178
+ thread_local! {
179
+ static POLL_TICK: core::cell::Cell<u32> = const { core::cell::Cell::new(0) };
180
+ static TRIPPED: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
181
+ }
182
+
183
+ /// Arm the deadline `millis` from now, or disarm with `None`. Clears any previous trip.
184
+ pub fn set_execution_deadline(millis: Option<u64>) {
185
+ TRIPPED.with(|t| t.set(false));
186
+ POLL_TICK.with(|t| t.set(0));
187
+ match millis {
188
+ Some(ms) => {
189
+ let at = START.elapsed().as_nanos() as u64 + ms.saturating_mul(1_000_000);
190
+ // 0 means "disarmed"; nudge an exact-zero deadline to 1ns.
191
+ DEADLINE_NS.store(at.max(1), Ordering::Relaxed);
192
+ }
193
+ None => DEADLINE_NS.store(0, Ordering::Relaxed),
194
+ }
195
+ }
196
+
197
+ /// Has the armed deadline passed? Cheap and false when disarmed.
198
+ #[inline]
199
+ pub fn execution_deadline_exceeded() -> bool {
200
+ let at = DEADLINE_NS.load(Ordering::Relaxed);
201
+ if at == 0 {
202
+ return false;
203
+ }
204
+ if TRIPPED.with(|t| t.get()) {
205
+ return true;
206
+ }
207
+ let tick = POLL_TICK.with(|t| {
208
+ let n = t.get().wrapping_add(1);
209
+ t.set(n);
210
+ n
211
+ });
212
+ if tick & DEADLINE_POLL_MASK != 0 {
213
+ return false;
214
+ }
215
+ if START.elapsed().as_nanos() as u64 >= at {
216
+ TRIPPED.with(|t| t.set(true));
217
+ return true;
218
+ }
219
+ false
220
+ }
221
+
222
+ /// Did this thread abort because the deadline tripped?
223
+ pub fn execution_deadline_tripped() -> bool {
224
+ TRIPPED.with(|t| t.get())
225
+ }
226
+ }
227
+
228
+ // Portable / single-core builds (GBA and friends) have no wall clock to poll.
229
+ #[cfg(feature = "portable")]
230
+ mod deadline {
231
+ pub fn set_execution_deadline(_millis: Option<u64>) {}
232
+ #[inline]
233
+ pub fn execution_deadline_exceeded() -> bool {
234
+ false
235
+ }
236
+ pub fn execution_deadline_tripped() -> bool {
237
+ false
238
+ }
239
+ }
240
+
241
+ pub use deadline::{execution_deadline_exceeded, execution_deadline_tripped, set_execution_deadline};
242
+
243
+ /// Message backends raise when [`execution_deadline_exceeded`] trips.
244
+ pub const DEADLINE_ERROR: &str = "Test exceeded its timeout and was aborted";
245
+
154
246
  // Current user-function call depth for the bytecode VM. The VM's recursive path builds a fresh
155
247
  // `Vm` per call (so no shared struct field can accumulate) and its `Callable::call` signature is
156
248
  // fixed — a thread-local is the VM's equivalent of the interpreter's shared `Rc<Cell>` counter.
@@ -639,6 +639,7 @@ impl Evaluator {
639
639
  }
640
640
  Statement::While { cond, body, .. } => {
641
641
  loop {
642
+ deadline_check()?;
642
643
  if !self.eval_expr(cond)?.is_truthy() {
643
644
  break;
644
645
  }
@@ -778,6 +779,10 @@ impl Evaluator {
778
779
  };
779
780
  let mut ret = Ok(Value::Null);
780
781
  loop {
782
+ if let Err(e) = deadline_check() {
783
+ ret = Err(e);
784
+ break;
785
+ }
781
786
  self.scope = Rc::clone(&loop_env);
782
787
  let cond_ok = match cond.as_ref() {
783
788
  Some(c) => match self.eval_expr(c) {
@@ -909,6 +914,7 @@ impl Evaluator {
909
914
  }
910
915
  Statement::DoWhile { body, cond, .. } => {
911
916
  loop {
917
+ deadline_check()?;
912
918
  match self.eval_statement(body) {
913
919
  Ok(_) => {}
914
920
  Err(EvalError::Break) => break,
@@ -5389,6 +5395,16 @@ impl Evaluator {
5389
5395
  }
5390
5396
  }
5391
5397
 
5398
+ /// Cooperative timeout poll for loop back-edges (`tish test --timeout`). Disarmed on every
5399
+ /// other path, where it costs one relaxed atomic load.
5400
+ #[inline]
5401
+ fn deadline_check() -> Result<(), EvalError> {
5402
+ if tishlang_core::execution_deadline_exceeded() {
5403
+ return Err(EvalError::Error(tishlang_core::DEADLINE_ERROR.to_string()));
5404
+ }
5405
+ Ok(())
5406
+ }
5407
+
5392
5408
  #[derive(Debug)]
5393
5409
  enum EvalError {
5394
5410
  Return(Value),
@@ -187,6 +187,16 @@ pub fn eval_to_core(v: &Value) -> Result<CoreValue, String> {
187
187
  }
188
188
  Value::Symbol(s) => Ok(CoreValue::Symbol(Arc::clone(s))),
189
189
  Value::Opaque(o) => Ok(CoreValue::Opaque(Arc::clone(o))),
190
+ // A core function that came IN through `core_to_eval` round-trips back out unchanged.
191
+ Value::CoreFn(f) => Ok(CoreValue::Function(f.clone())),
192
+ // Interpreter closures carry an AST body plus a scope that only the `Evaluator` can
193
+ // resolve, and they do not outlive it. Say so instead of printing a discriminant.
194
+ Value::Function { .. } | Value::Native(_) => Err(
195
+ "Cannot pass an interpreter function to a native module: interpreter closures are \
196
+ bound to the evaluator and cannot be invoked from native code. Use the default \
197
+ (VM) backend."
198
+ .to_string(),
199
+ ),
190
200
  _ => Err(format!(
191
201
  "Cannot pass {:?} to native function (unsupported type)",
192
202
  std::mem::discriminant(v)
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "3.3.3"
3
+ version = "3.7.0"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }