plum
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 +39 -17
- plum-cli/tests/assert_once_test.rs +54 -0
- plum-runtime/src/main.rs +37 -9
- plum-wasm-codegen/src/lib.rs +52 -23
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`
|
|
629
|
+
/// A pseudo-random `i64`: splitmix64 over process-wide state seeded once. Cheap,
|
|
630
|
-
/// dependency-free
|
|
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
|
-
|
|
639
|
+
let nanos = std::time::SystemTime::now()
|
|
639
|
-
|
|
640
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
640
|
-
|
|
641
|
+
.map(|d| d.as_nanos() as u64)
|
|
641
|
-
|
|
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
|
-
|
|
652
|
+
.wrapping_add(0x9E3779B97F4A7C15);
|
|
653
|
+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
|
643
|
-
|
|
654
|
+
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
|
644
|
-
|
|
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
|
-
|
|
269
|
+
let nanos = std::time::SystemTime::now()
|
|
263
|
-
|
|
270
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
264
|
-
|
|
271
|
+
.map(|d| d.as_nanos() as u64)
|
|
265
|
-
|
|
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
|
-
|
|
282
|
+
.wrapping_add(0x9E3779B97F4A7C15);
|
|
283
|
+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
|
267
|
-
|
|
284
|
+
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
|
268
|
-
|
|
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
|
-
|
|
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(
|
|
1503
|
+
ast::StringPart::Interp(rhs.clone()),
|
|
1504
1504
|
ast::StringPart::Text("\n Actual: ".to_string()),
|
|
1505
|
-
ast::StringPart::Interp(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|