plum
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 +2 -2
- plum-checker/src/lib.rs +2 -2
- plum-checker/tests/checker_tests.rs +1 -1
- plum-cli/tests/test_scope_test.rs +66 -0
- plum-core/src/ast.rs +7 -2
- plum-core/src/parser.rs +1 -0
- plum-examples/map_struct_values.plum +21 -0
- plum-examples/struct_arrays.plum +24 -0
- plum-std/Map.plum +0 -15
- plum-wasm-codegen/src/lib.rs +28 -1
- plum-wasm-codegen/tests/codegen_tests.rs +66 -0
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
|
-
|
|
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
|
|
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`.
|