plum

#treesitter#compiler#wasm

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

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


287a0ad — Peter John 2026-11-01T16:23:21+05:30
fix: optional reassign/field-store coercion, reject optional==value, keep literal-overflow errors through the loader merge
plum-checker/src/lib.rs CHANGED
@@ -2192,10 +2192,10 @@ fn checkStmt(
2192
2192
  // point of `:=` existing at all (catches `x := 5`
2193
2193
  // then later `x = 3.14` at compile time instead of
2194
2194
  // silently miscompiling or silently changing type).
2195
+ let existing = lookup(env, name)
2196
+ .expect("already_declared just confirmed this succeeds");
2195
- match inferExpr(value, env, ctx) {
2197
+ match inferExprWith(value, env, ctx, Some(&existing)) {
2196
2198
  Ok(t) => {
2197
- let existing = lookup(env, name)
2198
- .expect("already_declared just confirmed this succeeds");
2199
2199
  if let Err(msg) = unifyArg(&existing, &t, value, ctx) {
2200
2200
  errors.push(CheckError::at(
2201
2201
  fn_pos.0,
@@ -2256,7 +2256,10 @@ fn checkStmt(
2256
2256
  }
2257
2257
  ast::AssignTarget::Field(object, field_name) => {
2258
2258
  let label = format!("{}.{}", describeTargetObject(object), field_name);
2259
+ let field_expected = inferExpr(object, env, ctx)
2260
+ .ok()
2261
+ .and_then(|o| fieldTypeOf(&o, field_name, ctx).ok());
2259
- match (inferExpr(object, env, ctx), inferExpr(value, env, ctx)) {
2262
+ match (inferExpr(object, env, ctx), inferExprWith(value, env, ctx, field_expected.as_ref())) {
2260
2263
  (Ok(obj_ty @ (PlumType::TNamed(_) | PlumType::TApp(..))), Ok(value_ty)) => {
2261
2264
  match fieldTypeOf(&obj_ty, field_name, ctx) {
2262
2265
  Ok(field_ty) => {
@@ -3894,6 +3897,14 @@ fn inferExprInner(
3894
3897
  if let (PlumType::TErrName(_), PlumType::TErrName(_)) = (&lt, &rt) {
3895
3898
  return Ok(PlumType::TBool);
3896
3899
  }
3900
+ for side in [&lt, &rt] {
3901
+ if matches!(side, PlumType::TErr(..)) {
3902
+ return Err(format!(
3903
+ "cannot compare {}: unwrap it first with `?:`, `!`, `catch` or `try` (only `== null` / `!= null` applies to an optional)",
3904
+ side
3905
+ ));
3906
+ }
3907
+ }
3897
3908
  let opn = match c.op {
3898
3909
  ast::CmpOp::Eq => Some(("==", "eq")),
3899
3910
  ast::CmpOp::Neq | ast::CmpOp::NotEq2 => Some(("!=", "eq")),
plum-checker/tests/checker_tests.rs CHANGED
@@ -2105,3 +2105,12 @@ fn pattern_if_needs_no_exhaustiveness() {
2105
2105
  let src = format!("{}fun f(s: Shape) -> Int =\n if Dot := s\n return 1\n 0\n", PATTERN_IF_ENUM);
2106
2106
  assert!(checkSource(&parseClean(&src)).is_ok());
2107
2107
  }
2108
+
2109
+ #[test]
2110
+ fn comparing_an_optional_or_error_union_with_a_value_is_rejected() {
2111
+ let msg = errText(checkSource(&parseClean("fun f(a: Int?) -> Bool =\n a == 3\n")));
2112
+ assert!(msg.contains("cannot compare"), "{msg}");
2113
+ let msg = errText(checkSource(&parseClean("fun f(a: error{Bad}!Int) -> Bool =\n a == 3\n")));
2114
+ assert!(msg.contains("cannot compare"), "{msg}");
2115
+ assert!(checkSource(&parseClean("fun f(a: Int?) -> Bool =\n a == null\n")).is_ok());
2116
+ }
plum-core/src/loader.rs CHANGED
@@ -45,6 +45,7 @@ pub fn loadAndMergeWithOverrides(
45
45
  let mut names: HashMap<String, PathBuf> = HashMap::new();
46
46
  let mut prims: Vec<PrimitiveDecl> = Vec::new();
47
47
 
48
+ MERGED_LIT_ERRORS.with(|e| e.borrow_mut().clear());
48
49
  visited.insert(entry_canon.clone());
49
50
  mergeItems(&entry_canon, &entry_source, &mut items, &mut names, &mut prims)?;
50
51
  for import in &entry_source.imports {
@@ -65,7 +66,7 @@ pub fn loadAndMergeWithOverrides(
65
66
  imports: entry_source.imports,
66
67
  items,
67
68
  topLevelOrder: Vec::new(),
68
- formErrors: Vec::new(),
69
+ formErrors: MERGED_LIT_ERRORS.with(|e| std::mem::take(&mut *e.borrow_mut())),
69
70
  formUses: Vec::new(),
70
71
  stars: Vec::new(),
71
72
  formLoops: false,
@@ -236,6 +237,11 @@ fn firstErrorNode(node: tree_sitter::Node) -> Option<tree_sitter::Node> {
236
237
  None
237
238
  }
238
239
 
240
+ thread_local! {
241
+ static MERGED_LIT_ERRORS: std::cell::RefCell<Vec<(usize, usize, String)>> =
242
+ std::cell::RefCell::new(Vec::new());
243
+ }
244
+
239
245
  fn mergeItems(
240
246
  file: &Path,
241
247
  source: &Source,
@@ -243,6 +249,8 @@ fn mergeItems(
243
249
  names: &mut HashMap<String, PathBuf>,
244
250
  prims: &mut Vec<PrimitiveDecl>,
245
251
  ) -> Result<(), String> {
252
+ // Parse-time literal errors (out-of-range integers) of every merged file.
253
+ MERGED_LIT_ERRORS.with(|e| e.borrow_mut().extend(source.formErrors.iter().cloned()));
246
254
  // Namespaces (a `primitive` whose name is no built-in) travel with the
247
255
  // merged program so the checker can enforce their rules.
248
256
  prims.extend(
plum-core/tests/loader_test.rs CHANGED
@@ -369,3 +369,14 @@ fn source_and_fns_carry_their_file_path() {
369
369
  assert!(fileOf("mainFn").ends_with("main.plum"), "{}", fileOf("mainFn"));
370
370
  assert!(fileOf("helperFn").ends_with("Util.plum"), "{}", fileOf("helperFn"));
371
371
  }
372
+
373
+ #[test]
374
+ fn outOfRangeLiteralInEntryOrImportSurvivesTheMerge() {
375
+ let dir = TempDir::new().unwrap();
376
+ let lib_path = dir.path().join("libs");
377
+ writeLibFile(&lib_path, "plum-std/Int", "module fixtures\n\nprimitive Int =\n ZERO = 0\n");
378
+ writeLibFile(&lib_path, "helper", "module fixtures\n\nimport std/Int\n\nfun big(u: Unit) -> Unit =\n x := 0x8000000000000000\n u\n");
379
+ let entry = writeLibFile(&lib_path, "main", "module fixtures\n\nimport helper\n\nfun main(u: Unit) -> Unit =\n big(u)\n");
380
+ let merged = loadAndMerge(&entry, &lib_path).expect("loadAndMerge failed");
381
+ assert!(merged.formErrors.iter().any(|e| e.2.contains("out of range")), "{:?}", merged.formErrors);
382
+ }
plum-examples/optionals.plum CHANGED
@@ -143,3 +143,22 @@ test "error_union_of_optional"
143
143
  test "int_min_literal"
144
144
  assert Int.MIN == -0x8000_0000_0000_0000
145
145
  assert sumBig() == -1
146
+
147
+ test "reassigning an optional local or field to null or a value"
148
+ o := maybeInt(true)
149
+ o = null
150
+ assert o == null
151
+ o = 4
152
+ assert o! == 4
153
+ b := maybeBox(true)
154
+ b = null
155
+ assert b == null
156
+ h := Holder(item: Box(label: "x"), count: 2)
157
+ h.count = null
158
+ h.item = null
159
+ assert h.count == null
160
+ assert h.item == null
161
+ h.count = 5
162
+ h.item = Box(label: "y")
163
+ assert h.count! == 5
164
+ assert h.item!.label == "y"
plum-std/Str.plum CHANGED
@@ -44,7 +44,7 @@ enum Str =
44
44
  # (ordinary 64-bit multiplication), which is fine — only the bit pattern
45
45
  # matters, not the numeric value itself.
46
46
  fun hash(self) -> Int =
47
- h := 0xcbf29ce484222325
47
+ h := -0x340d631b7bdddcdb # FNV offset basis 0xcbf29ce484222325 as a signed Int
48
48
  i := 0
49
49
  while i < self.length()
50
50
  h = {h ^ self.byteAt(i)} * 0x100000001b3
plum-wasm-codegen/src/lib.rs CHANGED
@@ -4682,7 +4682,9 @@ fn compileStmt(
4682
4682
  for (target, value) in a.targets.iter().zip(a.values.iter()) {
4683
4683
  match target {
4684
4684
  ast::AssignTarget::Var(name) => {
4685
+ // The coerced slot type: `o = null` / `o = 4` into an `Int?`
4686
+ // local stores the whole optional, not the bare value.
4685
- let vty = inferLocalType(value, ctx);
4687
+ let vty = slotType(value, ctx);
4686
4688
  compileExpr(value, body, ctx, state)?;
4687
4689
  let idx = ctx
4688
4690
  .locals
@@ -4700,7 +4702,7 @@ fn compileStmt(
4700
4702
  }
4701
4703
  ast::AssignTarget::Field(object, field_name) => {
4702
4704
  let src = StoreSrc {
4703
- ty: inferLocalType(value, ctx),
4705
+ ty: slotType(value, ctx),
4704
4706
  push: &|dest, body, ctx, state| compileExprInto(value, dest, body, ctx, state),
4705
4707
  };
4706
4708
  compileFieldStore(object, field_name, &src, body, ctx, state)?;