@tishlang/tish-format 3.3.3 → 3.6.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.
@@ -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
  }
@@ -1231,6 +1231,29 @@ fn resolve_import_path(
1231
1231
  spec
1232
1232
  ));
1233
1233
  }
1234
+ // #618: build-time package-variant selection. `package.json` may remap an import specifier to
1235
+ // another module, which is tish's equivalent of a cargo feature swapping one implementation for
1236
+ // another:
1237
+ //
1238
+ // "tish": { "packageOverrides": { "./drop_tables": "./drop_tables_pocket" } }
1239
+ //
1240
+ // Applied to the SPECIFIER before any resolution, so it works for relative paths and package
1241
+ // names alike, and the replacement is then resolved by the ordinary rules (relative to the
1242
+ // importing file, as the original would have been). Matching is exact on the written
1243
+ // specifier — no prefix or glob matching, so an override cannot silently capture an import the
1244
+ // author did not mean to redirect.
1245
+ //
1246
+ // A remapped specifier is NOT re-checked against the override table: one substitution per
1247
+ // import, so a table that maps A->B and B->A terminates instead of looping.
1248
+ let overridden = {
1249
+ let cfg = read_project_tish_config(project_root);
1250
+ cfg.get("packageOverrides")
1251
+ .and_then(|o| o.as_object())
1252
+ .and_then(|o| o.get(spec))
1253
+ .and_then(|v| v.as_str())
1254
+ .map(|s| s.to_string())
1255
+ };
1256
+ let spec: &str = overridden.as_deref().unwrap_or(spec);
1234
1257
  if !spec.starts_with("./") && !spec.starts_with("../") {
1235
1258
  if let Some(path) = resolve_bare_spec(spec, from_dir, project_root) {
1236
1259
  return Ok(path);
@@ -1445,10 +1468,26 @@ fn collect_destructure_names(pattern: &DestructPattern, out: &mut HashSet<String
1445
1468
 
1446
1469
  /// Rename each module's non-exported top-level bindings whose name also occurs as a top-level
1447
1470
  /// name in another module, isolating module-private declarations (#97).
1448
- fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
1471
+ ///
1472
+ /// #587: this ALSO covers colliding EXPORTED names in non-entry modules, returning
1473
+ /// `renamed symbol -> original export name` per module so the export table can still be keyed by the
1474
+ /// name importers ask for. The original carve-out ("exported names stay stable so imports keep
1475
+ /// resolving") was stricter than necessary: `module_exports` is already an INDIRECTION from export
1476
+ /// name to source symbol, so the symbol is free to move as long as the key does not.
1477
+ ///
1478
+ /// Leaving exported collisions alone was not merely a missed optimisation — it silently produced a
1479
+ /// wrong program. Two modules exporting `greet`, flattened into one scope, emit two
1480
+ /// `function greet` declarations; JS function declarations HOIST, so the second overwrote the first
1481
+ /// before `const _greet = greet` ran, and a barrel that re-wrapped its own import recursed until the
1482
+ /// stack blew.
1483
+ ///
1484
+ /// The ENTRY module is deliberately excluded: its exports are the bundle's public surface, so
1485
+ /// renaming them would change the API the caller sees.
1486
+ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) -> Vec<HashMap<String, String>> {
1449
1487
  let n = modules.len();
1488
+ let mut export_renames: Vec<HashMap<String, String>> = vec![HashMap::new(); n];
1450
1489
  if n < 2 {
1451
- return; // a single module cannot collide with another
1490
+ return export_renames; // a single module cannot collide with another
1452
1491
  }
1453
1492
  let mut decls: Vec<HashSet<String>> = vec![HashSet::new(); n];
1454
1493
  let mut exported: Vec<HashSet<String>> = vec![HashSet::new(); n];
@@ -1471,13 +1510,35 @@ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
1471
1510
  *count.entry(name.as_str()).or_insert(0) += 1;
1472
1511
  }
1473
1512
  }
1513
+ // #587: for EXPORTED names the question is different, and `occupancy` answers the wrong one.
1514
+ // It counts import bindings, so a dep's exported `greet` looks like it collides with the
1515
+ // importer's binding of that same `greet` — but those are one thing, not two, and renaming on
1516
+ // that basis breaks nothing yet churns every ordinary import. A real collision is another module
1517
+ // DECLARING the name, so exported renames key off declarations only.
1518
+ let mut decl_count: HashMap<&str, usize> = HashMap::new();
1519
+ for d in &decls {
1520
+ for name in d {
1521
+ *decl_count.entry(name.as_str()).or_insert(0) += 1;
1522
+ }
1523
+ }
1474
1524
  for (i, m) in modules.iter_mut().enumerate() {
1525
+ let is_entry = i + 1 == n; // the entry module is last — its exports are the public surface
1475
1526
  let mut renames: HashMap<String, Arc<str>> = HashMap::new();
1476
1527
  for name in &decls[i] {
1477
- if exported[i].contains(name) {
1478
- continue; // exported names stay stable so imports keep resolving
1528
+ if count.get(name.as_str()).copied().unwrap_or(0) <= 1 {
1529
+ continue; // no collision — leave it exactly as it is (zero blast radius)
1479
1530
  }
1480
- if count.get(name.as_str()).copied().unwrap_or(0) > 1 {
1531
+ if exported[i].contains(name) {
1532
+ // #587: an exported collision in a NON-ENTRY module is renamed too, and the new
1533
+ // symbol is recorded so `module_exports` can still answer to the original name.
1534
+ // Gated on DECLARATION count — see the note above.
1535
+ if is_entry || decl_count.get(name.as_str()).copied().unwrap_or(0) <= 1 {
1536
+ continue;
1537
+ }
1538
+ let renamed = format!("{name}__m{i}");
1539
+ export_renames[i].insert(renamed.clone(), name.clone());
1540
+ renames.insert(name.clone(), Arc::from(renamed));
1541
+ } else {
1481
1542
  renames.insert(name.clone(), Arc::from(format!("{name}__m{i}")));
1482
1543
  }
1483
1544
  }
@@ -1491,6 +1552,7 @@ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
1491
1552
  rewrite_stmt_scope(stmt, &mut active, true);
1492
1553
  }
1493
1554
  }
1555
+ export_renames
1494
1556
  }
1495
1557
 
1496
1558
  /// Apply the rename for a *declared* name. At module top level the binding is the canonical
@@ -1896,7 +1958,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1896
1958
  }
1897
1959
 
1898
1960
  // #97: isolate module-private top-level bindings before they are flattened together.
1899
- isolate_private_top_level_bindings(&mut modules);
1961
+ let export_renames = isolate_private_top_level_bindings(&mut modules);
1900
1962
 
1901
1963
  let path_to_idx: HashMap<PathBuf, usize> = modules
1902
1964
  .iter()
@@ -1916,7 +1978,14 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1916
1978
  }
1917
1979
  _ => continue,
1918
1980
  };
1919
- module_exports[idx].insert(name.clone(), name);
1981
+ // #587: if this symbol was renamed for an exported collision, the export
1982
+ // table must still answer to the name importers ask for — key by the
1983
+ // ORIGINAL, point at the renamed symbol.
1984
+ let key = export_renames[idx]
1985
+ .get(&name)
1986
+ .cloned()
1987
+ .unwrap_or_else(|| name.clone());
1988
+ module_exports[idx].insert(key, name);
1920
1989
  }
1921
1990
  ExportDeclaration::Default(_) => {
1922
1991
  let default_name = format!("__default_{}", idx);
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "3.3.3"
3
+ version = "3.6.0"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "3.3.3",
3
+ "version": "3.6.0",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file