plum

#treesitter#compiler#wasm

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

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


ca5fd6fPeter John 2026-09-05T21:28:50+05:30
feat(number): add Number enum, migrate Int/Float methods, fix Float interpolation
examples/basics.plum CHANGED
@@ -1,7 +1,6 @@
1
1
  module basics
2
2
  import std/Bool
3
- import std/Int
4
- import std/Float
3
+ import std/Number
5
4
  import std/Str
6
5
 
7
6
  MAX_RETRIES = 3
examples/closures.plum CHANGED
@@ -1,7 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
- import std/Int
4
- import std/Float
3
+ import std/Number
5
4
 
6
5
  fun each(cb: fn(Int) -> Int) -> Int =
7
6
  cb(5)
examples/control_flow.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
- import std/Int
3
+ import std/Number
4
4
 
5
5
  fun loopSum(limit: Int) -> Int =
6
6
  total := 0
examples/dop_visitor.plum CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  import std/Os
40
40
  import std/Bool
41
- import std/Int
41
+ import std/Number
42
42
  import std/Str
43
43
 
44
44
  enum Rating =
examples/functions.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
- import std/Int
3
+ import std/Number
4
4
 
5
5
  fun addInts(a: Int, b: Int) -> Int =
6
6
  a + b
examples/match.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  import std/Option
2
2
  import std/Bool
3
- import std/Int
3
+ import std/Number
4
4
  import std/Str
5
5
 
6
6
  enum Color =
examples/methods.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
- import std/Int
3
+ import std/Number
4
4
 
5
5
  type Cat =
6
6
  name: Str
examples/numbers.plum ADDED
@@ -0,0 +1,104 @@
1
+ import std/Bool
2
+ import std/Str
3
+ import std/Number
4
+
5
+ # Regression coverage for `plum-std/Number.plum` — the methods it now hosts
6
+ # for BOTH `Int` and `Float` (via `match self`), after `Int.plum`/`Float.plum`
7
+ # were folded into it and deleted. Every call below reaches its method
8
+ # through the primitive-to-wrapping-enum dispatch fallback (`Int`/`Float`
9
+ # have no method tables of their own any more).
10
+
11
+ fun toInt(n: Number) -> Int =
12
+ match n
13
+ Int(i) => i
14
+ Float(f) => Int(f)
15
+
16
+ test "abs works for both Int and Float, wrapping back into the same variant"
17
+ assert {-5}.abs().kind() == "Int"
18
+ assert {-2.5}.abs().kind() == "Float"
19
+
20
+ test "sign returns -1/0/1 (or the matching Float) for both Int and Float"
21
+ assert toInt({5}.sign()) == 1
22
+ assert toInt({-5}.sign()) == -1
23
+ assert toInt({0}.sign()) == 0
24
+
25
+ test "hash is identity for Int and truncating for Float"
26
+ assert {5}.hash() == 5
27
+ assert {5.9}.hash() == 5
28
+
29
+ test "trunc/floor/ceil/round agree with Int passthrough and real Float math"
30
+ assert {5}.trunc() == 5.0f
31
+ assert {2.7}.trunc() == 2.0f
32
+ assert {-2.7}.trunc() == -2.0f
33
+ assert {2.7}.floor() == 2.0f
34
+ assert {-2.7}.floor() == -3.0f
35
+ assert {2.3}.ceil() == 3.0f
36
+ assert {-2.3}.ceil() == -2.0f
37
+ assert {2.5}.round() == 3.0f
38
+ assert {-2.5}.round() == -3.0f
39
+
40
+ test "sqrt works for Int (via Float conversion) and Float directly"
41
+ assert {4}.sqrt() == 2.0f
42
+ assert {2.25}.sqrt() == 1.5f
43
+
44
+ test "pow computes integer powers exactly for both Int and Float self"
45
+ assert {2}.pow(10.0f) == 1024.0f
46
+ assert {2.0}.pow(10.0f) == 1024.0f
47
+
48
+ test "log2/log10 agree between Int and the equivalent Float"
49
+ # `log`/`log2`/`log10` are built on the Taylor-series `ln` approximation
50
+ # (see `plum-std/Number.plum`'s `ln`), not exact libm — compare within a
51
+ # small epsilon rather than requiring bit-exact equality.
52
+ a := {8}.log2()
53
+ b := {8.0}.log2()
54
+ assert {a - b}.abs().toFloatValue() < 0.0001f
55
+ c := {100}.log10()
56
+ d := {100.0}.log10()
57
+ assert {c - d}.abs().toFloatValue() < 0.0001f
58
+
59
+ test "isFinite/isInfinite/isNaN are always well-defined for Int, real for Float"
60
+ assert {5}.isFinite()
61
+ assert !{5}.isInfinite()
62
+ assert !{5}.isNaN()
63
+ assert {1.0f / 0.0f}.isInfinite()
64
+ assert {0.0f / 0.0f}.isNaN()
65
+ assert {1.5}.isFinite()
66
+
67
+ test "min/max compare across Int and Float without losing the original variant"
68
+ assert {3}.min(5).kind() == "Int"
69
+ assert {3}.max(2.5).kind() == "Int"
70
+ assert {2.5}.min(3).kind() == "Float"
71
+
72
+ test "toStr renders Int and Float correctly, including negatives and fractions"
73
+ assert {42}.toStr() == "42"
74
+ assert {-7}.toStr() == "-7"
75
+ assert {0}.toStr() == "0"
76
+ assert {3.5}.toStr() == "3.5"
77
+ assert {-3.5}.toStr() == "-3.5"
78
+
79
+ test "parseInt/parseFloat round-trip a rendered number"
80
+ assert parseInt("123").unwrapOr(-1) == 123
81
+ assert parseInt("-45").unwrapOr(1) == -45
82
+ assert parseFloat("3.25").unwrapOr(-1.0f) == 3.25f
83
+
84
+ test "trig/hyperbolic/exp/ln free functions and Number methods still work post-migration"
85
+ assert sin(0.0f) == 0.0f
86
+ assert cos(0.0f) == 1.0f
87
+ assert {0}.sinh() == 0.0f
88
+ assert {0}.cosh() == 1.0f
89
+
90
+ test "string interpolation of a Float value works, via Number.toStr"
91
+ x := 3.5f
92
+ assert "value is {x}" == "value is 3.5"
93
+ y := {8}.log2()
94
+ assert "computed is {y}" == "computed is 3.0"
95
+
96
+ test "assert of two directly-chained Float method calls renders a real failure message"
97
+ # Previously crashed the whole compile with "interpolating a Float value is
98
+ # not yet supported" instead of a normal pass/fail report — `looksLikeFloat`
99
+ # only caught an OBVIOUS float literal on either side of the comparison,
100
+ # missing any Float-typed expression that wasn't written as one (like these
101
+ # chained method calls). Fixed at the root: `"{expr}"` interpolation of a
102
+ # Float now genuinely works (via `Number.toStr`), so the "Expected/Actual"
103
+ # failure-message machinery no longer needs to special-case Float at all.
104
+ assert {8}.log2() == {8.0}.log2()
examples/oop_visitor.plum CHANGED
@@ -31,7 +31,7 @@
31
31
 
32
32
  import std/Os
33
33
  import std/Bool
34
- import std/Int
34
+ import std/Number
35
35
  import std/Str
36
36
 
37
37
  enum Rating =
examples/strings.plum CHANGED
@@ -1,5 +1,5 @@
1
1
  import std/Bool
2
- import std/Int
2
+ import std/Number
3
3
  import std/Str
4
4
 
5
5
  fun greet(name: Str) -> Str =
examples/testing.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
- import std/Int
3
+ import std/Number
4
4
 
5
5
  fun add(a: Int, b: Int) -> Int =
6
6
  a + b
examples/types.plum CHANGED
@@ -1,8 +1,7 @@
1
1
  import std/Option
2
2
  import std/Bool
3
- import std/Int
4
- import std/Float
5
3
  import std/Str
4
+ import std/Number
6
5
 
7
6
  type Point =
8
7
  x: Int
@@ -119,6 +118,9 @@ enum ShapeWithFields =
119
118
  | CircleField(radius: Int)
120
119
  | SquareField(side: Int)
121
120
 
121
+ fun numberKind(n: Number) -> Str =
122
+ n.kind()
123
+
122
124
  type OptionBox =
123
125
  value: Option[Int]
124
126
 
@@ -186,6 +188,16 @@ test "named-payload field access on a multi-variant enum value works via a check
186
188
  test "enum variant used directly as a type checks and runs correctly"
187
189
  assert stringifyColor(Red) == "Red"
188
190
 
191
+ test "a bare Int/Float value flows into a Number-typed param with no wrapper syntax"
192
+ assert numberKind(5) == "Int"
193
+ assert numberKind(2.5) == "Float"
194
+
195
+ test "a bare Int/Float value dispatches a method defined only on Number, via fallback"
196
+ # `.kind()` isn't defined on `Int`/`Float` themselves — dispatch falls back
197
+ # to `Number`, the enum that bare-wraps them, boxing `self` first.
198
+ assert {5}.kind() == "Int"
199
+ assert {2.5}.kind() == "Float"
200
+
189
201
  test "enum class field construct and destructure runs correctly"
190
202
  b := OptionBox(value: Some(42))
191
203
  assert b.unwrap(0) == 42
plum-checker/src/lib.rs CHANGED
@@ -88,6 +88,21 @@ pub fn unify(t1: &PlumType, t2: &PlumType, ctx: &CheckCtx) -> Result<(), String>
88
88
  {
89
89
  Ok(())
90
90
  }
91
+ // A "union" enum (`enum Number = | Int | Float`) whose bare variants wrap
92
+ // builtin primitives, not classes — see `buildGlobalTables`'s primitive
93
+ // bare-wrap condition. A raw `Int`/`Float` value is accepted directly
94
+ // wherever the enum is expected, no wrapper syntax needed (mirrors the
95
+ // class bare-wrap case, which needs no special `unify` arm at all
96
+ // because a class construction's OWN inferred type already comes back
97
+ // as the enum name via `bareVariantWrapType` — a primitive literal has
98
+ // no such construction call to intercept, so `unify` itself has to
99
+ // bridge the gap here instead).
100
+ (PlumType::TNamed(enum_name), prim @ (PlumType::TInt | PlumType::TFloat))
101
+ | (prim @ (PlumType::TInt | PlumType::TFloat), PlumType::TNamed(enum_name))
102
+ if ctx.enum_variants.values().any(|info| &info.enum_name == enum_name && info.field_types == [prim.clone()]) =>
103
+ {
104
+ Ok(())
105
+ }
91
106
  (PlumType::TFun(ps1, r1), PlumType::TFun(ps2, r2)) if ps1.len() == ps2.len() => {
92
107
  for (p1, p2) in ps1.iter().zip(ps2.iter()) {
93
108
  unify(p1, p2, ctx)?;
@@ -253,6 +268,30 @@ pub fn buildGlobalTables(source: &ast::Source) -> (TypeEnv, ClassEnv, MethodEnv,
253
268
  for (tag, v) in e.variants.iter().enumerate() {
254
269
  let (field_types, field_names) = if !e.params.is_empty() {
255
270
  (shared_field_types.clone(), e.params.iter().map(|p| p.name.clone()).collect())
271
+ } else if v.fields.is_empty() && matches!(v.name.as_str(), "Int" | "Float") {
272
+ // Bare-wrap sugar for a "union" enum over builtin
273
+ // PRIMITIVES (`enum Number = | Int | Float`, see
274
+ // `plum-std/Number.plum`) — the wrapped payload is the
275
+ // dedicated primitive `PlumType` itself (`TInt`/
276
+ // `TFloat`), not a `TNamed` self-reference. Checked
277
+ // BEFORE the class bare-wrap case below: historically
278
+ // `Int`/`Float` were ALSO registered as zero-field
279
+ // "method-holder" classes (`type Int = fun
280
+ // abs(self)...`, attaching methods to the primitive
281
+ // receiver, before their methods moved into `Number`
282
+ // itself) — this ordering is what made a real
283
+ // primitive win that name collision over the
284
+ // incidentally-same-named class, and stays a
285
+ // defensive safeguard against a future same-named
286
+ // class reintroducing it. This is what lets a bare
287
+ // `Int`/`Float` value be used directly wherever
288
+ // `Number` is expected with no wrapper syntax — see
289
+ // `unify`'s matching
290
+ // primitive-vs-enum arm, and
291
+ // `monomorphize::wrapPrimitiveAgainstExpected`, which
292
+ // does the actual AST rewrite into this variant's
293
+ // constructor at specific expected-type usage sites.
294
+ (vec![plumTypeFromName(&v.name)], Vec::new())
256
295
  } else if v.fields.is_empty() && classes.contains_key(&v.name) {
257
296
  // Bare-type variant sugar: `| FantasyBook` (no `[...]`)
258
297
  // naming a class declared elsewhere means "this variant
@@ -441,7 +480,13 @@ pub fn checkSource(source: &ast::Source) -> CheckResult<()> {
441
480
  // as its variant tag and self-wraps it (`field_types == [TNamed(name)]`).
442
481
  for name in classes.keys() {
443
482
  if let Some(info) = enum_variants.get(name) {
483
+ // Same exemption, but for a "union" enum's primitive bare-wrap
484
+ // variant (`enum Number = | Int | Float`) — `Int`/`Float` are
485
+ // ALSO registered as zero-field "method-holder" classes (see
486
+ // `buildGlobalTables`'s primitive-bare-wrap comment), so this
487
+ // exact collision is expected and deliberate for them too.
444
- let is_bare_variant_wrap = info.field_types == [PlumType::TNamed(name.clone())];
488
+ let is_bare_variant_wrap = info.field_types == [PlumType::TNamed(name.clone())]
489
+ || info.field_types == [plumTypeFromName(name)];
445
490
  if !is_bare_variant_wrap {
446
491
  errors.push(CheckError {
447
492
  message: format!("'{}' is declared as both a class and an enum variant", name),
@@ -1162,7 +1207,17 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1162
1207
  // `Int(x)`/`Float(x)`/`Byte(x)` are builtin numeric conversions, not
1163
1208
  // ordinary calls — handled here so `y = Float(x)` unifies against
1164
1209
  // `TFloat` rather than falling through to the permissive
1165
- // "unknown fn" case.
1210
+ // "unknown fn" case. EXCEPT when `call.name` is ALSO a
1211
+ // registered enum variant (a "union" enum's bare-primitive-wrap
1212
+ // variant, e.g. `enum Number = | Int | Float`) whose single
1213
+ // field type exactly matches the arg's own type — a same-type
1214
+ // call (`Int(intExpr)`) then means constructing that variant
1215
+ // (`monomorphize::wrapPrimitiveAgainstExpected` synthesizes
1216
+ // exactly this call shape), not a no-op cast; falls through to
1217
+ // the ordinary enum-variant-construction handling just below
1218
+ // instead. A genuinely cross-type call (`Int(floatExpr)`) is
1219
+ // never ambiguous this way, so every existing numeric-cast call
1220
+ // site keeps its normal meaning.
1166
1221
  if (call.name == "Int" || call.name == "Float" || call.name == "Byte") && call.args.len() == 1 {
1167
1222
  let arg_expr = match &call.args[0] {
1168
1223
  ast::Arg::Positional(e) => e,
@@ -1170,14 +1225,21 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1170
1225
  ast::Arg::Pair { value, .. } => value,
1171
1226
  };
1172
1227
  let actual = inferExpr(arg_expr, env, ctx)?;
1228
+ let is_same_type_variant_wrap = ctx.enum_variants.get(call.name.as_str())
1229
+ .is_some_and(|info| info.field_types == [actual.clone()]);
1230
+ if !is_same_type_variant_wrap {
1173
- return match (call.name.as_str(), &actual) {
1231
+ return match (call.name.as_str(), &actual) {
1174
- ("Float", PlumType::TInt) => Ok(PlumType::TFloat),
1232
+ ("Float", PlumType::TInt) => Ok(PlumType::TFloat),
1175
- ("Int", PlumType::TFloat) => Ok(PlumType::TInt),
1233
+ ("Int", PlumType::TFloat) => Ok(PlumType::TInt),
1176
- ("Float", PlumType::TFloat) | ("Int", PlumType::TInt) => Ok(actual),
1234
+ ("Float", PlumType::TFloat) | ("Int", PlumType::TInt) => Ok(actual),
1177
- ("Byte", PlumType::TInt) | ("Byte", PlumType::TByte) => Ok(PlumType::TByte),
1235
+ ("Byte", PlumType::TInt) | ("Byte", PlumType::TByte) => Ok(PlumType::TByte),
1178
- ("Int", PlumType::TByte) => Ok(PlumType::TInt),
1236
+ ("Int", PlumType::TByte) => Ok(PlumType::TInt),
1179
- (name, other) => Err(format!("call '{}': cannot convert {} to {}", name, other, name)),
1237
+ (name, other) => Err(format!("call '{}': cannot convert {} to {}", name, other, name)),
1180
- };
1238
+ };
1239
+ }
1240
+ // Else: fall through to the ordinary enum-variant-construction
1241
+ // handling just below, exactly as if this special case didn't
1242
+ // exist for this call.
1181
1243
  }
1182
1244
  if let Some(info) = ctx.enum_variants.get(&call.name) {
1183
1245
  if call.args.len() != info.field_types.len() {
@@ -1359,7 +1421,25 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1359
1421
  other => Err(format!("cannot access field '{}' on non-class type {}", field_name, other)),
1360
1422
  },
1361
1423
  ast::AttrKind::Method(call) => match methodReceiverName(&obj_ty) {
1424
+ Some(class_name) => {
1425
+ // Fallback: a primitive/class receiver's method might be
1426
+ // defined once on the "union" enum that bare-wraps it
1427
+ // instead (`enum Number = | Int | Float`) — `class_name`
1428
+ // (e.g. "Int") is itself a registered enum-variant name
1429
+ // in that case (see `buildGlobalTables`'s bare-wrap
1430
+ // conditions); retry there before falling through to the
1431
+ // permissive "unmodeled method" case below. Mirrors
1432
+ // `plum-wasm-codegen`'s equivalent dispatch fallback,
1433
+ // which also boxes `self` into the wrap-enum's variant.
1362
- Some(class_name) => match ctx.methods.get(&(class_name.clone(), call.name.clone())) {
1434
+ let class_name = if ctx.methods.contains_key(&(class_name.clone(), call.name.clone())) {
1435
+ class_name
1436
+ } else {
1437
+ ctx.enum_variants.get(class_name.as_str())
1438
+ .map(|info| info.enum_name.clone())
1439
+ .filter(|enum_name| ctx.methods.contains_key(&(enum_name.clone(), call.name.clone())))
1440
+ .unwrap_or(class_name)
1441
+ };
1442
+ match ctx.methods.get(&(class_name.clone(), call.name.clone())) {
1363
1443
  Some(PlumType::TFun(param_types, ret)) => {
1364
1444
  match param_types.last() {
1365
1445
  Some(PlumType::TVariadic(elem)) => {
@@ -1420,7 +1500,8 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1420
1500
  }
1421
1501
  // Unmodeled method (e.g. builtin/std): allow, codegen will catch.
1422
1502
  _ => Ok(PlumType::TVar("_".to_string())),
1423
- },
1503
+ }
1504
+ }
1424
1505
  None => Ok(PlumType::TVar("_".to_string())),
1425
1506
  },
1426
1507
  }
plum-checker/src/monomorphize.rs CHANGED
@@ -742,6 +742,7 @@ impl<'a> Monomorphizer<'a> {
742
742
  ast::FnBody::Expr(e) => {
743
743
  if let Some(expected) = &expected_ret {
744
744
  self.resolveBareVariantAgainstExpected(e, expected);
745
+ self.wrapPrimitiveAgainstExpected(e, expected, &env);
745
746
  }
746
747
  self.rewriteExpr(e, &env)?;
747
748
  Some(self.inferConcrete(e, &env))
@@ -750,6 +751,7 @@ impl<'a> Monomorphizer<'a> {
750
751
  if let Some(expected) = &expected_ret {
751
752
  if let Some(ast::Stmt::Expr(e)) = block.stmts.last_mut() {
752
753
  self.resolveBareVariantAgainstExpected(e, expected);
754
+ self.wrapPrimitiveAgainstExpected(e, expected, &env);
753
755
  }
754
756
  }
755
757
  self.rewriteBlock(block, &mut env)?;
@@ -928,6 +930,7 @@ impl<'a> Monomorphizer<'a> {
928
930
  .and_then(|fields| fields.iter().find(|(n, _)| n == field_name).map(|(_, t)| t.clone()))
929
931
  {
930
932
  self.resolveBareVariantAgainstExpected(value, &field_ty);
933
+ self.wrapPrimitiveAgainstExpected(value, &field_ty, env);
931
934
  }
932
935
  }
933
936
  }
@@ -952,6 +955,7 @@ impl<'a> Monomorphizer<'a> {
952
955
  let resolved = self.resolveFieldType(&rt);
953
956
  let expected = crate::plumTypeFromAst(&resolved);
954
957
  self.resolveBareVariantAgainstExpected(e, &expected);
958
+ self.wrapPrimitiveAgainstExpected(e, &expected, env);
955
959
  }
956
960
  self.rewriteExpr(e, env)?;
957
961
  }
@@ -1046,6 +1050,7 @@ impl<'a> Monomorphizer<'a> {
1046
1050
  let expected = crate::plumTypeFromAst(&resolved);
1047
1051
  if let Some(ast::Stmt::Expr(e)) = body.stmts.last_mut() {
1048
1052
  self.resolveBareVariantAgainstExpected(e, &expected);
1053
+ self.wrapPrimitiveAgainstExpected(e, &expected, &case_env);
1049
1054
  }
1050
1055
  }
1051
1056
  self.rewriteBlock(body, &mut case_env)?;
@@ -1148,6 +1153,7 @@ impl<'a> Monomorphizer<'a> {
1148
1153
  let field_ty = self.resolveFieldType(&field.ty);
1149
1154
  let expected = crate::plumTypeFromAst(&field_ty);
1150
1155
  self.resolveBareVariantAgainstExpected(&mut fa.value, &expected);
1156
+ self.wrapPrimitiveAgainstExpected(&mut fa.value, &expected, env);
1151
1157
  }
1152
1158
  }
1153
1159
 
@@ -1495,6 +1501,38 @@ impl<'a> Monomorphizer<'a> {
1495
1501
  }
1496
1502
  }
1497
1503
 
1504
+ /// If `expr`'s own inferred type is a builtin primitive (`Int`/`Float`)
1505
+ /// and `expected` names a "union" enum with a bare-primitive-wrap variant
1506
+ /// matching that exact primitive (`enum Number = | Int | Float` — see
1507
+ /// `buildGlobalTables`'s primitive bare-wrap condition), rewrites `expr`
1508
+ /// in place into that variant's constructor call (`5` -> `Int(5)`),
1509
+ /// reusing the ordinary positional-variant-construction codegen path
1510
+ /// (`compileVariantConstruction`) unchanged. This is the primitive
1511
+ /// analogue of the class bare-wrap sugar's `ClassCall`->`FnCall` rewrite
1512
+ /// just below — unlike that one (which fires unconditionally, keyed only
1513
+ /// on the constructed name), this MUST be expected-type-directed: `Int`/
1514
+ /// `Float` are used as bare primitives everywhere in the language and
1515
+ /// can't be globally reinterpreted as "the enum-wrapped value". No-op if
1516
+ /// `expected` isn't such an enum, or `expr` isn't already exactly that
1517
+ /// primitive type (a genuine mismatch is left for `unify`/`unifyArg` to
1518
+ /// catch elsewhere).
1519
+ fn wrapPrimitiveAgainstExpected(&mut self, expr: &mut ast::Expr, expected: &PlumType, env: &TypeEnv) {
1520
+ let PlumType::TNamed(enum_name) = expected else { return };
1521
+ let actual = self.infer(expr, env);
1522
+ if !matches!(actual, PlumType::TInt | PlumType::TFloat) {
1523
+ return;
1524
+ }
1525
+ let owner = self.enum_variants.iter()
1526
+ .find(|(_, info)| &info.enum_name == enum_name && info.field_types == [actual.clone()]);
1527
+ if let Some((variant_name, _)) = owner {
1528
+ let inner = expr.clone();
1529
+ *expr = ast::Expr::FnCall(ast::FnCall {
1530
+ name: variant_name.clone(),
1531
+ args: vec![ast::Arg::Positional(inner)],
1532
+ });
1533
+ }
1534
+ }
1535
+
1498
1536
  /// True if the specialization about to be processed is ITSELF already a
1499
1537
  /// nested instance of `template_name` — one of `bindings`'s VALUES is
1500
1538
  /// either `template_name` itself or a recorded specialization OF it
@@ -2108,7 +2146,25 @@ impl<'a> Monomorphizer<'a> {
2108
2146
  fn rewriteExpr(&mut self, expr: &mut ast::Expr, env: &TypeEnv) -> Result<(), String> {
2109
2147
  match expr {
2110
2148
  ast::Expr::ClassCall(call) => {
2149
+ // A bare `Int`/`Float` field value being constructed into a
2150
+ // "union" enum field (`Wrapper(value: 5)` where `value:
2151
+ // Number`) or a named-payload enum variant field (`Circle
2152
+ // (radius: 5)`, though there `radius: Int` already matches so
2153
+ // this is a no-op) — neither an ordinary class's nor a named
2154
+ // variant's OWN field types depend on generic specialization,
2155
+ // so this can run unconditionally here regardless of whether
2156
+ // `call.type_name` turns out to be generic or not below.
2157
+ for fa in &mut call.fields {
2158
+ let expected_ty = self.classes.get(call.type_name.as_str())
2159
+ .and_then(|fields| fields.iter().find(|(n, _)| n == &fa.name).map(|(_, t)| t.clone()))
2160
+ .or_else(|| self.enum_variants.get(call.type_name.as_str()).and_then(|info| {
2161
+ info.field_names.iter().position(|n| n == &fa.name).map(|i| info.field_types[i].clone())
2162
+ }));
2163
+ if let Some(expected) = expected_ty {
2164
+ self.wrapPrimitiveAgainstExpected(&mut fa.value, &expected, env);
2165
+ }
2166
+ }
2111
- // Runs FIRST (before the generic per-field rewrite below): a
2167
+ // Runs next (before the generic per-field rewrite below): a
2112
2168
  // bare payload-free variant field value (`Node(..., next:
2113
2169
  // None)`) carries no type of its own to infer a generic
2114
2170
  // param from, and needs the class's OWN (about-to-be-
@@ -2141,6 +2197,32 @@ impl<'a> Monomorphizer<'a> {
2141
2197
  }
2142
2198
  }
2143
2199
  ast::Expr::FnCall(call) => {
2200
+ // Coerce a bare `Int`/`Float` ARGUMENT into a "union" enum
2201
+ // param (`fun useNumber(n: Number)`) — the one call site
2202
+ // `wrapPrimitiveAgainstExpected` didn't already have (return
2203
+ // position and field construction are handled elsewhere).
2204
+ // Looked up from `env` by the call's CURRENT name, which may
2205
+ // still be an unspecialized generic template at this point —
2206
+ // harmless, since a param typed by one of the function's own
2207
+ // generic params is a bare `TVar`/`TNamed(<param>)`, never a
2208
+ // real enum, so `expected` only resolves to an actual
2209
+ // `Number`-like enum for a param that's genuinely concretely
2210
+ // typed that way (generic function or not). Positional zip
2211
+ // against `param_types` mirrors the same simplification
2212
+ // `resolveFnInstantiation` already makes elsewhere in this
2213
+ // file — a keyword arg out of declared order would zip
2214
+ // against the wrong param, but that's a pre-existing
2215
+ // limitation, not one this feature introduces.
2216
+ if let Some(PlumType::TFun(param_types, _)) = env.get(call.name.as_str()).map(|s| *s.body.clone()) {
2217
+ for (arg, expected) in call.args.iter_mut().zip(param_types.iter()) {
2218
+ let e = match arg {
2219
+ ast::Arg::Positional(e) => e,
2220
+ ast::Arg::Keyword { value, .. } => value,
2221
+ ast::Arg::Pair { value, .. } => value,
2222
+ };
2223
+ self.wrapPrimitiveAgainstExpected(e, expected, env);
2224
+ }
2225
+ }
2144
2226
  for arg in &mut call.args {
2145
2227
  let e = match arg {
2146
2228
  ast::Arg::Positional(e) => e,
@@ -2178,6 +2260,42 @@ impl<'a> Monomorphizer<'a> {
2178
2260
  // entirely as a result; every other arg shape still goes
2179
2261
  // through it exactly as before.
2180
2262
  self.rewriteMethodClosureArgs(&attr.object, call, env)?;
2263
+ // Same "union" enum argument coercion as the free-function
2264
+ // `FnCall` arm above, keyed by (receiver type, method name)
2265
+ // in `self.methods` instead of a bare function name. Also
2266
+ // follows the SAME dispatch fallback `plum-wasm-codegen`'s
2267
+ // `AttrKind::Method` codegen uses (a method not found
2268
+ // under the receiver's own name might be defined on the
2269
+ // enum that bare-wraps it, e.g. `Number.min(self, other:
2270
+ // Number)` dispatched from an `Int` receiver) — without
2271
+ // this, an OTHER argument like `{3}.min(5)`'s `5` would
2272
+ // never get boxed into `Number`, even though codegen
2273
+ // correctly boxes `self` for the very same call, causing
2274
+ // a wasm validation failure (raw `i64` where the callee
2275
+ // expects a boxed `Number` struct ref).
2276
+ let receiver_ty = self.infer(&attr.object, env);
2277
+ let direct_receiver_name = crate::methodReceiverName(&receiver_ty);
2278
+ let dispatch_receiver_name = direct_receiver_name.clone().and_then(|name| {
2279
+ if self.methods.contains_key(&(name.clone(), call.name.clone())) {
2280
+ Some(name)
2281
+ } else {
2282
+ self.enum_variants.get(name.as_str()).map(|info| info.enum_name.clone())
2283
+ }
2284
+ });
2285
+ if let Some(receiver_name) = dispatch_receiver_name {
2286
+ if let Some(PlumType::TFun(param_types, _)) = self.methods.get(&(receiver_name, call.name.clone())).cloned() {
2287
+ for (arg, expected) in call.args.iter_mut().zip(param_types.iter()) {
2288
+ let e = match arg {
2289
+ ast::Arg::Positional(e) => e,
2290
+ ast::Arg::Keyword { value, .. } => value,
2291
+ ast::Arg::Pair { value, .. } => value,
2292
+ };
2293
+ if !matches!(e, ast::Expr::Closure(_)) {
2294
+ self.wrapPrimitiveAgainstExpected(e, expected, env);
2295
+ }
2296
+ }
2297
+ }
2298
+ }
2181
2299
  for arg in &mut call.args {
2182
2300
  let e = match arg {
2183
2301
  ast::Arg::Positional(e) => e,
plum-cli/src/main.rs CHANGED
@@ -65,10 +65,12 @@ enum Command {
65
65
  #[arg(long, default_value = ".")]
66
66
  lib_path: std::path::PathBuf,
67
67
  },
68
+ /// Compile a Plum source file's (or every file under one or more
68
- /// Compile a Plum source file's `test` blocks and run them under wasmtime
69
+ /// directories') `test` blocks and run them under wasmtime
69
70
  Test {
70
- /// Source file to compile and test
71
+ /// One or more source files and/or directories (searched recursively for *.plum files)
72
+ #[arg(required = true)]
71
- file: std::path::PathBuf,
73
+ paths: Vec<std::path::PathBuf>,
72
74
  /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
73
75
  #[arg(long, default_value = ".")]
74
76
  lib_path: std::path::PathBuf,
@@ -97,7 +99,7 @@ fn run() -> Result<()> {
97
99
  Command::Compile { file, output, lib_path } => cmdCompile(file, output, lib_path),
98
100
  Command::Run { file, lib_path } => cmdRun(file, lib_path),
99
101
  Command::Build { file, output, lib_path } => cmdBuild(file, output, lib_path),
100
- Command::Test { file, lib_path } => cmdTest(file, lib_path),
102
+ Command::Test { paths, lib_path } => cmdTest(paths, lib_path),
101
103
  Command::Editor { editor, insiders } => editor::install(editor, insiders),
102
104
  }
103
105
  }
@@ -161,10 +163,14 @@ fn compileToTestWasm(file: &std::path::Path, lib_path: &std::path::Path) -> Resu
161
163
  .map_err(|e| anyhow::anyhow!("{e}"))?;
162
164
 
163
165
  if let Err(errors) = plum_checker::checkSource(&ast) {
166
+ // Returns an error (rather than exiting the process directly, as
167
+ // `compileToWasm`'s equivalent check still does for the single-file
168
+ // `compile`/`run`/`build` commands) so a recursive `plum test <dir>`
169
+ // sweep can catch it per-file, in `cmdTest`'s directory branch, and
170
+ // continue to the rest of the directory instead of the whole run
164
- for e in &errors {
171
+ // dying on the first file with a type error.
165
- eprintln!("type error: {}", e.message);
172
+ let messages: Vec<String> = errors.iter().map(|e| format!("type error: {}", e.message)).collect();
166
- }
167
- process::exit(1);
173
+ anyhow::bail!(messages.join("\n"));
168
174
  }
169
175
 
170
176
  plum_wasm_codegen::compileTestSource(&ast).map_err(|e| anyhow::anyhow!("codegen error: {e}"))
@@ -592,12 +598,91 @@ fn cmdRun(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()>
592
598
  Ok(())
593
599
  }
594
600
 
601
+ /// `path` is a file if it points directly at one, otherwise a directory
602
+ /// walked recursively for every `*.plum` file (sorted for deterministic
595
- /// Compiles a Plum file's `test` blocks and runs each one under wasmtime as
603
+ /// output) what lets `plum test` accept a directory (or several) and run
596
- /// its own call — a hard `assert` trap fails only that one call (wasmtime
597
- /// doesn't poison the rest of the store), so one test panicking never stops
604
+ /// every file under it, replacing `scripts/test-plum.sh`'s own
598
- /// the others from running.
605
+ /// find-and-loop shell logic.
606
+ fn collectPlumFiles(path: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
607
+ if path.is_file() {
608
+ return Ok(vec![path.to_path_buf()]);
609
+ }
610
+ let mut files = Vec::new();
611
+ let mut dirs = vec![path.to_path_buf()];
612
+ while let Some(dir) = dirs.pop() {
613
+ for entry in fs::read_dir(&dir)
614
+ .with_context(|| format!("failed to read directory {}", dir.display()))?
615
+ {
616
+ let entry = entry?;
617
+ let p = entry.path();
618
+ if p.is_dir() {
619
+ dirs.push(p);
620
+ } else if p.extension().is_some_and(|ext| ext == "plum") {
621
+ files.push(p);
622
+ }
623
+ }
624
+ }
625
+ files.sort();
626
+ Ok(files)
627
+ }
628
+
599
- fn cmdTest(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()> {
629
+ fn cmdTest(paths: Vec<std::path::PathBuf>, lib_path: std::path::PathBuf) -> Result<()> {
630
+ // A single plain file keeps the exact original single-file UX (no
631
+ // `=== path ===` header, no aggregate summary line, no "N/1 files
632
+ // passed" — just that one file's own test tree). Anything else (a
633
+ // directory, or more than one path) uses the multi-file sweep below,
634
+ // matching `scripts/test-plum.sh`'s old find-and-loop shell logic.
635
+ if let [only] = paths.as_slice() {
636
+ if only.is_file() {
637
+ let (_, failed) = runTestFile(only, &lib_path)?;
638
+ if failed > 0 {
639
+ process::exit(1);
640
+ }
641
+ return Ok(());
642
+ }
643
+ }
644
+
645
+ let mut files = Vec::new();
646
+ for path in &paths {
647
+ files.extend(collectPlumFiles(path)?);
648
+ }
649
+ let mut total_passed = 0usize;
650
+ let mut total_failed = 0usize;
651
+ let mut files_failed = 0usize;
652
+ for file in &files {
653
+ println!("=== {} ===", file.display());
654
+ match runTestFile(file, &lib_path) {
655
+ Ok((passed, failed)) => {
656
+ total_passed += passed;
657
+ total_failed += failed;
658
+ if failed > 0 {
659
+ files_failed += 1;
660
+ }
661
+ }
662
+ Err(e) => {
663
+ eprintln!("error: {e:#}");
664
+ files_failed += 1;
665
+ }
666
+ }
667
+ println!();
668
+ }
669
+ let color = io::stdout().is_terminal();
670
+ let green = |s: &str| if color { format!("\x1b[32m{s}\x1b[0m") } else { s.to_string() };
671
+ let red = |s: &str| if color { format!("\x1b[31m{s}\x1b[0m") } else { s.to_string() };
672
+ let summary = format!("{total_passed} passed, {total_failed} failed");
673
+ println!("{}", if total_failed > 0 { red(&summary) } else { green(&summary) });
674
+ println!("{}/{} files passed", files.len() - files_failed, files.len());
675
+ if files_failed > 0 {
676
+ process::exit(1);
677
+ }
678
+ Ok(())
679
+ }
680
+
681
+ /// Runs every `test` block in one file, printing the Spock-flavored per-test
682
+ /// tree as it goes, and returns `(passed, failed)` counts — shared by both
683
+ /// the single-file and directory-recursive paths of `cmdTest`.
684
+ fn runTestFile(file: &std::path::Path, lib_path: &std::path::Path) -> Result<(usize, usize)> {
600
- let (wasm_bytes, names) = compileToTestWasm(&file, &lib_path)?;
685
+ let (wasm_bytes, names) = compileToTestWasm(file, lib_path)?;
601
686
 
602
687
  let mut config = wasmtime::Config::new();
603
688
  config.wasm_gc(true);
@@ -614,7 +699,7 @@ fn cmdTest(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()>
614
699
 
615
700
  if names.is_empty() {
616
701
  println!("no tests found in {}", file.display());
617
- return Ok(());
702
+ return Ok((0, 0));
618
703
  }
619
704
 
620
705
  // A Spock-flavored tree: "├─"/"└─" per test (the last one gets the
@@ -671,10 +756,7 @@ fn cmdTest(file: std::path::PathBuf, lib_path: std::path::PathBuf) -> Result<()>
671
756
  println!();
672
757
  let summary = format!("{passed} passed, {failed} failed");
673
758
  println!("{}", if failed > 0 { red(&summary) } else { green(&summary) });
674
- if failed > 0 {
759
+ Ok((passed, failed))
675
- process::exit(1);
676
- }
677
- Ok(())
678
760
  }
679
761
 
680
762
  /// Reads a `Str` (wasm-gc `array<i8>`) RESULT value's bytes into a Rust
plum-core/src/loader.rs CHANGED
@@ -75,30 +75,45 @@ fn loadImport(
75
75
  /// nothing naturally stops a file from using an `Int`/`Float` literal, a
76
76
  /// bare string literal, or a matching type annotation without ever importing
77
77
  /// them. This walks `source` for any such use and requires the matching
78
- /// `import std/Int` / `import std/Float` / `import std/Str` except in
78
+ /// `import std/Int` / `import std/Float` (or `import std/Number`, which now
79
+ /// declares both — see `plum-std/Number.plum`) / `import std/Str` — except
79
- /// `Int.plum`/`Float.plum`/`Str.plum` themselves, which declare the type
80
+ /// in `Number.plum`/`Str.plum` themselves, which declare the type rather
80
- /// rather than import it.
81
+ /// than import it.
81
82
  fn checkBuiltinImports(path: &Path, source: &Source) -> Result<(), String> {
82
83
  let used = usedBuiltinTypeNames(source);
83
84
  for name in ["Int", "Float", "Str"] {
84
85
  if !used.contains(name) {
85
86
  continue;
86
87
  }
88
+ // A file can also "declare itself" by giving `Int`/`Float` as a bare,
89
+ // payload-free variant name of one of its OWN enums (a "union" enum's
90
+ // primitive bare-wrap sugar, e.g. `enum Number = | Int | Float` — see
91
+ // `plum-checker`'s `buildGlobalTables`) instead of the older `type
92
+ // Int = ...` class form — either way, the file is the one DECLARING
93
+ // the type, not merely using it.
87
- let declares_self = source
94
+ let declares_self = source.items.iter().any(|i| match i {
88
- .items
89
- .iter()
90
- .any(|i| matches!(i, Item::Class(c) if c.name == name));
95
+ Item::Class(c) => c.name == name,
96
+ Item::Enum(e) => e.variants.iter().any(|v| v.name == name && v.fields.is_empty()),
97
+ _ => false,
98
+ });
91
99
  if declares_self {
92
100
  continue;
93
101
  }
94
102
  let import_path = format!("std/{}", name);
103
+ // `Int`/`Float` moved from their own dedicated files into
104
+ // `std/Number` (`enum Number = | Int | Float` bare-wraps both) —
105
+ // importing that satisfies "declares `Int`/`Float`" just as well as
106
+ // the older per-type file would have.
107
+ let alt_import_path = matches!(name, "Int" | "Float").then_some("std/Number");
95
- let already_imported = source.imports.iter().any(|imp| imp.path == import_path);
108
+ let already_imported = source.imports.iter().any(|imp| {
109
+ imp.path == import_path || alt_import_path.is_some_and(|alt| imp.path == alt)
110
+ });
96
111
  if !already_imported {
97
112
  return Err(format!(
98
113
  "'{}' uses type '{}' but doesn't import it: add `import {}`",
99
114
  path.display(),
100
115
  name,
101
- import_path
116
+ alt_import_path.unwrap_or(import_path.as_str())
102
117
  ));
103
118
  }
104
119
  }
plum-std/Array.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  module std
2
2
  import std/Bool
3
- import std/Int
3
+ import std/Number
4
4
 
5
5
  # A fixed-length, O(1)-indexable array of `T`, backed directly by a wasm-gc
6
6
  # `array<anyref>` — every `Array[T]` specialization (`Array$Int`, `Array$Str`,
plum-std/Base64.plum CHANGED
@@ -3,7 +3,7 @@ import std/Str
3
3
  import std/Buffer
4
4
  import std/Option
5
5
  import std/Bool
6
- import std/Int
6
+ import std/Number
7
7
 
8
8
  # The Base64 package contains support for doing Base64 binary-to-text encodings.
9
9
  #
plum-std/Buffer.plum CHANGED
@@ -2,7 +2,7 @@ module std
2
2
  import std/ByteSlice
3
3
  import std/Str
4
4
  import std/Bool
5
- import std/Int
5
+ import std/Number
6
6
 
7
7
  # A Buffer is a growable, mutable sequence of bytes for efficiently building
8
8
  # up a `Str` piece by piece — modeled on Go's `bytes.Buffer`. Backed by a
plum-std/Byte.plum CHANGED
@@ -1,5 +1,5 @@
1
1
  module std
2
- import std/Int
2
+ import std/Number
3
3
  import std/Bool
4
4
  import std/Str
5
5
 
plum-std/ByteSlice.plum CHANGED
@@ -2,7 +2,7 @@ module std
2
2
  import std/Byte
3
3
  import std/Str
4
4
  import std/Bool
5
- import std/Int
5
+ import std/Number
6
6
 
7
7
  # ByteSlice is `[]Byte` — Plum's counterpart to Go's byte slice: a
8
8
  # fixed-length, mutable sequence of raw bytes backed directly by a wasm-gc
plum-std/Err.plum CHANGED
@@ -1,6 +1,6 @@
1
1
  module std
2
2
  import std/Bool
3
- import std/Int
3
+ import std/Number
4
4
  import std/Str
5
5
 
6
6
  # This is used to represent an error value across the language
plum-std/Float.plum DELETED
@@ -1,377 +0,0 @@
1
- module std
2
- import std/Int
3
- import std/Result
4
- import std/Str
5
- import std/Bool
6
-
7
- E = 2.718281828459045f # Euler's number, the base of natural logarithms, e, https://oeis.org/A001113
8
- LN10 = 2.302585092994046f # The natural logarithm of 10, https://oeis.org/A002392
9
- LN2 = 0.6931471805599453f # The natural logarithm of 2, https://oeis.org/A002162
10
- LOG10E = 0.4342944819032518f # The base 10 logarithm of e, formula: 1 / LN10
11
- LOG2E = 1.4426950408889634f # The base 2 logarithm of e, formula: 1 / LN2
12
- PI = 3.141592653589793f # The ratio of the circumference of a circle to its diameter, https://oeis.org/A000796
13
- PHI = 1.618033988749895f # https://oeis.org/A001622
14
- SQRT1_2 = 0.7071067811865476f # The square root of 1/2
15
- SQRT2 = 1.4142135623730951f # The square root of 2, https://oeis.org/A002193
16
- SQRT_E = 1.6487212707001282f # https://oeis.org/A019774
17
- SQRT_PI = 1.7724538509055159f # https://oeis.org/A002161
18
- SQRT_PHI = 1.272019649514069f # https://oeis.org/A139339
19
- EPSILON = 2.220446049250313e-16f # The difference between 1 and the smallest floating point number greater than 1, formula: 7/3 - 4/3 - 1
20
- MIN_FLOAT_VALUE = 4.9406564584124654417656879286822137236505980e-324 # Lowest value of float
21
- MAX_FLOAT_VALUE = 1.79769313486231570814527423731704356798070e+308 # Highest value of float
22
- HALF_PI = 1.5707963267948966f # PI / 2
23
- TAU = 6.283185307179586f # 2 * PI
24
-
25
- # Natural exponential, e^x, via range reduction (halve x until |x| <= 0.5,
26
- # run the Taylor series there where it converges fast, then square the
27
- # result back up the same number of halvings).
28
- fun exp(x: Float) -> Float =
29
- if x != x
30
- return x
31
- if x > 700.0f
32
- return 1.0f / 0.0f
33
- if x < -700.0f
34
- return 0.0f
35
- v := x
36
- k := 0
37
- while v > 0.5f || v < -0.5f
38
- v = v / 2.0f
39
- k = k + 1
40
- term := 1.0f
41
- sum := 1.0f
42
- n := 1
43
- while n < 25
44
- term = term * v / Float(n)
45
- sum = sum + term
46
- n = n + 1
47
- result := sum
48
- i := 0
49
- while i < k
50
- result = result * result
51
- i = i + 1
52
- return result
53
-
54
- # Natural logarithm, via range reduction to v in [1, 2) plus the
55
- # fast-converging series ln(v) = 2*atanh((v-1)/(v+1)).
56
- fun ln(x: Float) -> Float =
57
- if x != x || x < 0.0f
58
- return 0.0f / 0.0f
59
- if x == 0.0f
60
- return -1.0f / 0.0f
61
- if x > MAX_FLOAT_VALUE
62
- return x
63
- v := x
64
- k := 0
65
- while v >= 2.0f
66
- v = v / 2.0f
67
- k = k + 1
68
- while v < 1.0f
69
- v = v * 2.0f
70
- k = k - 1
71
- t := {v - 1.0f} / {v + 1.0f}
72
- t2 := t * t
73
- term := t
74
- sum := t
75
- n := 1
76
- while n < 30
77
- term = term * t2
78
- sum = sum + term / Float(2 * n + 1)
79
- n = n + 1
80
- return Float(k) * LN2 + 2.0f * sum
81
-
82
- # Square root via Newton's method, iterating to a fixed point.
83
- fun sqrt(x: Float) -> Float =
84
- if x < 0.0f
85
- return 0.0f / 0.0f
86
- if x == 0.0f || x != x
87
- return x
88
- guess := x
89
- prev := 0.0f
90
- i := 0
91
- while guess != prev && i < 100
92
- prev = guess
93
- guess = 0.5f * {guess + x / guess}
94
- i = i + 1
95
- return guess
96
-
97
- # Reduces `x` into roughly `[-PI, PI]` by subtracting the nearest multiple of
98
- # `TAU` — the range `sin`/`cos`'s Taylor series below actually converge
99
- # quickly over. For very large `|x|` (many multiples of `TAU`), floating-point
100
- # cancellation in `x - k*TAU` loses precision the same way any naive
101
- # range-reduction by subtraction does; a real libm uses extended-precision
102
- # constants to avoid this, which isn't attempted here.
103
- fun reduceToPi(x: Float) -> Float =
104
- k := Int(x / TAU + {x >= 0.0f ? 0.5f : -0.5f})
105
- return x - Float(k) * TAU
106
-
107
- fun sin(x: Float) -> Float =
108
- if x != x || x > MAX_FLOAT_VALUE || x < -MAX_FLOAT_VALUE
109
- return 0.0f / 0.0f
110
- r := reduceToPi(x)
111
- r2 := r * r
112
- term := r
113
- sum := r
114
- n := 1
115
- while n < 10
116
- term = term * {-r2} / Float({2 * n} * {2 * n + 1})
117
- sum = sum + term
118
- n = n + 1
119
- return sum
120
-
121
- fun cos(x: Float) -> Float =
122
- if x != x || x > MAX_FLOAT_VALUE || x < -MAX_FLOAT_VALUE
123
- return 0.0f / 0.0f
124
- return sin(x + HALF_PI)
125
-
126
- fun tan(x: Float) -> Float =
127
- return sin(x) / cos(x)
128
-
129
- # Arctangent, via repeated argument-halving (`atan(x) = 2*atan(x / (1 +
130
- # sqrt(1+x^2)))`) until `|x| <= 0.5` (where the Taylor series below converges
131
- # quickly), then doubling the result back up the same number of times.
132
- fun atan(x: Float) -> Float =
133
- if x != x
134
- return x
135
- neg := x < 0.0f
136
- v := neg ? -x : x
137
- k := 0
138
- while v > 0.5f && k < 8
139
- v = v / {1.0f + sqrt(1.0f + v * v)}
140
- k = k + 1
141
- v2 := v * v
142
- term := v
143
- sum := v
144
- n := 1
145
- while n < 20
146
- term = term * {-v2}
147
- sum = sum + term / Float(2 * n + 1)
148
- n = n + 1
149
- scale := Float(1 << k)
150
- result := sum * scale
151
- return neg ? -result : result
152
-
153
- fun asin(x: Float) -> Float =
154
- if x != x || x < -1.0f || x > 1.0f
155
- return 0.0f / 0.0f
156
- if x == 1.0f
157
- return HALF_PI
158
- if x == -1.0f
159
- return -HALF_PI
160
- return atan(x / sqrt(1.0f - x * x))
161
-
162
- fun acos(x: Float) -> Float =
163
- return HALF_PI - asin(x)
164
-
165
- # Angle (in radians) of the point `(x, y)` from the origin, in the correct
166
- # quadrant for any sign combination of `x`/`y` (unlike plain `atan(y/x)`,
167
- # which can't distinguish opposite quadrants).
168
- fun atan2(y: Float, x: Float) -> Float =
169
- if x > 0.0f
170
- return atan(y / x)
171
- if x < 0.0f && y >= 0.0f
172
- return atan(y / x) + PI
173
- if x < 0.0f && y < 0.0f
174
- return atan(y / x) - PI
175
- if x == 0.0f && y > 0.0f
176
- return HALF_PI
177
- if x == 0.0f && y < 0.0f
178
- return -HALF_PI
179
- return 0.0f
180
-
181
- fun hypot(a: Float, b: Float) -> Float =
182
- return sqrt(a * a + b * b)
183
-
184
- # Cube root via Newton's method (fixed iteration count, unlike `sqrt`'s
185
- # converge-to-a-fixed-point loop, since the cubic update step doesn't reach an
186
- # exact fixed point in float precision as reliably as the quadratic one does).
187
- fun cbrt(x: Float) -> Float =
188
- if x == 0.0f || x != x
189
- return x
190
- neg := x < 0.0f
191
- v := neg ? -x : x
192
- guess := v
193
- i := 0
194
- while i < 60
195
- guess = {2.0f * guess + v / {guess * guess}} / 3.0f
196
- i = i + 1
197
- return neg ? -guess : guess
198
-
199
- type Float =
200
- # Parses a decimal float, with an optional leading `+`/`-` and an optional
201
- # `.` fractional part (no exponent notation). Called as `Float.fromStr(...)`.
202
- fun fromStr(s: Str) -> Result[Float, Str] =
203
- len := s.length()
204
- if len == 0
205
- return Err("empty string")
206
- neg := s.byteAt(0) == 45
207
- start := neg || s.byteAt(0) == 43 ? 1 : 0
208
- if start >= len
209
- return Err("invalid float: '{s}'")
210
- int_part := 0.0f
211
- saw_digit := False
212
- i := start
213
- while i < len && s.byteAt(i) != 46
214
- b := s.byteAt(i)
215
- if b < 48 || b > 57
216
- return Err("invalid float: '{s}'")
217
- int_part = int_part * 10.0f + Float(b - 48)
218
- saw_digit = True
219
- i = i + 1
220
- frac_part := 0.0f
221
- frac_scale := 1.0f
222
- if i < len && s.byteAt(i) == 46
223
- i = i + 1
224
- while i < len
225
- b = s.byteAt(i)
226
- if b < 48 || b > 57
227
- return Err("invalid float: '{s}'")
228
- frac_scale = frac_scale / 10.0f
229
- frac_part = frac_part + Float(b - 48) * frac_scale
230
- saw_digit = True
231
- i = i + 1
232
- if !saw_digit
233
- return Err("invalid float: '{s}'")
234
- value := int_part + frac_part
235
- return Ok(neg ? -value : value)
236
-
237
- # Inverse hyperbolic cosine, via acosh(x) = ln(x + sqrt(x^2 - 1)), x >= 1.
238
- fun acosh(self) -> Float =
239
- if self < 1.0f
240
- return 0.0f / 0.0f
241
- return ln(self + sqrt(self * self - 1.0f))
242
-
243
- fun sinh(self) -> Float =
244
- return {exp(self) - exp(-self)} / 2.0f
245
-
246
- fun cosh(self) -> Float =
247
- return {exp(self) + exp(-self)} / 2.0f
248
-
249
- fun tanh(self) -> Float =
250
- return self.sinh() / self.cosh()
251
-
252
- # Inverse hyperbolic sine: asinh(x) = ln(x + sqrt(x^2 + 1)).
253
- fun asinh(self) -> Float =
254
- return ln(self + sqrt(self * self + 1.0f))
255
-
256
- # Inverse hyperbolic tangent: atanh(x) = 0.5*ln((1+x)/(1-x)), |x| < 1.
257
- fun atanh(self) -> Float =
258
- if self <= -1.0f || self >= 1.0f
259
- return 0.0f / 0.0f
260
- return 0.5f * ln({1.0f + self} / {1.0f - self})
261
-
262
- fun abs(self) -> Float =
263
- return self < 0.0f ? -self : self
264
-
265
- fun sign(self) -> Float =
266
- if self > 0.0f
267
- return 1.0f
268
- if self < 0.0f
269
- return -1.0f
270
- return self # preserves 0.0 / -0.0 / NaN
271
-
272
- # `Int(self)` truncates toward zero (see `plum-wasm-codegen`'s `Int(x)`
273
- # conversion) — exactly `trunc`'s definition.
274
- fun trunc(self) -> Float =
275
- return Float(Int(self))
276
-
277
- fun floor(self) -> Float =
278
- t := self.trunc()
279
- if self < 0.0f && t != self
280
- return t - 1.0f
281
- return t
282
-
283
- fun ceil(self) -> Float =
284
- t := self.trunc()
285
- if self > 0.0f && t != self
286
- return t + 1.0f
287
- return t
288
-
289
- # Round-half-away-from-zero (matching Go's `math.Round`), not
290
- # round-half-to-even.
291
- fun round(self) -> Float =
292
- neg := self < 0.0f
293
- v := neg ? -self : self
294
- r := {v + 0.5f}.floor()
295
- return neg ? -r : r
296
-
297
- fun min(self, other: Float) -> Float =
298
- return self < other ? self : other
299
-
300
- fun max(self, other: Float) -> Float =
301
- return self > other ? self : other
302
-
303
- # self^y. Mirrors `Int.pow`: a whole-number `y` (including negative) is
304
- # computed exactly by repeated squaring; a fractional `y` falls back to
305
- # self^y = exp(y * ln(self)), which requires self > 0.
306
- fun pow(self, y: Float) -> Float =
307
- if y == 0.0f
308
- return 1.0f
309
- yi := Int(y)
310
- if Float(yi) == y
311
- neg := yi < 0
312
- n := neg ? -yi : yi
313
- result := 1.0f
314
- b := self
315
- m := n
316
- while m > 0
317
- if m % 2 == 1
318
- result = result * b
319
- b = b * b
320
- m = m / 2
321
- return neg ? 1.0f / result : result
322
- if self < 0.0f
323
- return 0.0f / 0.0f
324
- return exp(y * ln(self))
325
-
326
- # Check whether this number is finite, ie not +/-infinity and not NaN.
327
- fun isFinite(self) -> Bool =
328
- !self.isNaN() && !self.isInfinite()
329
-
330
- # Check whether this number is +/-infinity
331
- fun isInfinite(self) -> Bool =
332
- self > MAX_FLOAT_VALUE || self < -MAX_FLOAT_VALUE
333
-
334
- # Check whether this number is NaN.
335
- fun isNaN(self) -> Bool =
336
- self != self
337
-
338
- # Rounded to 6 fractional digits, with trailing zeros trimmed (but always at
339
- # least one digit after the point, e.g. `3.0` not `3.`).
340
- fun toStr(self) -> Str =
341
- if self.isNaN()
342
- return "NaN"
343
- if self.isInfinite()
344
- return self < 0.0f ? "-Infinity" : "Infinity"
345
- neg := self < 0.0f
346
- abs_val := neg ? -self : self
347
- int_part := Int(abs_val)
348
- frac_val := abs_val - Float(int_part)
349
- scaled := Int(frac_val * 1000000.0f + 0.5f)
350
- carry := scaled >= 1000000
351
- whole_part := carry ? int_part + 1 : int_part
352
- frac := carry ? 0 : scaled
353
- s := whole_part.toStr() + "." + trimmedFracDigits(frac, 6)
354
- return neg ? "-" + s : s
355
-
356
- # Renders `n`'s digits most-significant-first, always emitting exactly as many
357
- # digits as `place_value` has powers of ten (leading zeros included) — used to
358
- # render a fractional part at a fixed width regardless of its own leading zeros.
359
- fun fixedFracDigits(n: Int, place_value: Int) -> Str =
360
- if place_value == 0
361
- return ""
362
- return digitChar(n / place_value % 10) + fixedFracDigits(n, place_value / 10)
363
-
364
- fun pow10(n: Int) -> Int =
365
- if n <= 0
366
- return 1
367
- return 10 * pow10(n - 1)
368
-
369
- # `fixedFracDigits(n, pow10(digits - 1))`, minus however many trailing-zero
370
- # digits `n` has — but always at least one digit ("0" if `n` is 0 outright).
371
- fun trimmedFracDigits(n: Int, digits: Int) -> Str =
372
- if n == 0 || digits <= 1
373
- return digitChar(n % 10)
374
- if n % 10 == 0
375
- return trimmedFracDigits(n / 10, digits - 1)
376
- return fixedFracDigits(n, pow10(digits - 1))
377
-
plum-std/Http.plum CHANGED
@@ -8,7 +8,7 @@ import std/Option
8
8
  import std/Result
9
9
  import std/Str
10
10
  import std/Bool
11
- import std/Int
11
+ import std/Number
12
12
 
13
13
  # An HTTP response: status code, response headers, and the raw response
14
14
  # body as a `Str` (a byte array, so binary bodies round-trip intact).
@@ -86,7 +86,7 @@ fun request(method: Str, url: Str, headers: Map[Str, Str], body: Str) -> Result[
86
86
  flag := parts.get(0).unwrapOr("")
87
87
  if flag != "1"
88
88
  return Err(parts.get(2).unwrapOr("http request failed"))
89
- match Int.fromStr(parts.get(1).unwrapOr(""))
89
+ match parseInt(parts.get(1).unwrapOr(""))
90
90
  Ok(status) =>
91
91
  return Ok(Response(status: status, headers: decodeHeaders(parts.get(2).unwrapOr("")), body: resp_body))
92
92
  Err(e) =>
plum-std/Int.plum DELETED
@@ -1,147 +0,0 @@
1
- module std
2
- import std/Float
3
- import std/Result
4
- import std/Str
5
- import std/Bool
6
-
7
- MIN_VALUE = -0x8000_0000_0000_0000 # Lowest value of Int
8
- MAX_VALUE = 0x7FFF_FFFF_FFFF_FFFF # Highest value of Int
9
- LARGE = 1 << 28 # 2**28
10
-
11
- type Int =
12
- # returns the absolute value of the Int
13
- fun abs(self) -> Int =
14
- self < 0 ? -self : self
15
-
16
- # An `Int` already IS its own well-distributed bit pattern — used by
17
- # `Map[K: Hashable, V]` (see `map.plum`) to bucket `Int` keys.
18
- fun hash(self) -> Int =
19
- self
20
-
21
- # An Int is always already whole, so ceil/floor/round/trunc are all just
22
- # the value itself widened to Float.
23
- fun ceil(self) -> Float =
24
- Float(self)
25
-
26
- fun floor(self) -> Float =
27
- Float(self)
28
-
29
- fun round(self) -> Float =
30
- Float(self)
31
-
32
- fun trunc(self) -> Float =
33
- Float(self)
34
-
35
- fun log(self) -> Float =
36
- ln(Float(self))
37
-
38
- fun log2(self) -> Float =
39
- ln(Float(self)) * LOG2E
40
-
41
- fun log10(self) -> Float =
42
- ln(Float(self)) * LOG10E
43
-
44
- # floor(log2(|self|)), computed exactly with integer division rather than
45
- # through Float rounding error.
46
- fun logb(self) -> Float =
47
- if self == 0
48
- return -1.0f / 0.0f
49
- n := self < 0 ? -self : self
50
- k := 0
51
- while n >= 2
52
- n = n / 2
53
- k = k + 1
54
- return Float(k)
55
-
56
- # self^y. Integer exponents (the common case, including negative self) are
57
- # computed exactly by repeated squaring; fractional exponents fall back to
58
- # self^y = exp(y * ln(self)), which requires self > 0.
59
- fun pow(self, y: Float) -> Float =
60
- base := Float(self)
61
- if y == 0.0f
62
- return 1.0f
63
- yi := Int(y)
64
- if Float(yi) == y
65
- neg := yi < 0
66
- n := neg ? -yi : yi
67
- result := 1.0f
68
- b := base
69
- m := n
70
- while m > 0
71
- if m % 2 == 1
72
- result = result * b
73
- b = b * b
74
- m = m / 2
75
- return neg ? 1.0f / result : result
76
- if base < 0.0f
77
- return 0.0f / 0.0f
78
- return exp(y * ln(base))
79
-
80
- fun sqrt(self) -> Float =
81
- sqrt(Float(self))
82
-
83
- # Parses a decimal integer, with an optional leading `+`/`-`. Called as
84
- # `Int.fromStr("42")` — a "static" method with no `self`, like `Bool.parse`.
85
- fun fromStr(s: Str) -> Result[Int, Str] =
86
- len := s.length()
87
- if len == 0
88
- return Err("empty string")
89
- neg := s.byteAt(0) == 45
90
- start := neg || s.byteAt(0) == 43 ? 1 : 0
91
- if start >= len
92
- return Err("invalid integer: '{s}'")
93
- value := 0
94
- i := start
95
- while i < len
96
- b := s.byteAt(i)
97
- if b < 48 || b > 57
98
- return Err("invalid integer: '{s}'")
99
- value = value * 10 + b - 48
100
- i = i + 1
101
- return Ok(neg ? -value : value)
102
-
103
- fun toStr(self) -> Str =
104
- if self == 0
105
- return "0"
106
- neg := self < 0
107
- n := neg ? -self : self
108
- s := digitsToStr(n)
109
- return neg ? "-" + s : s
110
-
111
- # Builds the decimal digits of a positive Int, most significant first, by
112
- # peeling off one digit at a time from the least significant end (so the
113
- # recursion emits digits in reverse call order — the leading, smallest-place
114
- # digit is appended last).
115
- fun digitsToStr(n: Int) -> Str =
116
- if n == 0
117
- return ""
118
- return digitsToStr(n / 10) + digitChar(n % 10)
119
-
120
- fun digitChar(d: Int) -> Str =
121
- match d
122
- 0 => "0"
123
- 1 => "1"
124
- 2 => "2"
125
- 3 => "3"
126
- 4 => "4"
127
- 5 => "5"
128
- 6 => "6"
129
- 7 => "7"
130
- 8 => "8"
131
- 9 => "9"
132
- _ => "0"
133
-
134
- # A raw, host-provided pseudo-random 64-bit Int (xorshift64*, seeded from the
135
- # wall clock — not cryptographically secure). Every other random-number
136
- # function in std is built on top of this one host extern.
137
- extern fun rawRandomInt() -> Int
138
-
139
- # A random Float uniformly distributed in [0.0, 1.0).
140
- fun random() -> Float =
141
- return Float(rawRandomInt() & MAX_VALUE) / Float(MAX_VALUE)
142
-
143
- # A random Int uniformly distributed in [0, n). Returns 0 for n <= 0.
144
- fun randomInt(n: Int) -> Int =
145
- if n <= 0
146
- return 0
147
- return { rawRandomInt() & MAX_VALUE } % n
plum-std/Json.plum CHANGED
@@ -4,8 +4,7 @@ import std/Option
4
4
  import std/Result
5
5
  import std/List
6
6
  import std/Map
7
- import std/Int
8
- import std/Float
7
+ import std/Number
9
8
  import std/Str
10
9
  import std/Buffer
11
10
  import std/Err
@@ -188,14 +187,14 @@ type JsonParser =
188
187
  fun parseNumberText(self, text: Str) -> Result[Json, JsonParseError] =
189
188
  e_index := exponentIndex(text)
190
189
  if e_index < 0
191
- r := Float.fromStr(text)
190
+ r := parseFloat(text)
192
191
  match r
193
192
  Ok(f) =>
194
193
  return Ok(JsonFloat(f))
195
194
  Err(m) =>
196
195
  return Err(self.fail(m))
197
- mantissa := Float.fromStr(text.sub(0, e_index))
196
+ mantissa := parseFloat(text.sub(0, e_index))
198
- exponent := Int.fromStr(text.sub(e_index + 1, text.length()))
197
+ exponent := parseInt(text.sub(e_index + 1, text.length()))
199
198
  match mantissa
200
199
  Err(m) =>
201
200
  return Err(self.fail(m))
plum-std/List.plum CHANGED
@@ -2,7 +2,7 @@ module std
2
2
 
3
3
  import std/Option
4
4
  import std/Buffer
5
- import std/Int
5
+ import std/Number
6
6
  import std/Bool
7
7
  import std/Str
8
8
 
plum-std/Map.plum CHANGED
@@ -3,7 +3,7 @@ import std/List
3
3
  import std/Option
4
4
  import std/Array
5
5
  import std/Bool
6
- import std/Int
6
+ import std/Number
7
7
  import std/Str
8
8
 
9
9
  # Any type usable as a `Map` key needs a `hash`, so keys landing in different
plum-std/Number.plum ADDED
@@ -0,0 +1,548 @@
1
+ module std
2
+ import std/Str
3
+ import std/Bool
4
+ import std/Result
5
+
6
+ MIN_VALUE = -0x8000_0000_0000_0000 # Lowest value of Int
7
+ MAX_VALUE = 0x7FFF_FFFF_FFFF_FFFF # Highest value of Int
8
+ LARGE = 1 << 28 # 2**28
9
+
10
+ E = 2.718281828459045f # Euler's number, the base of natural logarithms, e, https://oeis.org/A001113
11
+ LN10 = 2.302585092994046f # The natural logarithm of 10, https://oeis.org/A002392
12
+ LN2 = 0.6931471805599453f # The natural logarithm of 2, https://oeis.org/A002162
13
+ LOG10E = 0.4342944819032518f # The base 10 logarithm of e, formula: 1 / LN10
14
+ LOG2E = 1.4426950408889634f # The base 2 logarithm of e, formula: 1 / LN2
15
+ PI = 3.141592653589793f # The ratio of the circumference of a circle to its diameter, https://oeis.org/A000796
16
+ PHI = 1.618033988749895f # https://oeis.org/A001622
17
+ SQRT1_2 = 0.7071067811865476f # The square root of 1/2
18
+ SQRT2 = 1.4142135623730951f # The square root of 2, https://oeis.org/A002193
19
+ SQRT_E = 1.6487212707001282f # https://oeis.org/A019774
20
+ SQRT_PI = 1.7724538509055159f # https://oeis.org/A002161
21
+ SQRT_PHI = 1.272019649514069f # https://oeis.org/A139339
22
+ EPSILON = 2.220446049250313e-16f # The difference between 1 and the smallest floating point number greater than 1, formula: 7/3 - 4/3 - 1
23
+ MIN_FLOAT_VALUE = 4.9406564584124654417656879286822137236505980e-324 # Lowest value of float
24
+ MAX_FLOAT_VALUE = 1.79769313486231570814527423731704356798070e+308 # Highest value of float
25
+ HALF_PI = 1.5707963267948966f # PI / 2
26
+ TAU = 6.283185307179586f # 2 * PI
27
+
28
+ # `Int`/`Float` bare-wrap into `Number` with no wrapper syntax at all (see
29
+ # `plum-checker`'s `unify`/`monomorphize::wrapPrimitiveAgainstExpected`) —
30
+ # assigning/returning/passing a bare `Int`/`Float` value wherever `Number` is
31
+ # expected just works. A method not found on `Int`/`Float` directly (they no
32
+ # longer have their own method tables at all) falls back to `Number`'s,
33
+ # boxing `self` first (see `plum-checker`/`plum-wasm-codegen`'s matching
34
+ # `AttrKind::Method` dispatch fallback) — so `x.abs()` for a bare `Int`/
35
+ # `Float` `x` dispatches here exactly as if `Int`/`Float` still had their own
36
+ # `abs` method.
37
+ enum Number =
38
+ | Int
39
+ | Float
40
+
41
+ fun kind(self) -> Str =
42
+ match self
43
+ Int(_) => "Int"
44
+ Float(_) => "Float"
45
+
46
+ fun toFloatValue(self) -> Float =
47
+ match self
48
+ Int(i) => Float(i)
49
+ Float(f) => f
50
+
51
+ fun abs(self) -> Number =
52
+ match self
53
+ Int(i) => Int(i < 0 ? -i : i)
54
+ Float(f) => Float(f < 0.0f ? -f : f)
55
+
56
+ # Preserves 0.0 / -0.0 / NaN for the `Float` case, matching Go's
57
+ # `math.Signbit`-adjacent `Copysign`/`sign` conventions loosely.
58
+ fun sign(self) -> Number =
59
+ match self
60
+ Int(i) => Int(i > 0 ? 1 : i < 0 ? -1 : 0)
61
+ Float(f) =>
62
+ if f > 0.0f
63
+ return Float(1.0f)
64
+ if f < 0.0f
65
+ return Float(-1.0f)
66
+ return Float(f)
67
+
68
+ # An `Int` already IS its own well-distributed bit pattern — used by
69
+ # `Map[K: Hashable, V]` to bucket `Int` keys. A `Float` hashes via a
70
+ # truncating conversion (no bit-level float hashing attempted).
71
+ fun hash(self) -> Int =
72
+ match self
73
+ Int(i) => i
74
+ Float(f) => Int(f)
75
+
76
+ # An Int is always already whole, so trunc/floor/ceil/round are all just
77
+ # the value itself widened to Float.
78
+ fun trunc(self) -> Float =
79
+ match self
80
+ Int(i) => Float(i)
81
+ # `Int(f)` truncates toward zero (see `plum-wasm-codegen`'s `Int(x)`
82
+ # cross-type conversion) — exactly `trunc`'s definition.
83
+ Float(f) => Float(Int(f))
84
+
85
+ fun floor(self) -> Float =
86
+ match self
87
+ Int(i) => Float(i)
88
+ Float(f) =>
89
+ t := Float(Int(f))
90
+ if f < 0.0f && t != f
91
+ return t - 1.0f
92
+ return t
93
+
94
+ fun ceil(self) -> Float =
95
+ match self
96
+ Int(i) => Float(i)
97
+ Float(f) =>
98
+ t := Float(Int(f))
99
+ if f > 0.0f && t != f
100
+ return t + 1.0f
101
+ return t
102
+
103
+ # Round-half-away-from-zero (matching Go's `math.Round`), not
104
+ # round-half-to-even.
105
+ fun round(self) -> Float =
106
+ match self
107
+ Int(i) => Float(i)
108
+ Float(f) =>
109
+ neg := f < 0.0f
110
+ v := neg ? -f : f
111
+ r := {v + 0.5f}.floor()
112
+ return neg ? -r : r
113
+
114
+ fun log(self) -> Float =
115
+ ln(self.toFloatValue())
116
+
117
+ fun log2(self) -> Float =
118
+ ln(self.toFloatValue()) * LOG2E
119
+
120
+ fun log10(self) -> Float =
121
+ ln(self.toFloatValue()) * LOG10E
122
+
123
+ # floor(log2(|self|)). An `Int` self computes this exactly with integer
124
+ # division rather than through Float rounding error; a `Float` self falls
125
+ # back to the real logarithm.
126
+ fun logb(self) -> Float =
127
+ match self
128
+ Int(i) =>
129
+ if i == 0
130
+ return -1.0f / 0.0f
131
+ n := i < 0 ? -i : i
132
+ k := 0
133
+ while n >= 2
134
+ n = n / 2
135
+ k = k + 1
136
+ return Float(k)
137
+ Float(f) =>
138
+ if f == 0.0f
139
+ return -1.0f / 0.0f
140
+ return {ln(f < 0.0f ? -f : f) * LOG2E}.floor()
141
+
142
+ fun sqrt(self) -> Float =
143
+ sqrt(self.toFloatValue())
144
+
145
+ # self^y. Mirrors the original `Int.pow`/`Float.pow`: a whole-number `y`
146
+ # (including negative) is computed exactly by repeated squaring; a
147
+ # fractional `y` falls back to self^y = exp(y * ln(self)), which requires
148
+ # self > 0.
149
+ fun pow(self, y: Float) -> Float =
150
+ powFloat(self.toFloatValue(), y)
151
+
152
+ # Check whether this number is finite, ie not +/-infinity and not NaN.
153
+ fun isFinite(self) -> Bool =
154
+ !self.isNaN() && !self.isInfinite()
155
+
156
+ fun isInfinite(self) -> Bool =
157
+ match self
158
+ Int(_) => False
159
+ Float(f) => f > MAX_FLOAT_VALUE || f < -MAX_FLOAT_VALUE
160
+
161
+ fun isNaN(self) -> Bool =
162
+ match self
163
+ Int(_) => False
164
+ Float(f) => f != f
165
+
166
+ fun min(self, other: Number) -> Number =
167
+ self.toFloatValue() < other.toFloatValue() ? self : other
168
+
169
+ fun max(self, other: Number) -> Number =
170
+ self.toFloatValue() > other.toFloatValue() ? self : other
171
+
172
+ # Inverse hyperbolic cosine, via acosh(x) = ln(x + sqrt(x^2 - 1)), x >= 1.
173
+ fun acosh(self) -> Float =
174
+ v := self.toFloatValue()
175
+ if v < 1.0f
176
+ return 0.0f / 0.0f
177
+ return ln(v + sqrt(v * v - 1.0f))
178
+
179
+ fun sinh(self) -> Float =
180
+ v := self.toFloatValue()
181
+ return {exp(v) - exp(-v)} / 2.0f
182
+
183
+ fun cosh(self) -> Float =
184
+ v := self.toFloatValue()
185
+ return {exp(v) + exp(-v)} / 2.0f
186
+
187
+ fun tanh(self) -> Float =
188
+ return self.sinh() / self.cosh()
189
+
190
+ # Inverse hyperbolic sine: asinh(x) = ln(x + sqrt(x^2 + 1)).
191
+ fun asinh(self) -> Float =
192
+ v := self.toFloatValue()
193
+ return ln(v + sqrt(v * v + 1.0f))
194
+
195
+ # Inverse hyperbolic tangent: atanh(x) = 0.5*ln((1+x)/(1-x)), |x| < 1.
196
+ fun atanh(self) -> Float =
197
+ v := self.toFloatValue()
198
+ if v <= -1.0f || v >= 1.0f
199
+ return 0.0f / 0.0f
200
+ return 0.5f * ln({1.0f + v} / {1.0f - v})
201
+
202
+ # Rounded to 6 fractional digits, with trailing zeros trimmed (but always
203
+ # at least one digit after the point, e.g. `3.0` not `3.`), for a `Float`;
204
+ # plain decimal digits for an `Int`.
205
+ fun toStr(self) -> Str =
206
+ match self
207
+ Int(i) => intToStr(i)
208
+ Float(f) =>
209
+ if f != f
210
+ return "NaN"
211
+ if f > MAX_FLOAT_VALUE || f < -MAX_FLOAT_VALUE
212
+ return f < 0.0f ? "-Infinity" : "Infinity"
213
+ neg := f < 0.0f
214
+ abs_val := neg ? -f : f
215
+ int_part := Int(abs_val)
216
+ frac_val := abs_val - Float(int_part)
217
+ scaled := Int(frac_val * 1000000.0f + 0.5f)
218
+ carry := scaled >= 1000000
219
+ whole_part := carry ? int_part + 1 : int_part
220
+ frac := carry ? 0 : scaled
221
+ s := intToStr(whole_part) + "." + trimmedFracDigits(frac, 6)
222
+ return neg ? "-" + s : s
223
+
224
+ # ---- free-function helpers (no `self` to dispatch on) ----
225
+
226
+ # Plain decimal rendering of an `Int` — factored out of `Number.toStr` so the
227
+ # `Float` branch can reuse it (via `intToStr(whole_part)`) without
228
+ # re-dispatching back through `Number.toStr` itself.
229
+ fun intToStr(n: Int) -> Str =
230
+ if n == 0
231
+ return "0"
232
+ neg := n < 0
233
+ v := neg ? -n : n
234
+ s := digitsToStr(v)
235
+ return neg ? "-" + s : s
236
+
237
+ # Builds the decimal digits of a positive Int, most significant first, by
238
+ # peeling off one digit at a time from the least significant end (so the
239
+ # recursion emits digits in reverse call order — the leading, smallest-place
240
+ # digit is appended last).
241
+ fun digitsToStr(n: Int) -> Str =
242
+ if n == 0
243
+ return ""
244
+ return digitsToStr(n / 10) + digitChar(n % 10)
245
+
246
+ fun digitChar(d: Int) -> Str =
247
+ match d
248
+ 0 => "0"
249
+ 1 => "1"
250
+ 2 => "2"
251
+ 3 => "3"
252
+ 4 => "4"
253
+ 5 => "5"
254
+ 6 => "6"
255
+ 7 => "7"
256
+ 8 => "8"
257
+ 9 => "9"
258
+ _ => "0"
259
+
260
+ # Renders `n`'s digits most-significant-first, always emitting exactly as many
261
+ # digits as `place_value` has powers of ten (leading zeros included) — used to
262
+ # render a fractional part at a fixed width regardless of its own leading zeros.
263
+ fun fixedFracDigits(n: Int, place_value: Int) -> Str =
264
+ if place_value == 0
265
+ return ""
266
+ return digitChar(n / place_value % 10) + fixedFracDigits(n, place_value / 10)
267
+
268
+ fun pow10(n: Int) -> Int =
269
+ if n <= 0
270
+ return 1
271
+ return 10 * pow10(n - 1)
272
+
273
+ # `fixedFracDigits(n, pow10(digits - 1))`, minus however many trailing-zero
274
+ # digits `n` has — but always at least one digit ("0" if `n` is 0 outright).
275
+ fun trimmedFracDigits(n: Int, digits: Int) -> Str =
276
+ if n == 0 || digits <= 1
277
+ return digitChar(n % 10)
278
+ if n % 10 == 0
279
+ return trimmedFracDigits(n / 10, digits - 1)
280
+ return fixedFracDigits(n, pow10(digits - 1))
281
+
282
+ # Shared `base^y` algorithm behind `Number.pow`, extracted so both the `Int`
283
+ # and `Float` self cases (which only differ in how `base` was obtained) share
284
+ # one body.
285
+ fun powFloat(base: Float, y: Float) -> Float =
286
+ if y == 0.0f
287
+ return 1.0f
288
+ yi := Int(y)
289
+ if Float(yi) == y
290
+ neg := yi < 0
291
+ n := neg ? -yi : yi
292
+ result := 1.0f
293
+ b := base
294
+ m := n
295
+ while m > 0
296
+ if m % 2 == 1
297
+ result = result * b
298
+ b = b * b
299
+ m = m / 2
300
+ return neg ? 1.0f / result : result
301
+ if base < 0.0f
302
+ return 0.0f / 0.0f
303
+ return exp(y * ln(base))
304
+
305
+ # Parses a decimal integer, with an optional leading `+`/`-`.
306
+ fun parseInt(s: Str) -> Result[Int, Str] =
307
+ len := s.length()
308
+ if len == 0
309
+ return Err("empty string")
310
+ neg := s.byteAt(0) == 45
311
+ start := neg || s.byteAt(0) == 43 ? 1 : 0
312
+ if start >= len
313
+ return Err("invalid integer: '{s}'")
314
+ value := 0
315
+ i := start
316
+ while i < len
317
+ b := s.byteAt(i)
318
+ if b < 48 || b > 57
319
+ return Err("invalid integer: '{s}'")
320
+ value = value * 10 + b - 48
321
+ i = i + 1
322
+ return Ok(neg ? -value : value)
323
+
324
+ # Parses a decimal float, with an optional leading `+`/`-` and an optional
325
+ # `.` fractional part (no exponent notation).
326
+ fun parseFloat(s: Str) -> Result[Float, Str] =
327
+ len := s.length()
328
+ if len == 0
329
+ return Err("empty string")
330
+ neg := s.byteAt(0) == 45
331
+ start := neg || s.byteAt(0) == 43 ? 1 : 0
332
+ if start >= len
333
+ return Err("invalid float: '{s}'")
334
+ int_part := 0.0f
335
+ saw_digit := False
336
+ i := start
337
+ while i < len && s.byteAt(i) != 46
338
+ b := s.byteAt(i)
339
+ if b < 48 || b > 57
340
+ return Err("invalid float: '{s}'")
341
+ int_part = int_part * 10.0f + Float(b - 48)
342
+ saw_digit = True
343
+ i = i + 1
344
+ frac_part := 0.0f
345
+ frac_scale := 1.0f
346
+ if i < len && s.byteAt(i) == 46
347
+ i = i + 1
348
+ while i < len
349
+ b = s.byteAt(i)
350
+ if b < 48 || b > 57
351
+ return Err("invalid float: '{s}'")
352
+ frac_scale = frac_scale / 10.0f
353
+ frac_part = frac_part + Float(b - 48) * frac_scale
354
+ saw_digit = True
355
+ i = i + 1
356
+ if !saw_digit
357
+ return Err("invalid float: '{s}'")
358
+ value := int_part + frac_part
359
+ return Ok(neg ? -value : value)
360
+
361
+ # A raw, host-provided pseudo-random 64-bit Int (xorshift64*, seeded from the
362
+ # wall clock — not cryptographically secure). Every other random-number
363
+ # function in std is built on top of this one host extern.
364
+ extern fun rawRandomInt() -> Int
365
+
366
+ # A random Float uniformly distributed in [0.0, 1.0).
367
+ fun random() -> Float =
368
+ return Float(rawRandomInt() & MAX_VALUE) / Float(MAX_VALUE)
369
+
370
+ # A random Int uniformly distributed in [0, n). Returns 0 for n <= 0.
371
+ fun randomInt(n: Int) -> Int =
372
+ if n <= 0
373
+ return 0
374
+ return { rawRandomInt() & MAX_VALUE } % n
375
+
376
+ # Natural exponential, e^x, via range reduction (halve x until |x| <= 0.5,
377
+ # run the Taylor series there where it converges fast, then square the
378
+ # result back up the same number of halvings).
379
+ fun exp(x: Float) -> Float =
380
+ if x != x
381
+ return x
382
+ if x > 700.0f
383
+ return 1.0f / 0.0f
384
+ if x < -700.0f
385
+ return 0.0f
386
+ v := x
387
+ k := 0
388
+ while v > 0.5f || v < -0.5f
389
+ v = v / 2.0f
390
+ k = k + 1
391
+ term := 1.0f
392
+ sum := 1.0f
393
+ n := 1
394
+ while n < 25
395
+ term = term * v / Float(n)
396
+ sum = sum + term
397
+ n = n + 1
398
+ result := sum
399
+ i := 0
400
+ while i < k
401
+ result = result * result
402
+ i = i + 1
403
+ return result
404
+
405
+ # Natural logarithm, via range reduction to v in [1, 2) plus the
406
+ # fast-converging series ln(v) = 2*atanh((v-1)/(v+1)).
407
+ fun ln(x: Float) -> Float =
408
+ if x != x || x < 0.0f
409
+ return 0.0f / 0.0f
410
+ if x == 0.0f
411
+ return -1.0f / 0.0f
412
+ if x > MAX_FLOAT_VALUE
413
+ return x
414
+ v := x
415
+ k := 0
416
+ while v >= 2.0f
417
+ v = v / 2.0f
418
+ k = k + 1
419
+ while v < 1.0f
420
+ v = v * 2.0f
421
+ k = k - 1
422
+ t := {v - 1.0f} / {v + 1.0f}
423
+ t2 := t * t
424
+ term := t
425
+ sum := t
426
+ n := 1
427
+ while n < 30
428
+ term = term * t2
429
+ sum = sum + term / Float(2 * n + 1)
430
+ n = n + 1
431
+ return Float(k) * LN2 + 2.0f * sum
432
+
433
+ # Square root via Newton's method, iterating to a fixed point.
434
+ fun sqrt(x: Float) -> Float =
435
+ if x < 0.0f
436
+ return 0.0f / 0.0f
437
+ if x == 0.0f || x != x
438
+ return x
439
+ guess := x
440
+ prev := 0.0f
441
+ i := 0
442
+ while guess != prev && i < 100
443
+ prev = guess
444
+ guess = 0.5f * {guess + x / guess}
445
+ i = i + 1
446
+ return guess
447
+
448
+ # Reduces `x` into roughly `[-PI, PI]` by subtracting the nearest multiple of
449
+ # `TAU` — the range `sin`/`cos`'s Taylor series below actually converge
450
+ # quickly over. For very large `|x|` (many multiples of `TAU`), floating-point
451
+ # cancellation in `x - k*TAU` loses precision the same way any naive
452
+ # range-reduction by subtraction does; a real libm uses extended-precision
453
+ # constants to avoid this, which isn't attempted here.
454
+ fun reduceToPi(x: Float) -> Float =
455
+ k := Int(x / TAU + {x >= 0.0f ? 0.5f : -0.5f})
456
+ return x - Float(k) * TAU
457
+
458
+ fun sin(x: Float) -> Float =
459
+ if x != x || x > MAX_FLOAT_VALUE || x < -MAX_FLOAT_VALUE
460
+ return 0.0f / 0.0f
461
+ r := reduceToPi(x)
462
+ r2 := r * r
463
+ term := r
464
+ sum := r
465
+ n := 1
466
+ while n < 10
467
+ term = term * {-r2} / Float({2 * n} * {2 * n + 1})
468
+ sum = sum + term
469
+ n = n + 1
470
+ return sum
471
+
472
+ fun cos(x: Float) -> Float =
473
+ if x != x || x > MAX_FLOAT_VALUE || x < -MAX_FLOAT_VALUE
474
+ return 0.0f / 0.0f
475
+ return sin(x + HALF_PI)
476
+
477
+ fun tan(x: Float) -> Float =
478
+ return sin(x) / cos(x)
479
+
480
+ # Arctangent, via repeated argument-halving (`atan(x) = 2*atan(x / (1 +
481
+ # sqrt(1+x^2)))`) until `|x| <= 0.5` (where the Taylor series below converges
482
+ # quickly), then doubling the result back up the same number of times.
483
+ fun atan(x: Float) -> Float =
484
+ if x != x
485
+ return x
486
+ neg := x < 0.0f
487
+ v := neg ? -x : x
488
+ k := 0
489
+ while v > 0.5f && k < 8
490
+ v = v / {1.0f + sqrt(1.0f + v * v)}
491
+ k = k + 1
492
+ v2 := v * v
493
+ term := v
494
+ sum := v
495
+ n := 1
496
+ while n < 20
497
+ term = term * {-v2}
498
+ sum = sum + term / Float(2 * n + 1)
499
+ n = n + 1
500
+ scale := Float(1 << k)
501
+ result := sum * scale
502
+ return neg ? -result : result
503
+
504
+ fun asin(x: Float) -> Float =
505
+ if x != x || x < -1.0f || x > 1.0f
506
+ return 0.0f / 0.0f
507
+ if x == 1.0f
508
+ return HALF_PI
509
+ if x == -1.0f
510
+ return -HALF_PI
511
+ return atan(x / sqrt(1.0f - x * x))
512
+
513
+ fun acos(x: Float) -> Float =
514
+ return HALF_PI - asin(x)
515
+
516
+ # Angle (in radians) of the point `(x, y)` from the origin, in the correct
517
+ # quadrant for any sign combination of `x`/`y` (unlike plain `atan(y/x)`,
518
+ # which can't distinguish opposite quadrants).
519
+ fun atan2(y: Float, x: Float) -> Float =
520
+ if x > 0.0f
521
+ return atan(y / x)
522
+ if x < 0.0f && y >= 0.0f
523
+ return atan(y / x) + PI
524
+ if x < 0.0f && y < 0.0f
525
+ return atan(y / x) - PI
526
+ if x == 0.0f && y > 0.0f
527
+ return HALF_PI
528
+ if x == 0.0f && y < 0.0f
529
+ return -HALF_PI
530
+ return 0.0f
531
+
532
+ fun hypot(a: Float, b: Float) -> Float =
533
+ return sqrt(a * a + b * b)
534
+
535
+ # Cube root via Newton's method (fixed iteration count, unlike `sqrt`'s
536
+ # converge-to-a-fixed-point loop, since the cubic update step doesn't reach an
537
+ # exact fixed point in float precision as reliably as the quadratic one does).
538
+ fun cbrt(x: Float) -> Float =
539
+ if x == 0.0f || x != x
540
+ return x
541
+ neg := x < 0.0f
542
+ v := neg ? -x : x
543
+ guess := v
544
+ i := 0
545
+ while i < 60
546
+ guess = {2.0f * guess + v / {guess * guess}} / 3.0f
547
+ i = i + 1
548
+ return neg ? -guess : guess
plum-std/Option.plum CHANGED
@@ -3,7 +3,7 @@ module std
3
3
  import std/Result
4
4
  import std/Str
5
5
  import std/Bool
6
- import std/Int
6
+ import std/Number
7
7
 
8
8
  # Option[T] represents a value that may or may not be present — Plum's
9
9
  # counterpart to Rust's `Option`/Go's "zero value or ok bool" idiom.
plum-std/Os.plum CHANGED
@@ -4,7 +4,7 @@ import std/Result
4
4
  import std/Str
5
5
  import std/Uuid
6
6
  import std/Bool
7
- import std/Int
7
+ import std/Number
8
8
 
9
9
  # NOTE: the original file modeled `stdin`/`stdout`/`stderr` as bare top-level
10
10
  # variable bindings and a `File(...)` constructor, but this language has no
plum-std/Result.plum CHANGED
@@ -2,7 +2,7 @@ module std
2
2
  import std/Option
3
3
  import std/Str
4
4
  import std/Bool
5
- import std/Int
5
+ import std/Number
6
6
 
7
7
  # Result[T, E] represents either success (`Ok`, carrying a `T`) or failure
8
8
  # (`Err`, carrying an `E`) — used throughout std for fallible operations
plum-std/Str.plum CHANGED
@@ -3,7 +3,7 @@ module std
3
3
  import std/List
4
4
  import std/Buffer
5
5
  import std/Bool
6
- import std/Int
6
+ import std/Number
7
7
 
8
8
  # Any type that can be converted to a str needs to implement this trait
9
9
  trait ToStr =
plum-std/Testing.plum CHANGED
@@ -4,7 +4,7 @@ import std/Option
4
4
  import std/Result
5
5
  import std/List
6
6
  import std/Bool
7
- import std/Int
7
+ import std/Number
8
8
  import std/Str
9
9
 
10
10
  # Small jest/rspec-style assertion helpers for use inside `test` blocks, e.g.
plum-std/Time.plum CHANGED
@@ -1,8 +1,7 @@
1
1
  module std
2
- import std/Int
2
+ import std/Number
3
3
  import std/Str
4
4
  import std/Bool
5
- import std/Float
6
5
 
7
6
  # A raw, host-provided wall-clock reading: milliseconds since the Unix epoch.
8
7
  extern fun rawNowMillis() -> Int
plum-std/Uuid.plum CHANGED
@@ -1,5 +1,5 @@
1
1
  module std
2
- import std/Int
2
+ import std/Number
3
3
  import std/Result
4
4
  import std/Str
5
5
  import std/Bool
plum-wasm-codegen/src/lib.rs CHANGED
@@ -952,46 +952,21 @@ fn strLit(s: String) -> ast::Expr {
952
952
  ast::Expr::String(ast::StringExpr { parts: vec![ast::StringPart::Text(s)] })
953
953
  }
954
954
 
955
- /// True if `expr` is OBVIOUSLY float-valued from its own AST shape alone (a
956
- /// bare float literal) — no real type inference, just enough to catch the
957
- /// overwhelmingly common `assert computed() == 1.5` shape. `"{expr}"` string
958
- /// interpolation doesn't support `Float` yet (see README's Known gaps), so
959
- /// `assertFailureMessage` skips the actual/expected enhancement whenever
960
- /// either side looks like one, rather than break compiling a real test that
961
- /// compares a float against a literal (a computed-float-vs-computed-float
962
- /// comparison with no literal on either side isn't caught by this — a
963
- /// narrower residual gap, not a new one: interpolating that value was never
964
- /// going to work either way).
965
- fn looksLikeFloat(expr: &ast::Expr) -> bool {
966
- match expr {
967
- ast::Expr::Float(_) => true,
968
- // `-1.5` parses as a unary negation wrapping the literal, not a bare
969
- // `Expr::Float` itself.
970
- ast::Expr::Unary(u) => looksLikeFloat(&u.operand),
971
- _ => false,
972
- }
973
- }
974
-
975
955
  /// Builds a failing `assert`'s report line: the literal condition text, plus —
976
- /// when the condition is a top-level comparison (`left OP right`) whose sides
956
+ /// when the condition is a top-level comparison (`left OP right`) the
977
- /// aren't obviously `Float` (see `looksLikeFloat`) — the ACTUAL (left) and
978
- /// EXPECTED (right) sides' own runtime values underneath, `assert computed()
957
+ /// ACTUAL (left) and EXPECTED (right) sides' own runtime values underneath,
979
- /// == literal` being the overwhelmingly common shape a test writes such a
958
+ /// `assert computed() == literal` being the overwhelmingly common shape a
980
- /// comparison in. Reuses `"{expr}"` string interpolation's own existing
959
+ /// test writes such a comparison in. Reuses `"{expr}"` string interpolation's
981
- /// codegen (rather than any special stringification of its own) — so this
960
+ /// own existing codegen (rather than any special stringification of its
982
- /// shows a value for any type interpolation already supports today, and
961
+ /// own) including `Float`, which interpolation now supports by rendering
983
- /// fails to compile with that same, ordinary interpolation error for a type
962
+ /// through `Number.toStr` (`plum-std/Number.plum`), so no type needs
984
- /// it doesn't (no separate risk introduced beyond what `looksLikeFloat`
985
- /// already guards against). A non-comparison condition (`assert isEven(4)`)
963
+ /// special-casing out here any more. A non-comparison condition (`assert
986
- /// has no meaningful actual/expected split — a boolean condition is always
964
+ /// isEven(4)`) has no meaningful actual/expected split — a boolean condition
987
- /// false by the time this fires — so it's just the bare text.
965
+ /// is always false by the time this fires — so it's just the bare text.
988
966
  fn assertFailureMessage(c: &ast::Check) -> ast::Expr {
989
967
  let ast::Expr::Compare(cmp) = &c.cond else {
990
968
  return strLit(format!("{}\n", c.text));
991
969
  };
992
- if looksLikeFloat(&cmp.left) || looksLikeFloat(&cmp.right) {
993
- return strLit(format!("{}\n", c.text));
994
- }
995
970
  ast::Expr::String(ast::StringExpr {
996
971
  parts: vec![
997
972
  ast::StringPart::Text(format!("{}\n", c.text)),
@@ -4248,13 +4223,43 @@ fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut
4248
4223
  // dispatched via `call_indirect` — not a direct `Call` to a named function.
4249
4224
  let is_closure_call = ctx.locals.contains_key(&call.name)
4250
4225
  && matches!(inferLocalType(&ast::Expr::Var(call.name.clone()), ctx), PlumType::TFun(_, _));
4226
+ // `Int(x)`/`Float(x)`/`Byte(x)` is the primitive numeric CAST
4227
+ // special case just below UNLESS `call.name` is ALSO a
4251
- if (call.name == "Int" || call.name == "Float" || call.name == "Byte") && call.args.len() == 1 && !ctx.func_ids.contains_key(&call.name) {
4228
+ // registered enum variant (a "union" enum's bare-primitive-wrap
4229
+ // variant, e.g. `enum Number = | Int | Float` — see
4230
+ // `buildGlobalTables`'s primitive bare-wrap condition) whose
4231
+ // single field type exactly matches `x`'s own type: that's a
4232
+ // same-type construction (`Int(intExpr)` -> box into `Number`'s
4233
+ // `Int` variant via `compileVariantConstruction` below), not a
4234
+ // cast. A genuinely cross-type call (`Int(floatExpr)`) is never
4235
+ // ambiguous — no bare-wrap variant's field type could match an
4236
+ // argument of a DIFFERENT primitive type — so every existing
4237
+ // numeric-cast call site in the stdlib (`Float(self)`,
4238
+ // `Int(y)`, ...) keeps working unchanged.
4239
+ let single_arg_ty = if call.args.len() == 1 {
4252
4240
  let arg_expr = match &call.args[0] {
4253
4241
  ast::Arg::Positional(e) => e,
4254
4242
  ast::Arg::Keyword { value, .. } => value,
4255
4243
  ast::Arg::Pair { value, .. } => value,
4256
4244
  };
4257
- let arg_ty = inferLocalType(arg_expr, ctx);
4245
+ Some(inferLocalType(arg_expr, ctx))
4246
+ } else {
4247
+ None
4248
+ };
4249
+ let is_same_type_variant_wrap = single_arg_ty.as_ref().is_some_and(|ty| {
4250
+ ctx.enum_variants.get(&call.name).is_some_and(|info| info.field_types == [ty.clone()])
4251
+ });
4252
+ if (call.name == "Int" || call.name == "Float" || call.name == "Byte")
4253
+ && !ctx.func_ids.contains_key(&call.name)
4254
+ && !is_same_type_variant_wrap
4255
+ && single_arg_ty.is_some()
4256
+ {
4257
+ let arg_expr = match &call.args[0] {
4258
+ ast::Arg::Positional(e) => e,
4259
+ ast::Arg::Keyword { value, .. } => value,
4260
+ ast::Arg::Pair { value, .. } => value,
4261
+ };
4262
+ let arg_ty = single_arg_ty.expect("just checked is_some");
4258
4263
  compileExpr(arg_expr, body, ctx, state)?;
4259
4264
  match (call.name.as_str(), &arg_ty) {
4260
4265
  ("Float", PlumType::TInt) => Instruction::F64ConvertI64S.encode(body),
@@ -4508,7 +4513,33 @@ fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut
4508
4513
  PlumType::TByteSlice => "ByteSlice".to_string(),
4509
4514
  other => return Err(format!("codegen: cannot call method '{}' on non-class type {}", call.name, other)),
4510
4515
  };
4511
- let key = format!("{}::{}", class_name, call.name);
4516
+ let direct_key = format!("{}::{}", class_name, call.name);
4517
+ // Fallback: a primitive/class receiver's method might be
4518
+ // defined once on the "union" enum that bare-wraps it
4519
+ // instead (`enum Number = | Int | Float`, e.g. `abs`
4520
+ // defined on `Number` rather than duplicated per
4521
+ // primitive) — `class_name` (e.g. "Int") is itself a
4522
+ // registered enum-variant NAME in that case (see
4523
+ // `buildGlobalTables`'s bare-wrap conditions), so look up
4524
+ // its owning enum and retry there. `box_variant_idx` is
4525
+ // this variant's own concrete GC struct type index, used
4526
+ // below to box the raw `self` value into it before the
4527
+ // call (the wrap-enum's method expects a boxed `Number`,
4528
+ // not a bare `i64`/`f64`).
4529
+ let wrap_fallback = (!ctx.func_ids.contains_key(&direct_key)).then(|| {
4530
+ ctx.enum_variants.get(class_name.as_str()).and_then(|info| {
4531
+ let wrap_key = format!("{}::{}", info.enum_name, call.name);
4532
+ ctx.func_ids.contains_key(&wrap_key).then(|| {
4533
+ let variant_idx = *ctx.gc_types.variant_type_idx.get(class_name.as_str())
4534
+ .expect("class_name is a registered enum variant, checked above");
4535
+ (info.enum_name.clone(), wrap_key, variant_idx)
4536
+ })
4537
+ })
4538
+ }).flatten();
4539
+ let (dispatch_name, key, box_variant_idx) = match wrap_fallback {
4540
+ Some((enum_name, wrap_key, variant_idx)) => (enum_name, wrap_key, Some(variant_idx)),
4541
+ None => (class_name.clone(), direct_key, None),
4542
+ };
4512
4543
  let func_idx = *ctx
4513
4544
  .func_ids
4514
4545
  .get(&key)
@@ -4533,10 +4564,17 @@ fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut
4533
4564
  // bare-`TypeName` fallback doesn't know about primitives), whose
4534
4565
  // `plumTypeToValtype` would wrongly resolve to a GC ref instead
4535
4566
  // of `i64`.
4536
- let vt = astTypeToWasm(&class_name).unwrap_or(ValType::I32);
4567
+ let vt = astTypeToWasm(&dispatch_name).unwrap_or(ValType::I32);
4537
4568
  pushSelfPlaceholder(vt, body);
4538
4569
  } else {
4539
4570
  compileExpr(&attr.object, body, ctx, state)?; // push self
4571
+ if let Some(variant_idx) = box_variant_idx {
4572
+ // Dispatching to the wrap-enum's method (see
4573
+ // `wrap_fallback` above) — its `self` param is
4574
+ // typed as the ENUM (`Number`), a boxed GC struct,
4575
+ // not the bare primitive/class value just pushed.
4576
+ Instruction::StructNew(variant_idx).encode(body);
4577
+ }
4540
4578
  }
4541
4579
 
4542
4580
  fn argExprOf(arg: &ast::Arg) -> &ast::Expr {
@@ -4550,7 +4588,7 @@ fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut
4550
4588
  // trailing args into one GC array, exactly like a plain function call
4551
4589
  // (see the `Expr::FnCall` variadic-call arm) — `call.args` doesn't
4552
4590
  // include `self`, matching `ctx.methods`' own param list.
4553
- let variadic_split = match ctx.methods.get(&(class_name.clone(), call.name.clone())) {
4591
+ let variadic_split = match ctx.methods.get(&(dispatch_name.clone(), call.name.clone())) {
4554
4592
  Some(PlumType::TFun(params, _)) => match params.last() {
4555
4593
  Some(PlumType::TVariadic(elem)) => Some(((**elem).clone(), params.len() - 1)),
4556
4594
  _ => None,
@@ -4696,9 +4734,10 @@ fn compileStaticString(text: &str, body: &mut Vec<u8>, state: &mut ModuleState)
4696
4734
 
4697
4735
  /// Lowers a string literal that contains at least one `{expr}` interpolation.
4698
4736
  /// Every part becomes a string-pointer-valued expression (static text via
4699
- /// `compileStaticString`; `Str`/`Int`/`Bool` interpolated values converted at
4737
+ /// `compileStaticString`; `Str`/`Int`/`Bool`/`Float` interpolated values
4738
+ /// converted at runtime — `Float` via `Number.toStr`, see the `TFloat` arm
4700
- /// runtime), then all parts are left-folded together with the `__string_concat`
4739
+ /// below), then all parts are left-folded together with the
4701
- /// runtime helper.
4740
+ /// `__string_concat` runtime helper.
4702
4741
  fn compileInterpolatedString(
4703
4742
  s: &ast::StringExpr,
4704
4743
  body: &mut Vec<u8>,
@@ -4731,7 +4770,23 @@ fn compileInterpolatedString(
4731
4770
  Instruction::End.encode(body);
4732
4771
  }
4733
4772
  PlumType::TFloat => {
4773
+ // Reuses `Number.toStr` (`plum-std/Number.plum`) rather
4774
+ // than a separate hand-written float-formatting helper —
4775
+ // box the raw `f64` into `Number`'s `Float` variant (the
4776
+ // same "box self, call the wrap-enum's method" idiom
4777
+ // `Expr::Attribute`'s `AttrKind::Method` dispatch
4778
+ // fallback uses for a method call on a bare `Float`
4779
+ // receiver), then call it directly. Any program using
4780
+ // `Float` at all already must `import std/Number` (see
4781
+ // `plum-core::loader::checkBuiltinImports`), so these
4782
+ // lookups are always present.
4783
+ compileExpr(expr, body, ctx, state)?;
4784
+ let variant_idx = *ctx.gc_types.variant_type_idx.get("Float")
4734
- return Err("codegen: interpolating a Float value is not yet supported".to_string());
4785
+ .ok_or_else(|| "codegen: interpolating a Float value requires `Number` (import std/Number)".to_string())?;
4786
+ Instruction::StructNew(variant_idx).encode(body);
4787
+ let func_idx = *ctx.func_ids.get("Number::toStr")
4788
+ .ok_or_else(|| "codegen: interpolating a Float value requires `Number.toStr` (import std/Number)".to_string())?;
4789
+ Instruction::Call(func_idx).encode(body);
4735
4790
  }
4736
4791
  other => {
4737
4792
  return Err(format!(
scripts/test-examples.sh DELETED
@@ -1,104 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # Integration test: compiles and runs every examples/*.plum file through the
4
- # REAL toolchain — the `plum` CLI binary (which resolves `import`s against
5
- # --lib-path, unlike plum-checker/plum-wasm-codegen's unit tests, which parse
6
- # and compile a single file directly) and, if available, an external
7
- # `wasmtime` runtime.
8
- #
9
- # This exists because the unit tests alone missed a real bug: examples/basics.plum
10
- # had an `import std/io` where libs/std/io.plum doesn't exist. The unit tests
11
- # never noticed since they don't touch plum-core's loader/import resolution at
12
- # all — only compiling and running through the actual CLI catches that class
13
- # of problem.
14
- #
15
- # Usage: scripts/test-examples.sh
16
- # Exit code is 0 if every example compiled (and, where checked, ran) correctly.
17
-
18
- set -uo pipefail
19
-
20
- cd "$(dirname "${BASH_SOURCE[0]}")/.."
21
-
22
- WORKDIR=$(mktemp -d)
23
- trap 'rm -rf "$WORKDIR"' EXIT
24
-
25
- echo "Building plum-cli..."
26
- if ! cargo build -p plum-cli --quiet 2>"$WORKDIR/build.log"; then
27
- echo "FAIL: plum-cli failed to build" >&2
28
- cat "$WORKDIR/build.log" >&2
29
- exit 1
30
- fi
31
- PLUM_BIN="target/debug/plum"
32
-
33
- HAVE_WASMTIME=0
34
- if command -v wasmtime >/dev/null 2>&1; then
35
- HAVE_WASMTIME=1
36
- else
37
- echo "note: wasmtime CLI not found on PATH — will compile every example but skip running them" >&2
38
- fi
39
-
40
- # Examples that export `main` and are expected to run without trapping. Kept
41
- # in sync with plum-wasm-codegen/tests/examples_test.rs.
42
- has_main() {
43
- case "$1" in
44
- basics|closures|match|methods) return 0 ;;
45
- *) return 1 ;;
46
- esac
47
- }
48
-
49
- # Expected `main` return value for examples whose result is asserted in
50
- # plum-wasm-codegen/tests/examples_test.rs. Empty means "just don't trap"
51
- # (e.g. basics.plum's main returns Unit, not a value to compare).
52
- expected_value() {
53
- case "$1" in
54
- closures) echo 120 ;;
55
- match) echo 5 ;;
56
- methods) echo 10 ;;
57
- *) echo "" ;;
58
- esac
59
- }
60
-
61
- failed=0
62
- passed=0
63
-
64
- for src in examples/*.plum; do
65
- name=$(basename "$src" .plum)
66
- wasm="$WORKDIR/$name.wasm"
67
-
68
- printf '%-14s compile ... ' "$name"
69
- if ! "$PLUM_BIN" compile "$src" -o "$wasm" >"$WORKDIR/$name.compile.log" 2>&1; then
70
- echo "FAIL"
71
- sed 's/^/ /' "$WORKDIR/$name.compile.log"
72
- failed=$((failed + 1))
73
- continue
74
- fi
75
- echo "ok"
76
-
77
- if [ "$HAVE_WASMTIME" -eq 1 ] && has_main "$name"; then
78
- printf '%-14s run ... ' "$name"
79
- if output=$(wasmtime run --invoke main "$wasm" 2>"$WORKDIR/$name.run.log"); then
80
- expected=$(expected_value "$name")
81
- if [ -n "$expected" ] && [ "$output" != "$expected" ]; then
82
- echo "FAIL (expected $expected, got '$output')"
83
- failed=$((failed + 1))
84
- continue
85
- fi
86
- if [ -n "$output" ]; then
87
- echo "ok (-> $output)"
88
- else
89
- echo "ok"
90
- fi
91
- else
92
- echo "FAIL (trapped)"
93
- sed 's/^/ /' "$WORKDIR/$name.run.log"
94
- failed=$((failed + 1))
95
- continue
96
- fi
97
- fi
98
-
99
- passed=$((passed + 1))
100
- done
101
-
102
- echo
103
- echo "$passed passed, $failed failed"
104
- [ "$failed" -eq 0 ]
scripts/test-plum.sh DELETED
@@ -1,65 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # Runs every native Plum `test`/`assert` block through the REAL `plum` CLI
4
- # (`plum test`) — the same toolchain used by end users, not a Rust-side
5
- # unit-test shortcut. Most of what used to be Rust `#[test]` functions in
6
- # plum-wasm-codegen/tests/codegen_tests.rs (compile a snippet, run it,
7
- # assert on the result) now live as `test` blocks co-located with the Plum
8
- # source they exercise — stdlib-shaped regression tests inside
9
- # plum-std/*.plum itself, and core-language-feature tests inside the
10
- # matching examples/*.plum (closures.plum, match.plum, functions.plum,
11
- # types.plum, control_flow.plum) — a Plum-language behavior is verified in
12
- # Plum itself, not re-described in Rust.
13
- #
14
- # `plum test` follows a file's `import`s transitively (same as `plum run`), so
15
- # running it on one plum-std file also re-runs every test in whatever it
16
- # imports — harmless duplication, not a correctness issue.
17
- #
18
- # Files with no `test` block just report "no tests found" and are skipped
19
- # without failing the run.
20
- #
21
- # Usage: scripts/test-plum.sh
22
- # Exit code is 0 if every file's tests passed (or it had none).
23
-
24
- set -uo pipefail
25
-
26
- cd "$(dirname "${BASH_SOURCE[0]}")/.."
27
-
28
- echo "Building plum-cli..."
29
- if ! cargo build -p plum-cli --quiet 2>/tmp/plum-test-plum-build.log; then
30
- echo "FAIL: plum-cli failed to build" >&2
31
- cat /tmp/plum-test-plum-build.log >&2
32
- exit 1
33
- fi
34
- PLUM_BIN="target/debug/plum"
35
-
36
- # Known-incomplete stdlib modules that don't compile yet at all (regex/http
37
- # are documented as deferred in README's "Known gaps") — skipped here so this
38
- # script stays a useful pass/fail gate instead of always failing for reasons
39
- # unrelated to `test`/`assert` regressions.
40
- is_known_incomplete() {
41
- case "$(basename "$1")" in
42
- Http.plum) return 0 ;;
43
- *) return 1 ;;
44
- esac
45
- }
46
-
47
- failed=0
48
- files_run=0
49
-
50
- for src in plum-std/*.plum examples/*.plum; do
51
- [ -f "$src" ] || continue
52
- if is_known_incomplete "$src"; then
53
- echo "=== $src === (skipped: known incomplete, see README's Known gaps)"
54
- continue
55
- fi
56
- echo "=== $src ==="
57
- if ! "$PLUM_BIN" test "$src"; then
58
- failed=$((failed + 1))
59
- fi
60
- files_run=$((files_run + 1))
61
- echo
62
- done
63
-
64
- echo "$((files_run - failed))/$files_run files passed"
65
- [ "$failed" -eq 0 ]