plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


ec115b0 — Peter John 2026-10-26T18:56:27+05:30
fix: stateful host RNG; evaluate assert operands once in test blocks
plum-cli/src/main.rs CHANGED
@@ -626,24 +626,33 @@ fn performHttpRequest(method: &str, url: &str, headers: &str, body: &str) -> Str
626
626
  }
627
627
  }
628
628
 
629
- /// A pseudo-random `i64` seeded from the wall clock — `xorshift64*`, cheap and
629
+ /// A pseudo-random `i64`: splitmix64 over process-wide state seeded once. Cheap,
630
- /// dependency-free (no `rand` crate in this workspace). Not cryptographically
630
+ /// dependency-free, not cryptographically secure.
631
- /// secure; fine for `Int.random`/`List.shuffle`/`Uuid.v4`, none of which need
632
- /// that. Re-seeded from the clock on every call rather than keeping PRNG state
633
- /// across calls — simpler than threading mutable state through `Store<()>`'s
634
- /// otherwise-stateless `()` data, at the cost of needing enough clock
635
- /// resolution between calls to avoid repeats (nanosecond resolution is ample
636
- /// for this use).
637
631
  fn randomI64() -> i64 {
632
+ use std::sync::atomic::{AtomicU64, Ordering};
633
+ // splitmix64 over a process-wide counter: seeded once from the clock and
634
+ // an address, then advanced by a fixed odd increment per call, so
635
+ // back-to-back calls can never repeat.
636
+ static STATE: AtomicU64 = AtomicU64::new(0);
637
+ let mut cur = STATE.load(Ordering::Relaxed);
638
+ if cur == 0 {
638
- let nanos = std::time::SystemTime::now()
639
+ let nanos = std::time::SystemTime::now()
639
- .duration_since(std::time::UNIX_EPOCH)
640
+ .duration_since(std::time::UNIX_EPOCH)
640
- .map(|d| d.as_nanos() as u64)
641
+ .map(|d| d.as_nanos() as u64)
641
- .unwrap_or(0);
642
+ .unwrap_or(0);
643
+ let seed = (nanos ^ (&STATE as *const AtomicU64 as u64)) | 1;
644
+ cur = match STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed) {
645
+ Ok(_) => seed,
646
+ Err(v) => v,
647
+ };
648
+ }
649
+ let _ = cur;
650
+ let mut z = STATE
651
+ .fetch_add(0x9E3779B97F4A7C15, Ordering::Relaxed)
642
- let mut x = nanos ^ 0x9E3779B97F4A7C15;
652
+ .wrapping_add(0x9E3779B97F4A7C15);
653
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
643
- x ^= x << 13;
654
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
644
- x ^= x >> 7;
655
+ (z ^ (z >> 31)) as i64
645
- x ^= x << 17;
646
- x as i64
647
656
  }
648
657
 
649
658
  /// `Str` is now a real two-level wasm-gc struct — `Str{data: Buffer{data:
@@ -1083,3 +1092,16 @@ fn cmdBuild(
1083
1092
  eprintln!("built {} → {}", file.display(), out_path.display());
1084
1093
  Ok(())
1085
1094
  }
1095
+
1096
+ #[cfg(test)]
1097
+ mod rng_tests {
1098
+ #[test]
1099
+ fn random_i64_never_repeats_back_to_back() {
1100
+ let mut prev = super::randomI64();
1101
+ for _ in 0..100_000 {
1102
+ let next = super::randomI64();
1103
+ assert_ne!(prev, next);
1104
+ prev = next;
1105
+ }
1106
+ }
1107
+ }
plum-cli/tests/assert_once_test.rs ADDED
@@ -0,0 +1,54 @@
1
+ #![allow(non_snake_case)]
2
+ use std::process::Command;
3
+
4
+ fn plumBin() -> std::path::PathBuf {
5
+ let mut path = std::env::current_exe().unwrap();
6
+ path.pop();
7
+ if path.ends_with("deps") {
8
+ path.pop();
9
+ }
10
+ path.push("plum");
11
+ path
12
+ }
13
+
14
+ /// A failing `assert a == b` must evaluate each operand exactly once, and
15
+ /// report the very values it compared.
16
+ #[test]
17
+ fn assert_operands_evaluated_once() {
18
+ let dir = std::env::temp_dir().join(format!("plum_assert_once_{}", std::process::id()));
19
+ std::fs::create_dir_all(&dir).unwrap();
20
+ let src = dir.join("once.plum");
21
+ std::fs::write(
22
+ &src,
23
+ r#"import std/Str
24
+ import std/Number
25
+
26
+ enum Counter =
27
+ | Counter(n: Int)
28
+
29
+ fun bump(self) -> Int =
30
+ self.n = self.n + 1
31
+ return self.n
32
+
33
+ test "operands once"
34
+ a := Counter(n: 0)
35
+ b := Counter(n: 100)
36
+ assert a.bump() == b.bump()
37
+ assert a.n == 1
38
+ assert b.n == 101
39
+ "#,
40
+ )
41
+ .unwrap();
42
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
43
+ let out = Command::new(plumBin())
44
+ .current_dir(root)
45
+ .args(["test", src.to_str().unwrap()])
46
+ .output()
47
+ .expect("failed to run plum");
48
+ let stdout = String::from_utf8_lossy(&out.stdout);
49
+ // Only the first assert (1 vs 101) fails; the counters advanced once each.
50
+ assert!(stdout.contains("Expected: 101"), "stdout was: {}", stdout);
51
+ assert!(stdout.contains("Actual: 1"), "stdout was: {}", stdout);
52
+ assert!(!stdout.contains("a.n == 1"), "counter advanced more than once: {}", stdout);
53
+ assert!(!stdout.contains("b.n == 101"), "counter advanced more than once: {}", stdout);
54
+ }
plum-runtime/src/main.rs CHANGED
@@ -259,15 +259,30 @@ fn performHttpRequest(method: &str, url: &str, headers: &str, body: &str) -> Str
259
259
  /// fresh per `plum build` invocation, not linked against `plum-cli`, so the
260
260
  /// logic is duplicated rather than shared.
261
261
  fn randomI64() -> i64 {
262
+ use std::sync::atomic::{AtomicU64, Ordering};
263
+ // splitmix64 over a process-wide counter: seeded once from the clock and
264
+ // an address, then advanced by a fixed odd increment per call, so
265
+ // back-to-back calls can never repeat.
266
+ static STATE: AtomicU64 = AtomicU64::new(0);
267
+ let mut cur = STATE.load(Ordering::Relaxed);
268
+ if cur == 0 {
262
- let nanos = std::time::SystemTime::now()
269
+ let nanos = std::time::SystemTime::now()
263
- .duration_since(std::time::UNIX_EPOCH)
270
+ .duration_since(std::time::UNIX_EPOCH)
264
- .map(|d| d.as_nanos() as u64)
271
+ .map(|d| d.as_nanos() as u64)
265
- .unwrap_or(0);
272
+ .unwrap_or(0);
273
+ let seed = (nanos ^ (&STATE as *const AtomicU64 as u64)) | 1;
274
+ cur = match STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed) {
275
+ Ok(_) => seed,
276
+ Err(v) => v,
277
+ };
278
+ }
279
+ let _ = cur;
280
+ let mut z = STATE
281
+ .fetch_add(0x9E3779B97F4A7C15, Ordering::Relaxed)
266
- let mut x = nanos ^ 0x9E3779B97F4A7C15;
282
+ .wrapping_add(0x9E3779B97F4A7C15);
283
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
267
- x ^= x << 13;
284
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
268
- x ^= x >> 7;
285
+ (z ^ (z >> 31)) as i64
269
- x ^= x << 17;
270
- x as i64
271
286
  }
272
287
 
273
288
  /// See the identical helpers in `plum-cli/src/main.rs` — this crate is built
@@ -390,3 +405,16 @@ fn main() {
390
405
  std::process::exit(1);
391
406
  }
392
407
  }
408
+
409
+ #[cfg(test)]
410
+ mod rng_tests {
411
+ #[test]
412
+ fn random_i64_never_repeats_back_to_back() {
413
+ let mut prev = super::randomI64();
414
+ for _ in 0..100_000 {
415
+ let next = super::randomI64();
416
+ assert_ne!(prev, next);
417
+ prev = next;
418
+ }
419
+ }
420
+ }
plum-wasm-codegen/src/lib.rs CHANGED
@@ -1492,17 +1492,17 @@ fn strLit(s: String) -> ast::Expr {
1492
1492
  /// special-casing out here any more. A non-comparison condition (`assert
1493
1493
  /// isEven(4)`) has no meaningful actual/expected split — a boolean condition
1494
1494
  /// is always false by the time this fires — so it's just the bare text.
1495
- fn assertFailureMessage(c: &ast::Check) -> ast::Expr {
1495
+ fn assertFailureMessage(c: &ast::Check, lhs: &ast::Expr, rhs: &ast::Expr) -> ast::Expr {
1496
- let ast::Expr::Compare(cmp) = &c.cond else {
1496
+ if !matches!(&c.cond, ast::Expr::Compare(_)) {
1497
1497
  return strLit(format!("{}\n", c.text));
1498
- };
1498
+ }
1499
1499
  ast::Expr::String(ast::StringExpr {
1500
1500
  parts: vec![
1501
1501
  ast::StringPart::Text(format!("{}\n", c.text)),
1502
1502
  ast::StringPart::Text(" Expected: ".to_string()),
1503
- ast::StringPart::Interp(cmp.right.clone()),
1503
+ ast::StringPart::Interp(rhs.clone()),
1504
1504
  ast::StringPart::Text("\n Actual: ".to_string()),
1505
- ast::StringPart::Interp(cmp.left.clone()),
1505
+ ast::StringPart::Interp(lhs.clone()),
1506
1506
  ast::StringPart::Text("\n".to_string()),
1507
1507
  ],
1508
1508
  })
@@ -1526,7 +1526,7 @@ fn desugarTestsToFns(source: &ast::Source) -> (ast::Source, Vec<(String, String)
1526
1526
  values: vec![strLit(String::new())],
1527
1527
  declare: true,
1528
1528
  }));
1529
- stmts.extend(t.body.stmts.iter().map(desugarAssertStmt));
1529
+ stmts.extend(desugarAssertStmts(&t.body.stmts));
1530
1530
  stmts.push(ast::Stmt::Expr(
1531
1531
  ast::Expr::Var("__report".to_string()),
1532
1532
  0,
@@ -1571,31 +1571,60 @@ fn desugarTestsToFns(source: &ast::Source) -> (ast::Source, Vec<(String, String)
1571
1571
  /// `if !cond { __report = __report + "<text>\n" }`, descending into nested
1572
1572
  /// control-flow blocks so an `assert` inside a `test`'s own
1573
1573
  /// `if`/`while`/`for`/`match` still records into the same hidden accumulator.
1574
+ fn desugarAssertStmts(stmts: &[ast::Stmt]) -> Vec<ast::Stmt> {
1575
+ stmts.iter().flat_map(desugarAssertStmt).collect()
1576
+ }
1577
+
1574
- fn desugarAssertStmt(stmt: &ast::Stmt) -> ast::Stmt {
1578
+ fn desugarAssertStmt(stmt: &ast::Stmt) -> Vec<ast::Stmt> {
1575
1579
  match stmt {
1576
1580
  ast::Stmt::Assert(c) => {
1581
+ // A comparison evaluates each operand exactly once into a
1582
+ // temporary (left first), then compares and reports the temporaries.
1583
+ static ASSERT_TMP: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1584
+ let mut pre = Vec::new();
1585
+ let mut cond = c.cond.clone();
1586
+ let (mut lhs, mut rhs) = (ast::Expr::Var(String::new()), ast::Expr::Var(String::new()));
1587
+ if let ast::Expr::Compare(cmp) = &c.cond {
1588
+ let n = ASSERT_TMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1589
+ let (l, r) = (format!("__lhs_{}", n), format!("__rhs_{}", n));
1590
+ for (name, e) in [(&l, &cmp.left), (&r, &cmp.right)] {
1591
+ pre.push(ast::Stmt::Assign(ast::Assign {
1592
+ targets: vec![ast::AssignTarget::Var(name.clone())],
1593
+ values: vec![e.clone()],
1594
+ declare: true,
1595
+ }));
1596
+ }
1597
+ lhs = ast::Expr::Var(l);
1598
+ rhs = ast::Expr::Var(r);
1599
+ cond = ast::Expr::Compare(Box::new(ast::CompareExpr {
1600
+ op: cmp.op.clone(),
1601
+ left: lhs.clone(),
1602
+ right: rhs.clone(),
1603
+ }));
1604
+ }
1577
1605
  let appendReport = ast::Stmt::Assign(ast::Assign {
1578
1606
  targets: vec![ast::AssignTarget::Var("__report".to_string())],
1579
1607
  values: vec![ast::Expr::Binary(Box::new(ast::BinaryExpr {
1580
1608
  op: ast::BinOp::Add,
1581
1609
  left: ast::Expr::Var("__report".to_string()),
1582
- right: assertFailureMessage(c),
1610
+ right: assertFailureMessage(c, &lhs, &rhs),
1583
1611
  }))],
1584
1612
  declare: false,
1585
1613
  });
1586
- ast::Stmt::If(ast::If {
1614
+ pre.push(ast::Stmt::If(ast::If {
1587
- condition: ast::Expr::Not(Box::new(c.cond.clone())),
1615
+ condition: ast::Expr::Not(Box::new(cond)),
1588
1616
  body: ast::Block {
1589
1617
  stmts: vec![appendReport],
1590
1618
  },
1591
1619
  else_ifs: vec![],
1592
1620
  else_: None,
1593
- })
1621
+ }));
1622
+ pre
1594
1623
  }
1595
- ast::Stmt::If(i) => ast::Stmt::If(ast::If {
1624
+ ast::Stmt::If(i) => vec![ast::Stmt::If(ast::If {
1596
1625
  condition: i.condition.clone(),
1597
1626
  body: ast::Block {
1598
- stmts: i.body.stmts.iter().map(desugarAssertStmt).collect(),
1627
+ stmts: desugarAssertStmts(&i.body.stmts),
1599
1628
  },
1600
1629
  else_ifs: i
1601
1630
  .else_ifs
@@ -1603,21 +1632,21 @@ fn desugarAssertStmt(stmt: &ast::Stmt) -> ast::Stmt {
1603
1632
  .map(|ei| ast::ElseIf {
1604
1633
  condition: ei.condition.clone(),
1605
1634
  body: ast::Block {
1606
- stmts: ei.body.stmts.iter().map(desugarAssertStmt).collect(),
1635
+ stmts: desugarAssertStmts(&ei.body.stmts),
1607
1636
  },
1608
1637
  })
1609
1638
  .collect(),
1610
1639
  else_: i.else_.as_ref().map(|b| ast::Block {
1611
- stmts: b.stmts.iter().map(desugarAssertStmt).collect(),
1640
+ stmts: desugarAssertStmts(&b.stmts),
1612
1641
  }),
1613
- }),
1642
+ })],
1614
- ast::Stmt::While(w) => ast::Stmt::While(ast::While {
1643
+ ast::Stmt::While(w) => vec![ast::Stmt::While(ast::While {
1615
1644
  condition: w.condition.clone(),
1616
1645
  body: ast::Block {
1617
- stmts: w.body.stmts.iter().map(desugarAssertStmt).collect(),
1646
+ stmts: desugarAssertStmts(&w.body.stmts),
1618
1647
  },
1619
- }),
1648
+ })],
1620
- ast::Stmt::Match(m) => ast::Stmt::Match(ast::Match {
1649
+ ast::Stmt::Match(m) => vec![ast::Stmt::Match(ast::Match {
1621
1650
  subjects: m.subjects.clone(),
1622
1651
  cases: m
1623
1652
  .cases
@@ -1626,12 +1655,12 @@ fn desugarAssertStmt(stmt: &ast::Stmt) -> ast::Stmt {
1626
1655
  patterns: c.patterns.clone(),
1627
1656
  guard: c.guard.clone(),
1628
1657
  body: ast::Block {
1629
- stmts: c.body.stmts.iter().map(desugarAssertStmt).collect(),
1658
+ stmts: desugarAssertStmts(&c.body.stmts),
1630
1659
  },
1631
1660
  })
1632
1661
  .collect(),
1633
- }),
1662
+ })],
1634
- other => other.clone(),
1663
+ other => vec![other.clone()],
1635
1664
  }
1636
1665
  }
1637
1666