plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
9682cec
— Peter John
2026-09-04T15:12:26+05:30
feat(plum-cli): colored test output, actual/expected values on failure
- README.md +3 -1
- examples/testing.plum +1 -1
- plum-cli/src/main.rs +14 -6
- plum-wasm-codegen/src/lib.rs +53 -2
README.md
CHANGED
|
@@ -218,11 +218,13 @@ test "add works"
|
|
|
218
218
|
$ plum test examples/testing.plum
|
|
219
219
|
└─ add works ✘
|
|
220
220
|
add(2, 2) == 5
|
|
221
|
+
Expected: 5
|
|
222
|
+
Actual: 4
|
|
221
223
|
|
|
222
224
|
0 passed, 1 failed
|
|
223
225
|
```
|
|
224
226
|
|
|
225
|
-
`assert` is a single statement with two behaviors depending on context. Outside a `test` block, a failing `assert` traps immediately, everywhere in the language, the same as it always has (`assert n > 0` above). Inside a `test` block, it's non-fatal instead: a failure is recorded
|
|
227
|
+
`assert` is a single statement with two behaviors depending on context. Outside a `test` block, a failing `assert` traps immediately, everywhere in the language, the same as it always has (`assert n > 0` above). Inside a `test` block, it's non-fatal instead: a failure is recorded — the condition's own source text, plus (for a top-level `==`/`!=`/`<`/... comparison) each side's own runtime value via its `.toStr()`, labeled `Expected`/`Actual` (right/left) — and the rest of the block keeps running, so one `test` can report every failing assertion in it, not just the first. `plum test`'s output is a tree, one `├─`/`└─` line per test with `✔`/`✘` (green/red in a real terminal), a failure listing each recorded assert underneath, followed by a `N passed, M failed` summary, with a non-zero exit code if anything failed. The actual/expected values aren't shown when either side is obviously a `Float` literal — string interpolation, which this reuses, doesn't support `Float` yet (see Known gaps) — a computed-`Float`-vs-computed-`Float` comparison with no literal on either side isn't caught by that guard, so it's the one remaining way this feature can't show values (the condition text itself is unaffected either way).
|
|
226
228
|
|
|
227
229
|
Full example: [`examples/testing.plum`](examples/testing.plum).
|
|
228
230
|
|
examples/testing.plum
CHANGED
|
@@ -5,7 +5,7 @@ fun isEven(n: Int) -> Bool =
|
|
|
5
5
|
n % 2 == 0
|
|
6
6
|
|
|
7
7
|
test "add works"
|
|
8
|
-
assert add(1, 2) ==
|
|
8
|
+
assert add(1, 2) == 3
|
|
9
9
|
assert add(2, 2) == 4
|
|
10
10
|
|
|
11
11
|
test "isEven works"
|
plum-cli/src/main.rs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
#![allow(non_snake_case)]
|
|
4
4
|
|
|
5
5
|
use std::fs;
|
|
6
|
-
use std::io::{self, Read};
|
|
6
|
+
use std::io::{self, IsTerminal, Read};
|
|
7
7
|
use std::process;
|
|
8
8
|
|
|
9
9
|
use anyhow::{Context, Result};
|
|
@@ -577,6 +577,13 @@ fn cmdTest(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()>
|
|
|
577
577
|
// corner), "✔"/"✘" for the outcome, and a failure's report indented under
|
|
578
578
|
// a continuation "│" (or blank once past the last test) so it visually
|
|
579
579
|
// nests under its own branch rather than reading like a new top-level line.
|
|
580
|
+
// Colored green/red like every other test runner's PASS/FAIL — but only
|
|
581
|
+
// when stdout is a real terminal, so piping `plum test` into a file/CI log
|
|
582
|
+
// doesn't fill it with raw escape codes.
|
|
583
|
+
let color = io::stdout().is_terminal();
|
|
584
|
+
let green = |s: &str| if color { format!("\x1b[32m{s}\x1b[0m") } else { s.to_string() };
|
|
585
|
+
let red = |s: &str| if color { format!("\x1b[31m{s}\x1b[0m") } else { s.to_string() };
|
|
586
|
+
|
|
580
587
|
let mut passed = 0usize;
|
|
581
588
|
let mut failed = 0usize;
|
|
582
589
|
let last = names.len() - 1;
|
|
@@ -600,25 +607,26 @@ fn cmdTest(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()>
|
|
|
600
607
|
Ok(()) => {
|
|
601
608
|
let report = readStrResult(&mut store, &results[0])?;
|
|
602
609
|
if report.is_empty() {
|
|
603
|
-
println!("{branch} {name} \u{2714}");
|
|
610
|
+
println!("{branch} {name} {}", green("\u{2714}"));
|
|
604
611
|
passed += 1;
|
|
605
612
|
} else {
|
|
606
|
-
println!("{branch} {name} \u{2718}");
|
|
613
|
+
println!("{branch} {name} {}", red("\u{2718}"));
|
|
607
614
|
for line in report.lines() {
|
|
608
|
-
println!("{cont} {
|
|
615
|
+
println!("{cont} {}", red(line));
|
|
609
616
|
}
|
|
610
617
|
failed += 1;
|
|
611
618
|
}
|
|
612
619
|
}
|
|
613
620
|
Err(trap) => {
|
|
614
|
-
println!("{branch} {name}
|
|
621
|
+
println!("{branch} {name} {} PANIC: {trap}", red("\u{2718}"));
|
|
615
622
|
failed += 1;
|
|
616
623
|
}
|
|
617
624
|
}
|
|
618
625
|
}
|
|
619
626
|
|
|
620
627
|
println!();
|
|
621
|
-
|
|
628
|
+
let summary = format!("{passed} passed, {failed} failed");
|
|
629
|
+
println!("{}", if failed > 0 { red(&summary) } else { green(&summary) });
|
|
622
630
|
if failed > 0 {
|
|
623
631
|
process::exit(1);
|
|
624
632
|
}
|
plum-wasm-codegen/src/lib.rs
CHANGED
|
@@ -910,6 +910,58 @@ fn strLit(s: String) -> ast::Expr {
|
|
|
910
910
|
ast::Expr::String(ast::StringExpr { parts: vec![ast::StringPart::Text(s)] })
|
|
911
911
|
}
|
|
912
912
|
|
|
913
|
+
/// True if `expr` is OBVIOUSLY float-valued from its own AST shape alone (a
|
|
914
|
+
/// bare float literal) — no real type inference, just enough to catch the
|
|
915
|
+
/// overwhelmingly common `assert computed() == 1.5` shape. `"{expr}"` string
|
|
916
|
+
/// interpolation doesn't support `Float` yet (see README's Known gaps), so
|
|
917
|
+
/// `assertFailureMessage` skips the actual/expected enhancement whenever
|
|
918
|
+
/// either side looks like one, rather than break compiling a real test that
|
|
919
|
+
/// compares a float against a literal (a computed-float-vs-computed-float
|
|
920
|
+
/// comparison with no literal on either side isn't caught by this — a
|
|
921
|
+
/// narrower residual gap, not a new one: interpolating that value was never
|
|
922
|
+
/// going to work either way).
|
|
923
|
+
fn looksLikeFloat(expr: &ast::Expr) -> bool {
|
|
924
|
+
match expr {
|
|
925
|
+
ast::Expr::Float(_) => true,
|
|
926
|
+
// `-1.5` parses as a unary negation wrapping the literal, not a bare
|
|
927
|
+
// `Expr::Float` itself.
|
|
928
|
+
ast::Expr::Unary(u) => looksLikeFloat(&u.operand),
|
|
929
|
+
_ => false,
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/// Builds a failing `assert`'s report line: the literal condition text, plus —
|
|
934
|
+
/// when the condition is a top-level comparison (`left OP right`) whose sides
|
|
935
|
+
/// aren't obviously `Float` (see `looksLikeFloat`) — the ACTUAL (left) and
|
|
936
|
+
/// EXPECTED (right) sides' own runtime values underneath, `assert computed()
|
|
937
|
+
/// == literal` being the overwhelmingly common shape a test writes such a
|
|
938
|
+
/// comparison in. Reuses `"{expr}"` string interpolation's own existing
|
|
939
|
+
/// codegen (rather than any special stringification of its own) — so this
|
|
940
|
+
/// shows a value for any type interpolation already supports today, and
|
|
941
|
+
/// fails to compile with that same, ordinary interpolation error for a type
|
|
942
|
+
/// it doesn't (no separate risk introduced beyond what `looksLikeFloat`
|
|
943
|
+
/// already guards against). A non-comparison condition (`assert isEven(4)`)
|
|
944
|
+
/// has no meaningful actual/expected split — a boolean condition is always
|
|
945
|
+
/// false by the time this fires — so it's just the bare text.
|
|
946
|
+
fn assertFailureMessage(c: &ast::Check) -> ast::Expr {
|
|
947
|
+
let ast::Expr::Compare(cmp) = &c.cond else {
|
|
948
|
+
return strLit(format!("{}\n", c.text));
|
|
949
|
+
};
|
|
950
|
+
if looksLikeFloat(&cmp.left) || looksLikeFloat(&cmp.right) {
|
|
951
|
+
return strLit(format!("{}\n", c.text));
|
|
952
|
+
}
|
|
953
|
+
ast::Expr::String(ast::StringExpr {
|
|
954
|
+
parts: vec![
|
|
955
|
+
ast::StringPart::Text(format!("{}\n", c.text)),
|
|
956
|
+
ast::StringPart::Text(" Expected: ".to_string()),
|
|
957
|
+
ast::StringPart::Interp(cmp.right.clone()),
|
|
958
|
+
ast::StringPart::Text("\n Actual: ".to_string()),
|
|
959
|
+
ast::StringPart::Interp(cmp.left.clone()),
|
|
960
|
+
ast::StringPart::Text("\n".to_string()),
|
|
961
|
+
],
|
|
962
|
+
})
|
|
963
|
+
}
|
|
964
|
+
|
|
913
965
|
fn desugarTestsToFns(source: &ast::Source) -> (ast::Source, Vec<(String, String)>) {
|
|
914
966
|
let mut items = Vec::with_capacity(source.items.len());
|
|
915
967
|
let mut extra_exports = Vec::new();
|
|
@@ -955,13 +1007,12 @@ fn desugarTestsToFns(source: &ast::Source) -> (ast::Source, Vec<(String, String)
|
|
|
955
1007
|
fn desugarAssertStmt(stmt: &ast::Stmt) -> ast::Stmt {
|
|
956
1008
|
match stmt {
|
|
957
1009
|
ast::Stmt::Assert(c) => {
|
|
958
|
-
let message = format!("{}\n", c.text);
|
|
959
1010
|
let appendReport = ast::Stmt::Assign(ast::Assign {
|
|
960
1011
|
targets: vec![ast::AssignTarget::Var("__report".to_string())],
|
|
961
1012
|
values: vec![ast::Expr::Binary(Box::new(ast::BinaryExpr {
|
|
962
1013
|
op: ast::BinOp::Add,
|
|
963
1014
|
left: ast::Expr::Var("__report".to_string()),
|
|
964
|
-
right:
|
|
1015
|
+
right: assertFailureMessage(c),
|
|
965
1016
|
}))],
|
|
966
1017
|
declare: false,
|
|
967
1018
|
});
|