plum

#treesitter#compiler#wasm

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

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


7a7fd58 — Peter John 2026-11-01T22:57:09+05:30
feat: Byte struct fields; assert report falls back without toStr; plum test runs only the named files' tests; MapPoint test moved out of std
README.md CHANGED
@@ -418,7 +418,7 @@ u2 := u
418
418
  u2.age = 4 # u.age is now 4: a record is a heap object, shared on assignment
419
419
  ```
420
420
 
421
- A `struct` has no generics; its fields are only `Int`, `Float`, `Bool` or other structs, and a struct can't contain itself. Values are copied on assignment and when passed, and are boxed inside `T?` and other generic slots. Arrays of structs (`Array[Vec2]`, and so `List[Vec2]` and `Grid[Tile]`) are stored field by field, one native array per field, with no per-element allocation. A `record` may have any field types and may be generic (`record Pair[K, V](key: K, val: V)`). Methods and `match` work as for any enum. See [`plum-examples/values.plum`](plum-examples/values.plum).
421
+ A `struct` has no generics; its fields are only `Int`, `Float`, `Bool`, `Byte` or other structs, and a struct can't contain itself. Values are copied on assignment and when passed, and are boxed inside `T?` and other generic slots. Arrays of structs (`Array[Vec2]`, and so `List[Vec2]` and `Grid[Tile]`) are stored field by field, one native array per field, with no per-element allocation. A `record` may have any field types and may be generic (`record Pair[K, V](key: K, val: V)`). Methods and `match` work as for any enum. See [`plum-examples/values.plum`](plum-examples/values.plum).
422
422
 
423
423
  ### Enums
424
424
 
@@ -965,7 +965,7 @@ Each error name gets a global `i32` code, interned in sorted order from 1 (0 mea
965
965
 
966
966
  ## Testing
967
967
 
968
- Zig-style `test` blocks are a native language feature, compiled and run only by `plum test`. `plum test` takes files and directories (searched recursively):
968
+ Zig-style `test` blocks are a native language feature, compiled and run only by `plum test`. `plum test` takes files and directories (searched recursively) and runs only the tests declared in those files, not the ones in their imports:
969
969
 
970
970
  ```plum
971
971
  fun add(a: Int, b: Int) -> Int =
plum-checker/src/lib.rs CHANGED
@@ -1315,13 +1315,13 @@ fn checkValueEnums(source: &ast::Source) -> Vec<CheckError> {
1315
1315
  let v = &e.variants[0];
1316
1316
  for (i, fty) in v.fields.iter().enumerate() {
1317
1317
  let ok = fty.generics.is_empty()
1318
- && (matches!(fty.name.as_str(), "Int" | "Float" | "Bool") || values.contains_key(fty.name.as_str()));
1318
+ && (matches!(fty.name.as_str(), "Int" | "Float" | "Bool" | "Byte") || values.contains_key(fty.name.as_str()));
1319
1319
  if !ok {
1320
1320
  let name = v.field_names.get(i).cloned().unwrap_or_else(|| i.to_string());
1321
1321
  errors.push(CheckError::at(
1322
1322
  e.line,
1323
1323
  e.column,
1324
- format!("struct field '{}' must be Int, Float, Bool or a struct", name),
1324
+ format!("struct field '{}' must be Int, Float, Bool, Byte or a struct", name),
1325
1325
  ));
1326
1326
  }
1327
1327
  }
plum-checker/tests/checker_tests.rs CHANGED
@@ -1581,7 +1581,7 @@ fn valueErrors(src: &str) -> Vec<String> {
1581
1581
  fn value_enum_str_field_rejected() {
1582
1582
  let errs = valueErrors("struct Named(label: Str, n: Int)\n");
1583
1583
  assert!(
1584
- errs.iter().any(|e| e.contains("struct field 'label' must be Int, Float, Bool or a struct")),
1584
+ errs.iter().any(|e| e.contains("struct field 'label' must be Int, Float, Bool, Byte or a struct")),
1585
1585
  "{errs:?}"
1586
1586
  );
1587
1587
  }
plum-cli/tests/test_scope_test.rs ADDED
@@ -0,0 +1,66 @@
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
+ fn runPlum(args: &[&str]) -> (String, bool) {
15
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
16
+ let out = Command::new(plumBin())
17
+ .current_dir(root)
18
+ .arg("test")
19
+ .args(args)
20
+ .output()
21
+ .expect("failed to run plum");
22
+ (String::from_utf8_lossy(&out.stdout).to_string(), out.status.success())
23
+ }
24
+
25
+ fn scratch(name: &str) -> std::path::PathBuf {
26
+ let dir = std::env::temp_dir().join(format!("plum_scope_{}_{}", name, std::process::id()));
27
+ std::fs::create_dir_all(&dir).unwrap();
28
+ dir
29
+ }
30
+
31
+ const TWO: &str = "import std/Str\nimport std/Int\n\ntest \"one\"\n assert 1 == 1\n\ntest \"two\"\n assert 2 == 2\n";
32
+
33
+ #[test]
34
+ fn fileRunsOnlyItsOwnTests() {
35
+ let dir = scratch("file");
36
+ let f = dir.join("two.plum");
37
+ std::fs::write(&f, TWO).unwrap();
38
+ let (out, ok) = runPlum(&[f.to_str().unwrap()]);
39
+ assert!(ok, "{out}");
40
+ assert!(out.contains("2 passed, 0 failed"), "{out}");
41
+ }
42
+
43
+ #[test]
44
+ fn directoryRunsSumOfOwnTests() {
45
+ let dir = scratch("dir");
46
+ std::fs::write(dir.join("a.plum"), TWO).unwrap();
47
+ std::fs::write(dir.join("b.plum"), "import std/Str\nimport std/Int\n\ntest \"only\"\n assert 3 == 3\n").unwrap();
48
+ let (out, ok) = runPlum(&[dir.to_str().unwrap()]);
49
+ assert!(ok, "{out}");
50
+ assert!(out.contains("3 passed, 0 failed"), "{out}");
51
+ }
52
+
53
+ #[test]
54
+ fn failingImportedTestDoesNotFailImporter() {
55
+ // Imports resolve relative to the repo root (the lib path), so place the files under it.
56
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
57
+ let dir = root.join("target").join(format!("scope_imp_{}", std::process::id()));
58
+ std::fs::create_dir_all(&dir).unwrap();
59
+ let rel = format!("target/scope_imp_{}/lib", std::process::id());
60
+ std::fs::write(dir.join("lib.plum"), "module lib\n\nimport std/Int\n\nfun one() -> Int =\n return 1\n\ntest \"bad\"\n assert 1 == 2\n").unwrap();
61
+ let f = dir.join("main.plum");
62
+ std::fs::write(&f, format!("import std/Int\nimport {rel}\n\ntest \"good\"\n assert one() == 1\n")).unwrap();
63
+ let (out, ok) = runPlum(&[f.to_str().unwrap()]);
64
+ assert!(ok, "{out}");
65
+ assert!(out.contains("1 passed, 0 failed"), "{out}");
66
+ }
plum-core/src/ast.rs CHANGED
@@ -57,8 +57,10 @@ impl Source {
57
57
  pub fn stampFile(&mut self, file: &str) {
58
58
  self.file = file.to_string();
59
59
  for item in &mut self.items {
60
- if let Item::Fn(f) = item {
60
+ match item {
61
- f.file = file.to_string();
61
+ Item::Fn(f) => f.file = file.to_string(),
62
+ Item::Test(t) => t.file = file.to_string(),
63
+ _ => {}
62
64
  }
63
65
  }
64
66
  }
@@ -225,6 +227,9 @@ pub struct Const {
225
227
  /// rest of the block) alongside the ordinary `assert` (fatal trap).
226
228
  #[derive(Debug, Clone, PartialEq)]
227
229
  pub struct Test {
230
+ /// Declaring file (stamped by the loader); `plum test` runs a test only
231
+ /// when this is the file it was asked to test.
232
+ pub file: String,
228
233
  pub name: String,
229
234
  pub body: Block,
230
235
  /// 1-based source position of the `test` keyword. 0 if unknown.
plum-core/src/parser.rs CHANGED
@@ -537,6 +537,7 @@ impl<'a> AstParser<'a> {
537
537
  .unwrap_or(Block { stmts: vec![] });
538
538
  let pos = node.start_position();
539
539
  Test {
540
+ file: String::new(),
540
541
  name,
541
542
  body,
542
543
  line: pos.row + 1,
plum-examples/map_struct_values.plum ADDED
@@ -0,0 +1,21 @@
1
+ # Map with struct values (moved out of plum-std/Map.plum so MapPoint
2
+ # does not become a global type for every importer of std/Map).
3
+ import std/Map
4
+ import std/Int
5
+ import std/Float
6
+ import std/Str
7
+
8
+ struct MapPoint(x: Float, y: Float)
9
+
10
+ test "Map[Int, MapPoint] struct values"
11
+ m := Map[Int, MapPoint]()
12
+ i := 0
13
+ while i < 500
14
+ m.set(i, MapPoint(x: Float(i), y: Float(i) * 2.0))
15
+ i = i + 1
16
+ v := m.get(499)!
17
+ assert v.x == 499.0
18
+ assert v.y == 998.0
19
+ m.set(499, MapPoint(x: 1.0, y: 2.0))
20
+ assert m.get(499)!.y == 2.0
21
+ assert m.length() == 500
plum-examples/struct_arrays.plum CHANGED
@@ -35,6 +35,11 @@ fun first(a: Array[T]) -> T =
35
35
  fun mk() -> !Array[Vec2] =
36
36
  Array[Vec2]().push(Vec2(x: 1.0, y: 2.0))
37
37
 
38
+ struct Px(r: Byte, g: Byte, b: Byte, a: Byte)
39
+
40
+ fun mkPx(v: Int) -> Px =
41
+ Px(r: Byte(v), g: Byte(v + 1), b: Byte(v + 2), a: Byte(255))
42
+
38
43
  test "List[Vec2] push/get/set round-trips across growth"
39
44
  l := List[Vec2]()
40
45
  for i := range 100
@@ -187,3 +192,22 @@ test "probe Str leaf"
187
192
  a := Array[Named]()
188
193
  a = a.push(Named(label: "hi", v: Vec2(x: 1.0, y: 2.0)))
189
194
  assert a.get(0).label == "hi"
195
+
196
+ test "Byte struct fields: copy, compare, store"
197
+ p := mkPx(10)
198
+ q := p
199
+ assert p == q
200
+ assert p.r == Byte(10)
201
+ assert p.a == Byte(255)
202
+ assert p != mkPx(11)
203
+ a := Array[Px]().push(p).push(mkPx(20))
204
+ assert a.get(1).g == Byte(21)
205
+ assert a.get(0) == q
206
+ l := List[Px]()
207
+ for i := range 50
208
+ l.push(mkPx(i))
209
+ assert l.get(37)!.b == Byte(39)
210
+ assert l.get(49)! == mkPx(49)
211
+
212
+ test "assert == on structs without toStr"
213
+ assert Vec2(x: 1.0, y: 2.0) == Vec2(x: 1.0, y: 2.0)
plum-std/Map.plum CHANGED
@@ -196,8 +196,6 @@ record Map[K, V](index: Array[Int], hashes: Array[Int], keys: Array[K], vals: Ar
196
196
  for k, v := range self
197
197
  yield v
198
198
 
199
- struct MapPoint(x: Float, y: Float)
200
-
201
199
  test "get/set/has round-trip and set replaces an existing key in place"
202
200
  m := Map[Str, Int]()
203
201
  assert m.has("a") == false
@@ -476,19 +474,6 @@ test "Map[Str, Int] with Buffer-built keys"
476
474
  assert m.get("k1999")! == 1999
477
475
  assert m.has("k2000") == false
478
476
 
479
- test "Map[Int, MapPoint] struct values"
480
- m := Map[Int, MapPoint]()
481
- i := 0
482
- while i < 500
483
- m.set(i, MapPoint(x: Float(i), y: Float(i) * 2.0))
484
- i = i + 1
485
- v := m.get(499)!
486
- assert v.x == 499.0
487
- assert v.y == 998.0
488
- m.set(499, MapPoint(x: 1.0, y: 2.0))
489
- assert m.get(499)!.y == 2.0
490
- assert m.length() == 500
491
-
492
477
  test "iteration is insertion order, skipping removed entries"
493
478
  m := Map[Int, Int]()
494
479
  m.set(30, 1)
plum-wasm-codegen/src/lib.rs CHANGED
@@ -1514,6 +1514,11 @@ fn strLit(s: String) -> ast::Expr {
1514
1514
  /// special-casing out here any more. A non-comparison condition (`assert
1515
1515
  /// isEven(4)`) has no meaningful actual/expected split — a boolean condition
1516
1516
  /// is always false by the time this fires — so it's just the bare text.
1517
+ /// First part of an assert report string: tells `compileInterpolatedString`
1518
+ /// that if any interpolated side has no renderable type (no `toStr`), only the
1519
+ /// plain condition-text line (the next part) is emitted.
1520
+ const ASSERT_REPORT_MARK: &str = "\u{1}assert-report";
1521
+
1517
1522
  fn assertFailureMessage(c: &ast::Check, lhs: &ast::Expr, rhs: &ast::Expr) -> ast::Expr {
1518
1523
  let isNullLit = |e: &ast::Expr| matches!(e, ast::Expr::ErrorValue(v) if v.name == ast::NULL_ERR_NAME);
1519
1524
  let plain = match &c.cond {
@@ -1525,6 +1530,7 @@ fn assertFailureMessage(c: &ast::Check, lhs: &ast::Expr, rhs: &ast::Expr) -> ast
1525
1530
  }
1526
1531
  ast::Expr::String(ast::StringExpr {
1527
1532
  parts: vec![
1533
+ ast::StringPart::Text(ASSERT_REPORT_MARK.to_string()),
1528
1534
  ast::StringPart::Text(format!("{}\n", c.text)),
1529
1535
  ast::StringPart::Text(" Expected: ".to_string()),
1530
1536
  ast::StringPart::Interp(rhs.clone()),
@@ -1541,6 +1547,8 @@ fn desugarTestsToFns(source: &ast::Source) -> (ast::Source, Vec<(String, String)
1541
1547
  let mut test_idx = 0usize;
1542
1548
  for item in &source.items {
1543
1549
  match item {
1550
+ // Tests declared in imported files are not run: only the entry file's own.
1551
+ ast::Item::Test(t) if !t.file.is_empty() && t.file != source.file => {}
1544
1552
  ast::Item::Test(t) => {
1545
1553
  // Leading "__" can never collide with a real Plum-declared name —
1546
1554
  // `fn_identifier`/`var_identifier` must start with a lowercase letter.
@@ -7278,7 +7286,26 @@ fn compileInterpolatedString(
7278
7286
  state: &mut ModuleState,
7279
7287
  ) -> Result<(), String> {
7280
7288
  let mut first = true;
7289
+ let mut parts: &[ast::StringPart] = &s.parts;
7290
+ if let Some(ast::StringPart::Text(m)) = parts.first() {
7291
+ if m == ASSERT_REPORT_MARK {
7292
+ parts = &parts[1..];
7293
+ let renderable = |e: &ast::Expr| match inferLocalType(e, ctx) {
7294
+ PlumType::TInt | PlumType::TBool | PlumType::TFloat => true,
7295
+ PlumType::TNamed(ref n) if n == ast::STR_TYPE => true,
7296
+ other => plum_checker::nominalName(&other)
7297
+ .is_some_and(|n| ctx.func_ids.contains_key(&format!("{}::toStr", n))),
7298
+ };
7299
+ let all = parts.iter().all(|p| match p {
7300
+ ast::StringPart::Interp(e) => renderable(e),
7301
+ _ => true,
7302
+ });
7303
+ if !all {
7304
+ parts = &parts[..1];
7305
+ }
7306
+ }
7307
+ }
7281
- for part in &s.parts {
7308
+ for part in parts {
7282
7309
  match part {
7283
7310
  ast::StringPart::Text(t) => {
7284
7311
  compileStaticString(t, body, state);
plum-wasm-codegen/tests/codegen_tests.rs CHANGED
@@ -174,6 +174,72 @@ test \"hard assert traps\"
174
174
  );
175
175
  }
176
176
 
177
+ /// A failing top-level `assert a == b` on values with no `toStr` (struct,
178
+ /// record, tag-less enum, optional) falls back to the plain condition-text
179
+ /// report instead of failing to compile; Int still shows Expected/Actual.
180
+ #[test]
181
+ fn assertReportFallsBackWithoutToStr() {
182
+ let src = "\
183
+ import std/Str
184
+ import std/Int
185
+ import std/Float
186
+
187
+ struct V2(x: Float, y: Float)
188
+ record Rec(a: Int)
189
+ enum Shape = Circle | Square\n\nfun maybe() -> Int? =\n 3
190
+
191
+ test \"fb pass\"
192
+ assert V2(x: 1.0, y: 2.0) == V2(x: 1.0, y: 2.0)
193
+
194
+ test \"fb struct\"
195
+ assert V2(x: 1.0, y: 2.0) == V2(x: 1.0, y: 3.0)
196
+
197
+ test \"fb record\"
198
+ r := Rec(a: 1)
199
+ assert r.a == 1
200
+ assert Rec(a: 1) == Rec(a: 2)
201
+
202
+ test \"fb enum\"
203
+ assert Circle == Square
204
+
205
+ test \"fb opt\"
206
+ o := maybe()
207
+ assert o == null
208
+
209
+ test \"fb int\"
210
+ assert 1 + 1 == 3
211
+ ";
212
+ let dir = std::env::temp_dir().join(format!("plum_assert_fallback_{}", std::process::id()));
213
+ std::fs::create_dir_all(&dir).unwrap();
214
+ let path = dir.join("main.plum");
215
+ std::fs::write(&path, src).unwrap();
216
+ let source = plum_core::loadAndMerge(&path, &repoRoot()).expect("load");
217
+ let (bytes, _names) = compileTestSource(&source).expect("test source should compile");
218
+ let engine = gcEngine();
219
+ let module = wasmtime::Module::new(&engine, &bytes).expect("module should be loadable");
220
+ let mut store = wasmtime::Store::new(&engine, ());
221
+ let mut linker = wasmtime::Linker::new(&engine);
222
+ linker.define_unknown_imports_as_default_values(&mut store, &module).unwrap();
223
+ let instance = linker.instantiate(&mut store, &module).expect("instantiate");
224
+ let mut run = |name: &str| {
225
+ let f = instance.get_func(&mut store, &format!("test#{name}")).expect("export");
226
+ let mut results = [wasmtime::Val::I32(0)];
227
+ f.call(&mut store, &[], &mut results).expect("no trap");
228
+ readStrResult(&mut store, &results[0])
229
+ };
230
+ assert_eq!(run("fb pass"), "");
231
+ let r = run("fb struct");
232
+ assert!(r.contains("V2(x: 1.0, y: 2.0) == V2(x: 1.0, y: 3.0)") && !r.contains("Expected"), "got: {r}");
233
+ let r = run("fb record");
234
+ assert!(r.contains("Rec(a: 1) == Rec(a: 2)") && !r.contains("Expected"), "got: {r}");
235
+ let r = run("fb enum");
236
+ assert!(r.contains("Circle == Square") && !r.contains("Expected"), "got: {r}");
237
+ let r = run("fb opt");
238
+ assert!(r.contains("o == null"), "got: {r}");
239
+ let r = run("fb int");
240
+ assert!(r.contains("Expected: 3") && r.contains("Actual: 2"), "got: {r}");
241
+ }
242
+
177
243
  /// Normal `compileSource` (used by `plum run`/`build`/`compile`) must ignore
178
244
  /// `test` blocks entirely — no `test#`-prefixed export should ever appear
179
245
  /// outside `plum test`.