plum

#treesitter#compiler#wasm

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

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


ad8b044 — Peter John 2026-10-26T12:13:42+05:30
feat: duck-typed operators; README for Zig-style generics; results
README.md CHANGED
@@ -127,6 +127,32 @@ negated = -sum
127
127
  inverted = !cmp
128
128
  ```
129
129
 
130
+ ### Operators on your own types
131
+
132
+ On `Int`, `Float`, `Byte` and `Str` operators are built in. On any other (declared) type, an operator calls a method on the left operand, resolved statically per instantiation:
133
+
134
+ | Operator | Method |
135
+ |---|---|
136
+ | `a + b`, `a - b`, `a * b`, `a / b` | `a.add(b)`, `sub`, `mul`, `div` |
137
+ | `-a` | `a.neg()` |
138
+ | `a == b`, `a != b` | `a.eq(b)`, `!a.eq(b)` |
139
+ | `a < b`, `a <= b`, `a > b`, `a >= b` | `a.lt(b)`, `a.le(b)`, `a.gt(b)`, `a.ge(b)` |
140
+
141
+ Each operator needs its own method (no derivation from `lt`/`eq`). The right operand may have a different type: `v * 2.0` calls `mul(self, s: Float)`. Using an operator without the method is an error such as `Vec2 has no operator '+' (define add)`, with the instantiation chain when it happens inside a generic. `==`/`!=` on a type with no `eq` keep their built-in meaning (variant identity), and tag-union `==` is unchanged.
142
+
143
+ ```plum
144
+ enum Vec2 =
145
+ | Vec2(x: Float, y: Float)
146
+
147
+ fun add(self, o: Vec2) -> Vec2 =
148
+ Vec2(x: self.x + o.x, y: self.y + o.y)
149
+
150
+ fun mul(self, s: Float) -> Vec2 =
151
+ Vec2(x: self.x * s, y: self.y * s)
152
+ ```
153
+
154
+ See [`plum-examples/operators.plum`](plum-examples/operators.plum).
155
+
130
156
  ## Variables and assignment
131
157
 
132
158
  ```plum
@@ -241,9 +267,34 @@ Generic types, methods, and free functions are duck-typed and compiled once per
241
267
 
242
268
  ### How generics compile
243
269
 
244
- Generics are duck-typed, as in Zig. A generic body is type-checked once per concrete instantiation, and each instantiation gets its own compiled copy with unboxed values. `List[Int]` and `List[Str]` are different code.
270
+ Generics are duck-typed and compiled per type, as in Zig. A generic body is type-checked once per concrete instantiation, and each instantiation gets its own compiled copy with unboxed values: `List[Int]` and `List[Str]` are different code, and a `List[Float]` stores raw `f64`s.
271
+
272
+ - **No bounds, no traits.** `[T]` carries no constraint. `fun show(x: T) -> Str = x.toStr()` works for every type that has a `toStr` method and fails at the use site otherwise. The old `trait`/`implements` syntax and `[T: X]` bounds are a parse error with a migration hint.
273
+ - **Errors point at the use site.** A missing method is reported against the concrete type, with the chain of instantiations that led there:
274
+
275
+ ```
276
+ in outer[Plain] (called at main.plum:9:3):
277
+ in maxOf[Plain] (called at main.plum:5:3):
278
+ Plain has no method 'lt'
279
+ ```
280
+
281
+ - **Unreachable generics are not emitted.** A generic that no root (a non-generic fn or a test) reaches is neither checked per type nor compiled.
282
+ - **Typed arrays are native.** Each instantiation stores its elements in an array of its own element type, with no boxing at generic edges.
283
+ - **Explicit "trait values".** When a generic needs behavior that is not a method on `T`, pass it explicitly, like a Zig comptime parameter or a Haskell dictionary:
284
+
285
+ ```plum
286
+ fun sumWith(xs: List[T], zero: T, add: fn(T, T) -> T) -> T =
287
+ acc := zero
288
+ for i := range xs.length()
289
+ acc = add(acc, xs.get(i) ?? zero)
290
+ acc
291
+
292
+ sumWith(List(1, 2, 3), 0, |a, b| a + b) # 6
293
+ sumWith(List("a", "b"), "", |a, b| a + b) # "ab"
294
+ ```
295
+
296
+ See `num_record_passed_explicitly` in [`plum-examples/generics.plum`](plum-examples/generics.plum). (Enum fields cannot hold `fn` types yet, so the "record of functions" is passed as separate arguments.)
245
297
 
246
- - **No bounds, no traits.** `[T]` carries no constraint. `fun show(x: T) -> Str = x.toStr()` works for every type that has a `toStr` method and fails at the call site otherwise. The old `trait` and `implements` syntax and `[T: X]` bounds were removed and now give a parse error with a migration hint.
247
298
  - **Mixed types are enums.** To hold several kinds of value in one list, declare an enum and `match self`:
248
299
 
249
300
  ```plum
@@ -350,7 +401,7 @@ fun greet(name: Str) -> Str =
350
401
  extern fun printLn(s: Str)
351
402
  ```
352
403
 
353
- `extern fun` declares a function backed by a host-provided wasm import, with no Plum body. `plum-std/Os.plum` declares `printLn` for use via `import std/os`.
404
+ `extern fun` declares a function backed by a host-provided wasm import, with no Plum body. At the boundary `Int` is narrowed to a wasm `i32` (the rest of the language uses 64-bit `Int`), so extern parameters and results are 32-bit. `plum-std/Os.plum` declares `printLn` for use via `import std/os`.
354
405
 
355
406
  ```sh
356
407
  $ plum run plum-examples/io.plum
docs/plans/2026-10-26-zig-generics-and-numbers.md CHANGED
@@ -128,3 +128,24 @@ Migrate every example, test and README snippet off traits. Update the README sec
128
128
  ## Open questions
129
129
 
130
130
  - Will the user's further ideas change scope or order? This is still a draft.
131
+
132
+ ## Results
133
+
134
+ T5 (duck-typed operators) and T6 (docs) are done. `a + b`, `-a`, `==` and `<` on a non-primitive left operand now call `add`, `neg`, `eq` and `lt`. Each of `sub mul div le gt ge` is its own method with no derivation from `lt`/`eq`, and the right operand may have a different type. A missing method is `<Type> has no operator '+' (define add)`, with the instantiation chain inside generics. `==`/`!=` on a type with no `eq` keep their built-in meaning, because existing code compares enum variants that way. Tag-union `==` is unchanged.
135
+
136
+ Tests: `cargo test --workspace` has 0 failures and `plum test plum-std plum-examples` ran 2321 passed, 0 failed (baseline 2252). New tests are in `plum-examples/operators.plum` (`vec2_add_operator`, `mul_scalar_rhs`, `neg`, `eq_and_neq`, `lt_ordering`), the checker test `operator_missing_method_error`, and `num_record_passed_explicitly` in `generics.plum`. `sumWith` takes `zero` and `add` as separate arguments, because enum fields cannot hold `fn` types yet.
137
+
138
+ Compile time (release bench, best of 5, from the repo root, over `plum-examples/*.plum` and `plum-std/*.plum`; load + check + codegen):
139
+
140
+ | | typical | max | wasm size |
141
+ |---|---|---|---|
142
+ | per-type copies (now) | 31-37 ms | 53.0 ms (`dop_visitor`; `oop_visitor` 52.4, `Os` 50.8) | 28.8-42.1 KB (typical about 29-30 KB) |
143
+ | erased baseline | 20-25 ms | not recorded | Bool.wasm 19 KB |
144
+
145
+ So compile time is about 1.4x the erased baseline and the smallest wasm is about 1.5x larger. Part of that is the always-loaded std prelude: even a near-empty file costs 31 ms and 28.8 KB, and the examples add only 0-20 ms on top of that.
146
+
147
+ LOC since b7a5ee8 (excluding generated parser files): 62 files changed, 2509 insertions(+), 2721 deletions(-). That is 212 fewer lines, even with the per-instantiation machinery added.
148
+
149
+ Known gaps:
150
+ - A closure that calls an `fn`-typed parameter fails with `unknown function`; this was found while writing `sumWith`.
151
+ - An enum field cannot have an `fn` type.
plum-checker/src/lib.rs CHANGED
@@ -31,6 +31,9 @@ thread_local! {
31
31
  /// generic arguments are pinned like a directly-returned expression's.
32
32
  static TAIL: RefCell<Option<PlumType>> = const { RefCell::new(None) };
33
33
  static MATCH_TAIL: RefCell<Option<PlumType>> = const { RefCell::new(None) };
34
+ /// Operator expressions (address) on non-primitive operands -> the synthetic method call
35
+ /// (`a + b` -> `a.add(b)`) they resolve to. Kept so the cloned arguments have stable addresses.
36
+ static OP_CALLS: RefCell<HashMap<usize, std::rc::Rc<ast::FnCall>>> = RefCell::new(HashMap::new());
34
37
  }
35
38
 
36
39
  /// A call to a generic fn/method seen in some body: callee identity plus the
@@ -100,6 +103,9 @@ pub struct TypedInfo {
100
103
  /// the instantiation it calls. `implicit` marks calls codegen makes itself (the
101
104
  /// `toStr` of a `"{x}"` interpolation, keyed by the interpolated expression).
102
105
  pub call_inst: HashMap<(Option<usize>, usize, bool), usize>,
106
+ /// Operator expression address -> the method call it desugars to (`a + b` -> `a.add(b)`;
107
+ /// the receiver is the operator's own left operand, `args` holds the right one).
108
+ pub op_calls: HashMap<usize, std::rc::Rc<ast::FnCall>>,
103
109
  }
104
110
 
105
111
  /// One reached instantiation of a generic fn or method of a generic type.
@@ -1003,7 +1009,9 @@ fn checkSourceFull(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1003
1009
  RECORD.with(|r| *r.borrow_mut() = Some(HashMap::new()));
1004
1010
  RECORD_BINDS.with(|r| *r.borrow_mut() = Some(Binds::new()));
1005
1011
  CALLS.with(|r| *r.borrow_mut() = Some(Vec::new()));
1012
+ OP_CALLS.with(|r| r.borrow_mut().clear());
1006
1013
  let (result, mut info) = checkSourceInner(source);
1014
+ info.op_calls = OP_CALLS.with(|r| std::mem::take(&mut *r.borrow_mut()));
1007
1015
  let mut recorded = RECORD.with(|r| r.borrow_mut().take()).unwrap_or_default();
1008
1016
  let binds = RECORD_BINDS.with(|r| r.borrow_mut().take()).unwrap_or_default();
1009
1017
  CALLS.with(|r| r.borrow_mut().take());
@@ -2866,6 +2874,46 @@ fn expectedTagLiteral(
2866
2874
  }
2867
2875
  }
2868
2876
 
2877
+ /// True for an operand whose operators are user methods (`add`, `eq`, ...): a
2878
+ /// declared class/enum, not a primitive, `Str`, `Bool` or tag union.
2879
+ pub fn isOperatorOperand(t: &PlumType) -> bool {
2880
+ matches!(t, PlumType::TNamed(n) | PlumType::TApp(n, _) if n != "Str" && n != "Int" && n != "Float" && n != "Bool" && n != "Byte")
2881
+ }
2882
+
2883
+ /// Resolves operator `sym` on a non-primitive left operand to `left.method(right)`,
2884
+ /// recording the synthetic call (see `TypedInfo::op_calls`) and its instantiation.
2885
+ #[allow(clippy::too_many_arguments)]
2886
+ fn inferOperatorCall(
2887
+ expr: &ast::Expr,
2888
+ lt: &PlumType,
2889
+ sym: &str,
2890
+ method: &str,
2891
+ right: Option<&ast::Expr>,
2892
+ env: &TypeEnv,
2893
+ ctx: &CheckCtx,
2894
+ expected: Option<&PlumType>,
2895
+ ) -> Result<PlumType, String> {
2896
+ let class = methodReceiverName(lt).unwrap_or_default();
2897
+ if !ctx.methods.contains_key(&(class, method.to_string())) {
2898
+ return Err(format!("{} has no operator '{}' (define {})", lt, sym, method));
2899
+ }
2900
+ let addr = expr as *const ast::Expr as usize;
2901
+ let call = OP_CALLS.with(|m| {
2902
+ m.borrow_mut()
2903
+ .entry(addr)
2904
+ .or_insert_with(|| {
2905
+ std::rc::Rc::new(ast::FnCall {
2906
+ name: method.to_string(),
2907
+ args: right.iter().map(|r| ast::Arg::Positional((*r).clone())).collect(),
2908
+ line: 0,
2909
+ column: 0,
2910
+ })
2911
+ })
2912
+ .clone()
2913
+ });
2914
+ inferMethodCall(lt, method, &call.args, env, ctx, expected)
2915
+ }
2916
+
2869
2917
  pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<PlumType, String> {
2870
2918
  inferExprWith(expr, env, ctx, None)
2871
2919
  }
@@ -3091,10 +3139,38 @@ fn inferExprInner(
3091
3139
  unify(&PlumType::TBool, &t, ctx)?;
3092
3140
  Ok(PlumType::TBool)
3093
3141
  }
3142
+ ast::Expr::Unary(u) => {
3094
- ast::Expr::Unary(u) => inferExpr(&u.operand, env, ctx),
3143
+ let t = inferExpr(&u.operand, env, ctx)?;
3144
+ if matches!(u.op, ast::UnOp::Neg) {
3145
+ if matches!(t, PlumType::TParam(_)) {
3146
+ return Ok(PlumType::TVar("_".to_string()));
3147
+ }
3148
+ if isOperatorOperand(&t) {
3149
+ return inferOperatorCall(expr, &t, "-", "neg", None, env, ctx, expected);
3150
+ }
3151
+ }
3152
+ Ok(t)
3153
+ }
3095
3154
  ast::Expr::Binary(b) => {
3096
3155
  let lt = inferExpr(&b.left, env, ctx)?;
3097
3156
  let rt = inferExpr(&b.right, env, ctx)?;
3157
+ let opn = match b.op {
3158
+ ast::BinOp::Add => Some(("+", "add")),
3159
+ ast::BinOp::Sub => Some(("-", "sub")),
3160
+ ast::BinOp::Mul => Some(("*", "mul")),
3161
+ ast::BinOp::Div => Some(("/", "div")),
3162
+ _ => None,
3163
+ };
3164
+ if let Some((sym, method)) = opn {
3165
+ if matches!(lt, PlumType::TParam(_)) && lt != rt {
3166
+ return Ok(PlumType::TVar("_".to_string()));
3167
+ }
3168
+ if isOperatorOperand(&lt) {
3169
+ return inferOperatorCall(
3170
+ expr, &lt, sym, method, Some(&b.right), env, ctx, expected,
3171
+ );
3172
+ }
3173
+ }
3098
3174
  unify(&lt, &rt, ctx).map_err(|e| format!("binary op: {}", e))?;
3099
3175
  bindAcross(&lt, &rt);
3100
3176
  if matches!(lt, PlumType::TVar(_)) && !matches!(rt, PlumType::TVar(_)) {
@@ -3122,6 +3198,29 @@ fn inferExprInner(
3122
3198
  }
3123
3199
  return Ok(PlumType::TBool);
3124
3200
  }
3201
+ let opn = match c.op {
3202
+ ast::CmpOp::Eq => Some(("==", "eq")),
3203
+ ast::CmpOp::Neq | ast::CmpOp::NotEq2 => Some(("!=", "eq")),
3204
+ ast::CmpOp::Lt => Some(("<", "lt")),
3205
+ ast::CmpOp::Lte => Some(("<=", "le")),
3206
+ ast::CmpOp::Gt => Some((">", "gt")),
3207
+ ast::CmpOp::Gte => Some((">=", "ge")),
3208
+ };
3209
+ if let Some((sym, method)) = opn {
3210
+ if matches!(lt, PlumType::TParam(_)) && lt != rt {
3211
+ return Ok(PlumType::TBool);
3212
+ }
3213
+ // `==`/`!=` keep their built-in meaning (variant/reference identity) on a
3214
+ // type that defines no `eq`; the ordering operators have no fallback.
3215
+ let has_eq = methodReceiverName(&lt)
3216
+ .is_some_and(|cl| ctx.methods.contains_key(&(cl, "eq".to_string())));
3217
+ if isOperatorOperand(&lt) && (method != "eq" || has_eq) {
3218
+ let r = inferOperatorCall(expr, &lt, sym, method, Some(&c.right), env, ctx, None)?;
3219
+ unify(&PlumType::TBool, &r, ctx)
3220
+ .map_err(|e| format!("operator '{}' ({}) must return Bool: {}", sym, method, e))?;
3221
+ return Ok(PlumType::TBool);
3222
+ }
3223
+ }
3125
3224
  unify(&lt, &rt, ctx).map_err(|e| format!("compare op: {}", e))?;
3126
3225
  bindAcross(&lt, &rt);
3127
3226
  Ok(PlumType::TBool)
plum-checker/tests/checker_tests.rs CHANGED
@@ -1889,6 +1889,18 @@ fn nested_instantiation_chain() {
1889
1889
  assert!(msg.contains("Plain has no method 'lt'"), "{}", msg);
1890
1890
  }
1891
1891
 
1892
+ #[test]
1893
+ fn operator_missing_method_error() {
1894
+ let src = format!(
1895
+ "{}fun twice(a: T) -> T =\n a + a\n\nfun main() -> Plain =\n twice(Plain(v: 1))\n",
1896
+ DUCK_DEFS
1897
+ );
1898
+ let msg = errText(checkSource(&parseClean(&src)));
1899
+ assert!(msg.contains("in twice[Plain]"), "{}", msg);
1900
+ assert!(msg.contains("called at"), "{}", msg);
1901
+ assert!(msg.contains("Plain has no operator '+' (define add)"), "{}", msg);
1902
+ }
1903
+
1892
1904
  #[test]
1893
1905
  fn instantiation_set_listed() {
1894
1906
  let src = format!(
plum-examples/generics.plum CHANGED
@@ -68,6 +68,15 @@ fun nameOf(s: T) -> Str =
68
68
 
69
69
  # --- numeric -------------------------------------------------------------
70
70
 
71
+ # "Trait values": there are no traits, so the behavior a generic needs is passed
72
+ # explicitly (here the "Num dictionary" `add` + `zero` as two arguments; enum
73
+ # fields cannot hold `fn` types yet, so it is not bundled into a record).
74
+ fun sumWith(xs: List[T], zero: T, add: fn(T, T) -> T) -> T =
75
+ acc := zero
76
+ for i := range xs.length()
77
+ acc = add(acc, xs.get(i) ?? zero)
78
+ acc
79
+
71
80
  test "Int extremes survive a generic fn"
72
81
  big := 9223372036854775807
73
82
  small := 0 - 9223372036854775807 - 1
@@ -209,3 +218,8 @@ test "== on T works for Int and Str"
209
218
  assert !same("a", "b")
210
219
  assert same(9007199254740993, 9007199254740993)
211
220
  assert !same(9007199254740993, 9007199254740992)
221
+
222
+ test "num_record_passed_explicitly"
223
+ assert sumWith(List(1, 2, 3), 0, |a, b| a + b) == 6
224
+ assert sumWith(List("a", "b"), "", |a, b| a + b) == "ab"
225
+ assert sumWith(List(1.5, 2.5), 0.0, |a, b| a + b) == 4.0
plum-examples/operators.plum ADDED
@@ -0,0 +1,68 @@
1
+ module operators
2
+ import std/Str
3
+ import std/Number
4
+
5
+ # Operators on non-primitive operands call methods: `a + b` is `a.add(b)`,
6
+ # `-a` is `a.neg()`, `a == b` is `a.eq(b)`, `a < b` is `a.lt(b)`.
7
+ enum Vec2 =
8
+ | Vec2(x: Float, y: Float)
9
+
10
+ fun toStr(self) -> Str =
11
+ "Vec2({self.x}, {self.y})"
12
+
13
+ fun add(self, o: Vec2) -> Vec2 =
14
+ Vec2(x: self.x + o.x, y: self.y + o.y)
15
+
16
+ fun mul(self, s: Float) -> Vec2 =
17
+ Vec2(x: self.x * s, y: self.y * s)
18
+
19
+ fun neg(self) -> Vec2 =
20
+ Vec2(x: 0.0 - self.x, y: 0.0 - self.y)
21
+
22
+ fun eq(self, o: Vec2) -> Bool =
23
+ self.x == o.x && self.y == o.y
24
+
25
+ fun lt(self, o: Vec2) -> Bool =
26
+ self.x < o.x
27
+
28
+ fun le(self, o: Vec2) -> Bool =
29
+ self.x <= o.x
30
+
31
+ enum Plain =
32
+ | Plain(v: Int)
33
+
34
+ fun double(a: T) -> T =
35
+ a + a
36
+
37
+ test "vec2_add_operator"
38
+ r := Vec2(x: 1.0, y: 2.0) + Vec2(x: 3.0, y: 4.0)
39
+ assert r.x == 4.0
40
+ assert r.y == 6.0
41
+
42
+ test "mul_scalar_rhs"
43
+ r := Vec2(x: 1.0, y: 2.0) * 2.0
44
+ assert r.x == 2.0
45
+ assert r.y == 4.0
46
+
47
+ test "neg"
48
+ r := -Vec2(x: 1.0, y: -2.0)
49
+ assert r.x == -1.0
50
+ assert r.y == 2.0
51
+
52
+ test "eq_and_neq"
53
+ a := Vec2(x: 1.0, y: 2.0)
54
+ assert a == Vec2(x: 1.0, y: 2.0)
55
+ assert a != Vec2(x: 1.0, y: 3.0)
56
+ assert !{a == Vec2(x: 0.0, y: 0.0)}
57
+
58
+ test "lt_ordering"
59
+ a := Vec2(x: 1.0, y: 0.0)
60
+ b := Vec2(x: 2.0, y: 0.0)
61
+ assert a < b
62
+ assert !{b < a}
63
+ assert a <= b
64
+
65
+ test "operators work through a generic"
66
+ d := double(Vec2(x: 1.0, y: 1.0))
67
+ assert d.x == 2.0
68
+ assert double(4) == 8
plum-wasm-codegen/src/lib.rs CHANGED
@@ -379,6 +379,8 @@ pub struct CompileCtx<'a> {
379
379
  pub inst_funcs: HashMap<usize, (u32, FuncSig)>,
380
380
  /// See `plum_checker::TypedInfo::call_inst`.
381
381
  pub call_inst: HashMap<(Option<usize>, usize, bool), usize>,
382
+ /// See `plum_checker::TypedInfo::op_calls`.
383
+ pub op_calls: HashMap<usize, std::rc::Rc<ast::FnCall>>,
382
384
  /// Every free (non-method) fn name, generic or not.
383
385
  pub known_fns: HashSet<String>,
384
386
  pub eg: EnumGenerics,
@@ -450,6 +452,7 @@ struct LocalCtx<'a> {
450
452
  func_sigs: &'a HashMap<String, FuncSig>,
451
453
  inst_funcs: &'a HashMap<usize, (u32, FuncSig)>,
452
454
  call_inst: &'a HashMap<(Option<usize>, usize, bool), usize>,
455
+ op_calls: &'a HashMap<usize, std::rc::Rc<ast::FnCall>>,
453
456
  known_fns: &'a HashSet<String>,
454
457
  /// Index of the unit being compiled (closures are keyed by it) and its instantiation.
455
458
  unit_idx: usize,
@@ -1641,6 +1644,7 @@ fn compileSourceInner(
1641
1644
  instantiations,
1642
1645
  inst_expr_types,
1643
1646
  call_inst,
1647
+ op_calls,
1644
1648
  ..
1645
1649
  } = typed;
1646
1650
  // Per-instantiation expression types, split out by instantiation id.
@@ -2157,6 +2161,7 @@ fn compileSourceInner(
2157
2161
  func_sigs,
2158
2162
  inst_funcs,
2159
2163
  call_inst,
2164
+ op_calls,
2160
2165
  known_fns,
2161
2166
  eg,
2162
2167
  methods,
@@ -3807,6 +3812,7 @@ fn compileFnBody(
3807
3812
  func_sigs: &ctx.func_sigs,
3808
3813
  inst_funcs: &ctx.inst_funcs,
3809
3814
  call_inst: &ctx.call_inst,
3815
+ op_calls: &ctx.op_calls,
3810
3816
  known_fns: &ctx.known_fns,
3811
3817
  unit_idx: unit.idx,
3812
3818
  unit_inst: unit.inst,
@@ -5498,6 +5504,14 @@ fn plumTypeFromValtypeHint(vt: ValType) -> PlumType {
5498
5504
  }
5499
5505
  }
5500
5506
 
5507
+ /// An operator on a non-primitive operand compiled as its desugared method call. The
5508
+ /// checker's `op_calls` entry is shared by every instantiation of a generic body, so
5509
+ /// the operand's type in THIS unit decides.
5510
+ fn isOpCall(expr: &ast::Expr, operand: &ast::Expr, ctx: &LocalCtx) -> bool {
5511
+ ctx.op_calls.contains_key(&(expr as *const ast::Expr as usize))
5512
+ && plum_checker::isOperatorOperand(&inferLocalType(operand, ctx))
5513
+ }
5514
+
5501
5515
  fn compileExpr(
5502
5516
  expr: &ast::Expr,
5503
5517
  body: &mut Vec<u8>,
@@ -5695,6 +5709,24 @@ fn compileExpr(
5695
5709
  }
5696
5710
  Instruction::End.encode(body);
5697
5711
  }
5712
+ ast::Expr::Unary(u) if isOpCall(expr, &u.operand, ctx) => {
5713
+ let call = &ctx.op_calls[&(expr as *const ast::Expr as usize)];
5714
+ let ty = inferLocalType(&u.operand, ctx);
5715
+ compileMethodCall(&u.operand, call, &ty, expr, false, body, ctx, state)?;
5716
+ }
5717
+ ast::Expr::Binary(b) if isOpCall(expr, &b.left, ctx) => {
5718
+ let call = &ctx.op_calls[&(expr as *const ast::Expr as usize)];
5719
+ let ty = inferLocalType(&b.left, ctx);
5720
+ compileMethodCall(&b.left, call, &ty, expr, false, body, ctx, state)?;
5721
+ }
5722
+ ast::Expr::Compare(c) if isOpCall(expr, &c.left, ctx) => {
5723
+ let call = &ctx.op_calls[&(expr as *const ast::Expr as usize)];
5724
+ let ty = inferLocalType(&c.left, ctx);
5725
+ compileMethodCall(&c.left, call, &ty, expr, false, body, ctx, state)?;
5726
+ if matches!(c.op, ast::CmpOp::Neq | ast::CmpOp::NotEq2) {
5727
+ Instruction::I32Eqz.encode(body);
5728
+ }
5729
+ }
5698
5730
  ast::Expr::Unary(u) => match u.op {
5699
5731
  ast::UnOp::Neg => {
5700
5732
  if matches!(inferLocalType(&u.operand, ctx), PlumType::TFloat) {
@@ -6885,6 +6917,7 @@ fn compileClosureBody(
6885
6917
  func_sigs: &ctx.func_sigs,
6886
6918
  inst_funcs: &ctx.inst_funcs,
6887
6919
  call_inst: &ctx.call_inst,
6920
+ op_calls: &ctx.op_calls,
6888
6921
  known_fns: &ctx.known_fns,
6889
6922
  unit_idx: unit.idx,
6890
6923
  unit_inst: unit.inst,