plum

#treesitter#compiler#wasm

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

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


9682cecPeter John 2026-09-04T15:12:26+05:30
feat(plum-cli): colored test output, actual/expected values on failure
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 as its own literal condition text, 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 `✔`/`✘`, a failure listing each recorded assert's condition underneath, followed by a `N passed, M failed` summary, with a non-zero exit code if anything failed.
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) == 4
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} {line}");
615
+ println!("{cont} {}", red(line));
609
616
  }
610
617
  failed += 1;
611
618
  }
612
619
  }
613
620
  Err(trap) => {
614
- println!("{branch} {name} \u{2718} PANIC: {trap}");
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
- println!("{passed} passed, {failed} failed");
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: strLit(message),
1015
+ right: assertFailureMessage(c),
965
1016
  }))],
966
1017
  declare: false,
967
1018
  });