plum

#treesitter#compiler#wasm

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

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


d61ff9f — Peter John 2026-11-06T13:36:25+05:30
fip fun: a compile-time guarantee of in-place updates
compiler/checker/tests/checker/fip_test.rs ADDED
@@ -0,0 +1,176 @@
1
+ #![allow(non_snake_case)]
2
+ //! `fip fun`: every spread and `Array.with` written in the body must be proved in place; a
3
+ //! violation is a compile error at the exact span, from the driver, after the proof ran.
4
+ use plum_core::Diagnostic;
5
+ use plum_driver::PassOptions;
6
+ use plum_test_support::load;
7
+
8
+ const HEAD: &str = "import std/Int\nimport std/Array\n\nstruct Pt(px: Int, py: Int)\n\n";
9
+
10
+ fn run(name: &str, body: &str, opts: &PassOptions) -> Result<(), Vec<Diagnostic>> {
11
+ let source = load(name, &format!("{HEAD}{body}")).expect("loads");
12
+ plum_driver::check(&source, opts).map(|_| ())
13
+ }
14
+
15
+ /// 1-based line and column of the first `needle` in `HEAD + body`, and its end (exclusive).
16
+ fn span(body: &str, needle: &str) -> (usize, usize, usize, usize) {
17
+ let all = format!("{HEAD}{body}");
18
+ let at = all.find(needle).unwrap_or_else(|| panic!("no {needle:?}"));
19
+ let line = all[..at].matches('\n').count() + 1;
20
+ let col = at - all[..at].rfind('\n').map_or(0, |n| n + 1) + 1;
21
+ (line, col, line, col + needle.len())
22
+ }
23
+
24
+ fn errs(name: &str, body: &str) -> Vec<Diagnostic> {
25
+ run(name, body, &PassOptions::default()).expect_err("a fip violation")
26
+ }
27
+
28
+ /// One error, at exactly `needle`, whose message is `not in place: ` + `reason`.
29
+ fn expectAt(name: &str, body: &str, needle: &str, reason: &str) {
30
+ let es = errs(name, body);
31
+ assert_eq!(es.len(), 1, "{es:?}");
32
+ let e = &es[0];
33
+ assert_eq!((e.line, e.column, e.end_line, e.end_column), span(body, needle), "{e:?}");
34
+ assert_eq!(e.message, format!("not in place: {reason}"), "{e:?}");
35
+ }
36
+
37
+ #[test]
38
+ fn passing_forms_compile() {
39
+ let body = "\
40
+ fip fun plain(a: Int) -> Int = a + 1
41
+ fip fun fresh() -> Array[Int] = Array.filled(2, 0).with(0, 1)
42
+ fip fun mv(p: Pt) -> Pt = Pt(..p, px: p.px + 1)
43
+ fun useMv() -> Pt = mv(Pt(px: 1, py: 2))
44
+ ";
45
+ run("fip_ok", body, &PassOptions::default()).expect("compiles");
46
+ }
47
+
48
+ #[test]
49
+ fn alias_kept() {
50
+ let body = "fip fun upd(a: Array[Int]) -> Array[Int] =\n b := a\n a.with(0, 1)\n";
51
+ expectAt("fip_alias", body, "a.with(0, 1)", "`a` is also held by `b`");
52
+ }
53
+
54
+ #[test]
55
+ fn spread_alias_kept() {
56
+ let body = "fip fun upd(s: Pt) -> Pt =\n k := s\n Pt(..s, px: 1)\n";
57
+ expectAt("fip_spread_alias", body, "Pt(..s, px: 1)", "`s` is also held by `k`");
58
+ }
59
+
60
+ #[test]
61
+ fn stored_in_a_list() {
62
+ let body = "fip fun upd(a: Array[Int]) -> Array[Int] =\n xs := [a]\n a.with(0, 1)\n";
63
+ expectAt("fip_list", body, "a.with(0, 1)", "`a` is stored in field `head`");
64
+ }
65
+
66
+ #[test]
67
+ fn captured_by_a_closure() {
68
+ let body = "fip fun upd(a: Array[Int]) -> Array[Int] =\n g := || a.get(0)\n a.with(0, 1)\n";
69
+ expectAt("fip_capture", body, "a.with(0, 1)", "`a` is captured by a closure");
70
+ }
71
+
72
+ #[test]
73
+ fn read_after_the_update() {
74
+ let body = "fip fun upd(a: Array[Int]) -> Int =\n b := a.with(0, 1)\n a.get(0)\n";
75
+ expectAt("fip_after", body, "a.with(0, 1)", "`a` is read after the update");
76
+ }
77
+
78
+ #[test]
79
+ fn shared_by_another_caller() {
80
+ let body = "fip fun upd(a: Array[Int]) -> Array[Int] = a.with(0, 1)\nfun keep(x: Array[Int]) -> Int =\n y := upd(x)\n x.get(0)\n";
81
+ expectAt(
82
+ "fip_caller",
83
+ body,
84
+ "a.with(0, 1)",
85
+ "`a` may be shared by a caller: `x` is passed to `upd`, which may keep it or the caller reads it afterwards (call of `upd` from keep)",
86
+ );
87
+ }
88
+
89
+ #[test]
90
+ fn spread_shared_by_another_caller() {
91
+ let body = "fip fun upd(p: Pt) -> Pt = Pt(..p, px: 1)\nfun keep(x: Pt) -> Int =\n y := upd(x)\n y.px + x.px\n";
92
+ expectAt(
93
+ "fip_spread_caller",
94
+ body,
95
+ "Pt(..p, px: 1)",
96
+ "`p` may be shared by a caller: `x` is passed to `upd`, which may keep it or the caller reads it afterwards (call of `upd` from keep)",
97
+ );
98
+ }
99
+
100
+ #[test]
101
+ fn a_caller_that_hands_over_keeps_it_in_place() {
102
+ let body = "fip fun upd(p: Pt) -> Pt = Pt(..p, px: 1)\nfun keep(x: Pt) -> Pt = upd(x)\n";
103
+ run("fip_handover", body, &PassOptions::default()).expect("compiles");
104
+ }
105
+
106
+ #[test]
107
+ fn update_inside_a_closure() {
108
+ let body = "fip fun upd(a: Array[Int]) -> Array[Int] =\n g := || a.with(0, 1)\n g()\n";
109
+ expectAt("fip_in_closure", body, "a.with(0, 1)", "the update is inside a closure");
110
+ }
111
+
112
+ #[test]
113
+ fn spread_inside_a_closure_counts() {
114
+ let body = "fip fun upd(p: Pt) -> Pt =\n g := |n: Int| Pt(..p, px: n)\n g(1)\n";
115
+ expectAt("fip_spread_closure", body, "Pt(..p, px: n)", "`p` is captured by a closure");
116
+ }
117
+
118
+ #[test]
119
+ fn a_fip_method() {
120
+ let body = "\
121
+ struct Bx(items: Array[Int], n: Int) =
122
+ fip fun put(self, x: Int) -> Bx = Bx(..self, items: self.items.with(0, x))
123
+ fun peek(self) -> Int = self.items.get(0)
124
+ fun twice(b: Bx) -> Int =
125
+ c := b.put(1)
126
+ c.peek() + b.peek()
127
+ ";
128
+ let es = errs("fip_method", body);
129
+ let why = "`self` may be shared by a caller: `b` is the receiver of `put`, which may keep it or the caller reads it afterwards (call of `put` from twice)";
130
+ let want = [span(body, "Bx(..self, items: self.items.with(0, x))"), span(body, "self.items.with(0, x)")];
131
+ let got: Vec<_> = es.iter().map(|e| (e.line, e.column, e.end_line, e.end_column)).collect();
132
+ assert_eq!(got, want, "{es:?}");
133
+ assert!(es.iter().all(|e| e.message == format!("not in place: {why}")), "{es:?}");
134
+ }
135
+
136
+ #[test]
137
+ fn a_fip_method_handed_over_compiles() {
138
+ let body = "\
139
+ struct Bx(items: Array[Int], n: Int) =
140
+ fip fun put(self, x: Int) -> Bx = Bx(..self, items: self.items.with(0, x))
141
+ fun once(n: Int) -> Bx =
142
+ b := Bx(items: Array.filled(n, 0), n: n)
143
+ b = b.put(1)
144
+ b
145
+ ";
146
+ run("fip_method_ok", body, &PassOptions::default()).expect("compiles");
147
+ }
148
+
149
+ #[test]
150
+ fn an_inlined_callee_is_reported_at_the_fip_fun() {
151
+ // `helper` is small, so fusion builds its spread into `upd`; the spot is no longer the user's.
152
+ let body = "fun helper(s: Pt) -> Pt = Pt(..s, px: s.px + 1)\nfip fun upd(p: Pt) -> Int =\n q := helper(p)\n q.px + p.px\n";
153
+ let es = errs("fip_inlined", body);
154
+ assert_eq!(es.len(), 1, "{es:?}");
155
+ let (line, col, _, _) = span(body, "fip fun upd");
156
+ assert_eq!((es[0].line, es[0].column), (line, col), "{es:?}");
157
+ assert_eq!(
158
+ es[0].message,
159
+ "not in place: `p` is read after the spread (a spread that fusion built into `fip fun upd`; the exact spot is not kept)"
160
+ );
161
+ }
162
+
163
+ #[test]
164
+ fn a_fip_fun_is_not_inlined_away() {
165
+ // Were `upd` inlined into `keep` and left without callers, its own spread would look unshared.
166
+ let body = "fip fun upd(p: Pt) -> Pt = Pt(..p, px: 1)\nfun keep(x: Pt) -> Int =\n y := upd(x)\n y.px + x.px\n";
167
+ assert_eq!(errs("fip_not_inlined", body).len(), 1);
168
+ }
169
+
170
+ #[test]
171
+ fn not_enforced_without_the_proof() {
172
+ let body = "fip fun upd(a: Array[Int]) -> Array[Int] =\n b := a\n a.with(0, 1)\n";
173
+ run("fip_nofbip", body, &PassOptions { fbip: false, ..PassOptions::default() }).expect("--no-fbip: not enforced");
174
+ run("fip_nofusion", body, &PassOptions { fusion: false, ..PassOptions::default() }).expect("--no-fusion: not enforced");
175
+ assert_eq!(errs("fip_on", body).len(), 1);
176
+ }
compiler/checker/tests/checker/fusion_pipeline_test.rs CHANGED
@@ -124,7 +124,7 @@ fn explain_lists_unproved_uniqueness() {
124
124
  let mut copy = parse(src);
125
125
  let notes = fusionNotes(&mut copy);
126
126
  let n = notes.iter().find(|n| n.contains("not in place")).unwrap_or_else(|| panic!("{notes:?}"));
127
- assert!(n.contains("aliased"), "{n}");
127
+ assert!(n.contains("also held"), "{n}");
128
128
  }
129
129
 
130
130
  #[test]
compiler/checker/tests/checker/main.rs CHANGED
@@ -6,6 +6,7 @@ mod diagnostic_tests;
6
6
  mod io_primitive_tests;
7
7
  mod explicit_generics_tests;
8
8
  mod features;
9
+ mod fip_test;
9
10
  mod fusion_pipeline_test;
10
11
  mod inline_variants_tests;
11
12
  mod question_tests;
compiler/core/src/ast.rs CHANGED
@@ -543,6 +543,8 @@ pub struct Fn {
543
543
  /// `abstract fun m(self) -> T` (no body): an abstract struct method that every
544
544
  /// subtype must override.
545
545
  pub is_abstract: bool,
546
+ /// `fip fun`: every spread and `Array.with` in the body must be proved in place (driver check).
547
+ pub is_fip: bool,
546
548
  /// The fn declared `self` as its first parameter (an instance method). A
547
549
  /// fn nested in a type or `primitive` body without it is a static.
548
550
  pub has_self: bool,
compiler/core/src/ctors.rs CHANGED
@@ -484,6 +484,7 @@ pub fn lowerCtors(source: &mut Source) -> Vec<String> {
484
484
  is_test: false,
485
485
  is_override: false,
486
486
  is_abstract: false,
487
+ is_fip: false,
487
488
  has_self: true,
488
489
  params: vec![],
489
490
  returns: None,
@@ -503,6 +504,7 @@ pub fn lowerCtors(source: &mut Source) -> Vec<String> {
503
504
  is_test: false,
504
505
  is_override: false,
505
506
  is_abstract: false,
507
+ is_fip: false,
506
508
  has_self: false,
507
509
  params: nparams
508
510
  .iter()
compiler/core/src/drive.rs CHANGED
@@ -513,7 +513,8 @@ impl Driver {
513
513
  continue;
514
514
  }
515
515
  }
516
+ // A `fip fun` stays a call, so the uniqueness proof sees every caller of it.
516
- if f.is_extern || f.is_export {
517
+ if f.is_extern || f.is_export || f.is_fip {
517
518
  continue;
518
519
  }
519
520
  let k = keyOf(f);
@@ -1849,6 +1850,7 @@ impl Driver {
1849
1850
  is_test: false,
1850
1851
  is_override: false,
1851
1852
  is_abstract: false,
1853
+ is_fip: false,
1852
1854
  has_self: false,
1853
1855
  params,
1854
1856
  returns: cret.as_ref().map(|r| substType(r, &map)),
compiler/core/src/fip.rs ADDED
@@ -0,0 +1,98 @@
1
+ //! `fip fun`: a compile-time guarantee that the function's own updates run in place.
2
+ //!
3
+ //! Every spread `T(..x, ..)` and every `Array.with` call written in the body of a `fip fun`
4
+ //! (closures inside it included) must be proved in place by `uniqueness::markUnique`. Calls to
5
+ //! other functions are not checked: the guarantee is local to the body.
6
+ //!
7
+ //! The check runs on the program after fusion, because only there is the proof complete. Fusion
8
+ //! never inlines or fuses a `fip fun` (simplify and drive skip it), so every call of one stays
9
+ //! visible to the proof and the function is never removed; the flag lives on the `Fn`, so the
10
+ //! check cannot be skipped. A site whose span is not one the function had before fusion (a
11
+ //! callee inlined into its body) is reported at the `fip` function's own line instead, with a
12
+ //! note saying so.
13
+ use crate::ast::*;
14
+ use crate::diag::{DiagKind, Diagnostic};
15
+ use crate::uniqueness::UniqueReport;
16
+ use crate::visit::walkBlockRef;
17
+ use std::collections::{HashMap, HashSet};
18
+
19
+ type SpanKey = (usize, usize, usize, usize);
20
+
21
+ fn key(s: &Span) -> SpanKey {
22
+ (s.startLine, s.startColumn, s.endLine, s.endColumn)
23
+ }
24
+
25
+ /// The spans of the spreads and `.with` calls each `fip fun` had before fusion, by the fn's
26
+ /// own position (the rewrites keep a fn's line and column).
27
+ #[derive(Default)]
28
+ pub struct FipOrigins(HashMap<(String, usize, usize), HashSet<SpanKey>>);
29
+
30
+ impl FipOrigins {
31
+ pub fn isEmpty(&self) -> bool {
32
+ self.0.is_empty()
33
+ }
34
+ }
35
+
36
+ fn isCheckedSite(e: &Expr) -> bool {
37
+ match &e.kind {
38
+ ExprKind::ClassCall(c) => c.spread.is_some(),
39
+ ExprKind::Attribute(a) => matches!(&a.attr, AttrKind::Method(c) if c.name == "with"),
40
+ _ => false,
41
+ }
42
+ }
43
+
44
+ /// Records the `fip fun`s of `src` and the spans of their checked sites.
45
+ pub fn origins(src: &Source) -> FipOrigins {
46
+ let mut out = FipOrigins::default();
47
+ for item in &src.items {
48
+ let Item::Fn(f) = item else { continue };
49
+ if !f.is_fip {
50
+ continue;
51
+ }
52
+ let mut spans = HashSet::new();
53
+ let mut note = |e: &Expr| {
54
+ if isCheckedSite(e) {
55
+ spans.insert(key(&e.span));
56
+ }
57
+ };
58
+ match &f.body {
59
+ FnBody::Expr(e) => crate::visit::walkRef(e, &mut note),
60
+ FnBody::Block(b) => walkBlockRef(b, &mut note),
61
+ FnBody::Extern => {}
62
+ }
63
+ out.0.insert((f.file.clone(), f.line, f.column), spans);
64
+ }
65
+ out
66
+ }
67
+
68
+ /// One error per spread or `Array.with` of a `fip fun` that the proof left a copy.
69
+ pub fn diagnostics(src: &Source, report: &UniqueReport, origins: &FipOrigins) -> Vec<Diagnostic> {
70
+ let mut out: Vec<Diagnostic> = Vec::new();
71
+ for v in &report.fipSites {
72
+ let Err(why) = &v.result else { continue };
73
+ let Some(Item::Fn(f)) = src.items.get(v.itemIdx) else { continue };
74
+ let known = origins.0.get(&(f.file.clone(), f.line, f.column));
75
+ let d = match known {
76
+ Some(spans) if spans.contains(&key(&v.span)) => Diagnostic::new(
77
+ DiagKind::Type,
78
+ f.file.clone(),
79
+ v.span.startLine,
80
+ v.span.startColumn,
81
+ format!("not in place: {why}"),
82
+ )
83
+ .with_end(v.span.endLine, v.span.endColumn),
84
+ _ => Diagnostic::new(
85
+ DiagKind::Type,
86
+ f.file.clone(),
87
+ f.line,
88
+ f.column,
89
+ format!("not in place: {why} (a {} that fusion built into `fip fun {}`; the exact spot is not kept)", v.what, f.name),
90
+ ),
91
+ };
92
+ if !out.iter().any(|o| o.file == d.file && o.line == d.line && o.column == d.column && o.message == d.message) {
93
+ out.push(d);
94
+ }
95
+ }
96
+ out.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
97
+ out
98
+ }
compiler/core/src/lib.rs CHANGED
@@ -12,6 +12,7 @@ pub mod drive;
12
12
  pub mod embeds;
13
13
  pub mod enum_checks;
14
14
  pub mod formatter;
15
+ pub mod fip;
15
16
  pub mod forms;
16
17
  pub mod generics;
17
18
  pub mod layout;
compiler/core/src/parser.rs CHANGED
@@ -481,6 +481,7 @@ impl<'a> AstParser<'a> {
481
481
  let is_export = node.child_by_field_name("exportKw").is_some();
482
482
  let is_override = node.child_by_field_name("overrideKw").is_some();
483
483
  let is_abstract = node.child_by_field_name("abstractKw").is_some();
484
+ let is_fip = node.child_by_field_name("fipKw").is_some();
484
485
 
485
486
  let generics: Vec<String> = node
486
487
  .child_by_field_name("generics")
@@ -505,6 +506,7 @@ impl<'a> AstParser<'a> {
505
506
  is_test: false,
506
507
  is_override,
507
508
  is_abstract,
509
+ is_fip,
508
510
  has_self: !self.childrenOfKind(node, "self").is_empty(),
509
511
  params,
510
512
  returns,
compiler/core/src/simplify.rs CHANGED
@@ -297,6 +297,7 @@ impl<'a> Cx<'a> {
297
297
  && !self.recursive.contains(name)
298
298
  && !self.stack.iter().any(|s| s == name)
299
299
  && !f.is_extern
300
+ && !f.is_fip
300
301
  && !f.is_export
301
302
  && !f.has_self
302
303
  && f.type_param.is_none()
compiler/core/src/tests_lower.rs CHANGED
@@ -116,6 +116,7 @@ fn desugarTestsToFns(source: &crate::ast::Source) -> (crate::ast::Source, Vec<(S
116
116
  is_test: true,
117
117
  is_override: false,
118
118
  is_abstract: false,
119
+ is_fip: false,
119
120
  has_self: true,
120
121
  params: ioParam.clone(),
121
122
  returns: Some(crate::ast::Type {
compiler/core/src/uniqueness.rs CHANGED
@@ -47,6 +47,20 @@ use std::collections::{HashMap, HashSet};
47
47
  pub struct UniqueReport {
48
48
  pub proved: Vec<String>,
49
49
  pub unproved: Vec<(String, String)>,
50
+ /// A verdict per spread and per `Array.with` inside a `fip fun` (empty when there is none).
51
+ pub fipSites: Vec<SiteVerdict>,
52
+ }
53
+
54
+ /// One spread or `Array.with` call of a `fip fun`, and whether it was proved in place.
55
+ #[derive(Debug, Clone)]
56
+ pub struct SiteVerdict {
57
+ /// Index of the fn in `Source.items`.
58
+ pub itemIdx: usize,
59
+ pub span: Span,
60
+ /// `"spread"` or `"Array.with"`.
61
+ pub what: &'static str,
62
+ /// `Err` carries the reason in plain words.
63
+ pub result: Result<(), String>,
50
64
  }
51
65
 
52
66
  // ---------- traversal ----------
@@ -432,6 +446,7 @@ struct SpreadWith {
432
446
 
433
447
  struct SpreadSite {
434
448
  unit: usize,
449
+ node: NodeId,
435
450
  typeName: String,
436
451
  var: Option<String>,
437
452
  given: Vec<String>,
@@ -646,14 +661,14 @@ impl An {
646
661
  let param = u.params.iter().position(|p| p.name == v);
647
662
  if let Some(i) = param {
648
663
  if !u.free && !u.method {
649
- return Err(format!("{v} is a parameter of an exported or iterator fn; callers are not tracked"));
664
+ return Err(format!("`{v}` is a parameter of an exported or iterator fn; its callers are not tracked"));
650
665
  }
651
666
  if !u.params[i].plain {
652
- return Err(format!("{v} is a fn-typed parameter"));
667
+ return Err(format!("`{v}` is a fn-typed parameter"));
653
668
  }
654
669
  if !entryUnique && !self.paramUnique(unit, i) {
655
670
  let why = self.upWhy.borrow().get(&(unit, i)).cloned();
656
- return Err(why.unwrap_or_else(|| format!("a caller of {} may pass a shared {v}", u.name)));
671
+ return Err(why.unwrap_or_else(|| format!("a caller of `{}` may pass a shared `{v}`", u.name)));
657
672
  }
658
673
  }
659
674
  let mut s = Scan { an: self, v, strict, boundDepth: if param.is_some() { Some(0) } else { None }, err: None };
@@ -662,7 +677,7 @@ impl An {
662
677
  return Err(e);
663
678
  }
664
679
  if s.boundDepth.is_none() {
665
- return Err(format!("{v} is not a local or parameter (global or field)"));
680
+ return Err(format!("`{v}` is not a local or parameter (a global or a field)"));
666
681
  }
667
682
  Ok(())
668
683
  }
@@ -673,7 +688,7 @@ impl An {
673
688
  /// `x.f = x.f.with(..)`, or `T(..s, f: s.f.with(..))` of a unique `s`.
674
689
  fn withOk(&self, site: &Site) -> Result<(), String> {
675
690
  if site.inClosure {
676
- return Err("inside a closure".to_string());
691
+ return Err("the update is inside a closure".to_string());
677
692
  }
678
693
  let Some(Some(recv)) = site.args.first() else { return Err("no receiver".to_string()) };
679
694
  if self.fresh(recv) {
@@ -687,27 +702,27 @@ impl An {
687
702
  if consumed {
688
703
  Ok(())
689
704
  } else {
690
- Err(format!("{v} is used after the write"))
705
+ Err(format!("`{v}` is read after the update"))
691
706
  }
692
707
  }
693
708
  ExprKind::Attribute(a) => {
694
- let AttrKind::Field(f) = &a.attr else { return Err("receiver is a method result".to_string()) };
709
+ let AttrKind::Field(f) = &a.attr else { return Err("the receiver is a method result".to_string()) };
695
710
  match self.spreadWith.get(&site.node) {
696
711
  Some(sw) => {
697
712
  if !sw.clean {
698
- return Err(format!("another field of the spread reads {f}"));
713
+ return Err(format!("another field of the spread reads `{f}`"));
699
714
  }
700
715
  self.varOk(sw.unit, &sw.var)?;
701
716
  if self.uf.borrow().contains(&sw.field) {
702
717
  Ok(())
703
718
  } else {
704
- Err(format!("field {f} may be shared"))
719
+ Err(format!("field `{f}` may be shared"))
705
720
  }
706
721
  }
707
722
  None => self.argOk(site, recv),
708
723
  }
709
724
  }
710
- _ => Err("receiver may be shared".to_string()),
725
+ _ => Err("the array may be held elsewhere".to_string()),
711
726
  }
712
727
  }
713
728
 
@@ -728,28 +743,28 @@ impl An {
728
743
  return Ok(());
729
744
  }
730
745
  if site.inClosure {
731
- return Err("argument inside a closure".to_string());
746
+ return Err("an argument is passed from inside a closure".to_string());
732
747
  }
733
748
  match &e.kind {
734
749
  ExprKind::Var(_) | ExprKind::Self_ => self.varOk(site.caller, varName(e).unwrap()),
735
750
  ExprKind::Attribute(a) => {
736
751
  let AttrKind::Field(f) = &a.attr else {
737
- return Err("argument is a method call result that may be shared".to_string());
752
+ return Err("an argument is a method call result that may be shared".to_string());
738
753
  };
739
754
  let ok = match &site.assignBack {
740
755
  Some(AssignTarget::Field(tobj, tf)) => tf == f && **tobj == a.object,
741
756
  _ => false,
742
757
  };
743
758
  if !ok {
744
- return Err(format!("field {f} passed without being written back"));
759
+ return Err(format!("field `{f}` is passed on without being written back"));
745
760
  }
746
761
  if self.uf.borrow().contains(f) {
747
762
  Ok(())
748
763
  } else {
749
- Err(format!("field {f} may be shared"))
764
+ Err(format!("field `{f}` may be shared"))
750
765
  }
751
766
  }
752
- _ => Err("argument may be shared".to_string()),
767
+ _ => Err("an argument may be shared".to_string()),
753
768
  }
754
769
  }
755
770
  }
@@ -814,14 +829,14 @@ impl<'a> Scan<'a> {
814
829
  }
815
830
 
816
831
  fn escapes(&self) -> String {
817
- format!("{} escapes while still live", self.v)
832
+ format!("`{}` is still used after the update", self.v)
818
833
  }
819
834
 
820
835
  fn checkPatterns(&mut self, ps: &[CasePattern]) {
821
836
  let mut names = vec![];
822
837
  ps.iter().for_each(|p| crate::visit::patternBinders(p, &mut names));
823
838
  if names.iter().any(|n| n == self.v) {
824
- self.fail(format!("{} rebound by a pattern", self.v));
839
+ self.fail(format!("`{}` is rebound by a pattern", self.v));
825
840
  }
826
841
  }
827
842
 
@@ -841,27 +856,27 @@ impl<'a> Scan<'a> {
841
856
  match self.boundDepth {
842
857
  None => self.boundDepth = Some(depth),
843
858
  Some(d) if d == depth => {}
844
- Some(_) => self.fail(format!("{v} shadowed in an inner scope")),
859
+ Some(_) => self.fail(format!("`{v}` is shadowed in an inner scope")),
845
860
  }
846
861
  }
847
862
  if !self.bindingOk(val, a.declare) {
848
- self.fail(format!("{v} bound to a value that may be shared"));
863
+ self.fail(format!("`{v}` is bound to a value that may be shared"));
849
864
  } else {
850
865
  self.expr(val, false, true);
851
866
  }
852
867
  }
853
- AssignTarget::Var(n) => self.operand(val, format!("{v} aliased by {n}")),
868
+ AssignTarget::Var(n) => self.operand(val, format!("`{v}` is also held by `{n}`")),
854
869
  AssignTarget::Field(o, f) => {
855
870
  if !self.bare(o) {
856
871
  self.expr(o, false, false);
857
872
  }
858
- self.operand(val, format!("{v} stored in field {f}"));
873
+ self.operand(val, format!("`{v}` is stored in field `{f}`"));
859
874
  }
860
875
  }
861
876
  } else {
862
877
  for t in &a.targets {
863
878
  match t {
864
- AssignTarget::Var(n) if *n == v => self.fail(format!("{v} rebound in a multi-assign")),
879
+ AssignTarget::Var(n) if *n == v => self.fail(format!("`{v}` is rebound in a multi-assign")),
865
880
  AssignTarget::Var(_) => {}
866
881
  AssignTarget::Field(o, _) => {
867
882
  if !self.bare(o) {
@@ -871,7 +886,7 @@ impl<'a> Scan<'a> {
871
886
  }
872
887
  }
873
888
  for val in &a.values {
874
- self.operand(val, format!("{v} escapes via a multi-assign"));
889
+ self.operand(val, format!("`{v}` is also held through a multi-assign"));
875
890
  }
876
891
  }
877
892
  }
@@ -894,7 +909,7 @@ impl<'a> Scan<'a> {
894
909
  for (k, sub) in m.subjects.iter().enumerate() {
895
910
  if self.bare(sub) {
896
911
  if m.cases.iter().any(|c| matches!(c.patterns.get(k), Some(CasePattern::Name(_)))) {
897
- self.fail(format!("{v} aliased by a pattern binding"));
912
+ self.fail(format!("`{v}` is also held by a pattern binding"));
898
913
  }
899
914
  } else {
900
915
  self.expr(sub, false, false);
@@ -927,7 +942,7 @@ impl<'a> Scan<'a> {
927
942
  && (!ab || self.resultOk(g))
928
943
  });
929
944
  if !ok {
930
- self.fail(format!("{v} passed to {} without being dead afterwards", c.name));
945
+ self.fail(format!("`{v}` is passed to `{}`, which may keep it or the caller reads it afterwards", c.name));
931
946
  }
932
947
  } else {
933
948
  self.expr(e, false, false);
@@ -954,7 +969,7 @@ impl<'a> Scan<'a> {
954
969
  let readOnly = recvBare && !cands.is_empty() && cands.iter().all(|g| self.an.ro.contains(g));
955
970
  if recvBare {
956
971
  if !readOnly && !handOver(self, None) {
957
- self.fail(format!("{v} passed as self to {} without being dead afterwards", c.name));
972
+ self.fail(format!("`{v}` is the receiver of `{}`, which may keep it or the caller reads it afterwards", c.name));
958
973
  }
959
974
  } else {
960
975
  self.expr(&a.object, false, false);
@@ -963,7 +978,7 @@ impl<'a> Scan<'a> {
963
978
  let e = argExpr(x);
964
979
  if self.bare(e) {
965
980
  if !handOver(self, Some(k)) {
966
- self.fail(format!("{v} passed to method {} without being dead afterwards", c.name));
981
+ self.fail(format!("`{v}` is passed to method `{}`, which may keep it or the caller reads it afterwards", c.name));
967
982
  }
968
983
  } else {
969
984
  self.expr(e, false, false);
@@ -996,14 +1011,14 @@ impl<'a> Scan<'a> {
996
1011
  match &cc.spread {
997
1012
  Some(s) if self.bare(s) => {
998
1013
  if !(tail || ab) {
999
- self.fail(format!("{v} used after its spread"));
1014
+ self.fail(format!("`{v}` is read after the spread"));
1000
1015
  }
1001
1016
  }
1002
1017
  Some(s) => self.expr(s, false, false),
1003
1018
  None => {}
1004
1019
  }
1005
1020
  for fa in &cc.fields {
1006
- self.operand(&fa.value, format!("{v} stored in field {}", fa.name));
1021
+ self.operand(&fa.value, format!("`{v}` is stored in field `{}`", fa.name));
1007
1022
  }
1008
1023
  }
1009
1024
  ExprKind::Attribute(a) => match &a.attr {
@@ -1025,7 +1040,7 @@ impl<'a> Scan<'a> {
1025
1040
  true
1026
1041
  });
1027
1042
  if found {
1028
- self.fail(format!("{v} captured by a closure"));
1043
+ self.fail(format!("`{v}` is captured by a closure"));
1029
1044
  }
1030
1045
  }
1031
1046
  ExprKind::Binary(b) => {
@@ -1076,19 +1091,26 @@ pub fn markUnique(src: &mut Source) -> UniqueReport {
1076
1091
  solve(&an);
1077
1092
  // Report and mark.
1078
1093
  let mut report = UniqueReport::default();
1094
+ let isFip = |ui: usize| matches!(&src.items[an.units[ui].itemIdx], Item::Fn(f) if f.is_fip);
1095
+ let mut verdicts: Vec<(usize, NodeId, &'static str, Result<(), String>)> = Vec::new();
1079
1096
  let mut okVars: Vec<HashSet<String>> = vec![HashSet::new(); an.units.len()];
1080
1097
  for (ui, u) in an.units.iter().enumerate() {
1081
1098
  let label = format!("{}:{}", u.label, lineOf(src, u));
1082
1099
  for s in an.spreads.iter().filter(|s| s.unit == ui) {
1083
- match &s.var {
1100
+ let verdict = match &s.var {
1084
- None => report.unproved.push((label.clone(), "spread source is not a plain variable".into())),
1101
+ None => Err("the spread source is not a plain variable".to_string()),
1085
- Some(v) => match an.varOk(ui, v) {
1102
+ Some(v) => an.varOk(ui, v),
1103
+ };
1104
+ match (&s.var, &verdict) {
1086
- Ok(()) => {
1105
+ (Some(v), Ok(())) => {
1087
- report.proved.push(label.clone());
1106
+ report.proved.push(label.clone());
1088
- okVars[ui].insert(v.clone());
1107
+ okVars[ui].insert(v.clone());
1089
- }
1108
+ }
1090
- Err(r) => report.unproved.push((label.clone(), r)),
1109
+ (_, Err(r)) => report.unproved.push((label.clone(), r.clone())),
1110
+ _ => {}
1091
- },
1111
+ }
1112
+ if isFip(ui) {
1113
+ verdicts.push((ui, s.node, "spread", verdict));
1092
1114
  }
1093
1115
  }
1094
1116
  }
@@ -1100,12 +1122,16 @@ pub fn markUnique(src: &mut Source) -> UniqueReport {
1100
1122
  continue;
1101
1123
  }
1102
1124
  let label = format!("{}:{} (array write)", u.label, lineOf(src, u));
1103
- match an.withOk(site) {
1125
+ let verdict = an.withOk(site);
1126
+ match &verdict {
1104
1127
  Ok(()) => {
1105
1128
  withMarks.insert(site.node);
1106
1129
  report.proved.push(label);
1107
1130
  }
1108
- Err(r) => report.unproved.push((label, r)),
1131
+ Err(r) => report.unproved.push((label, r.clone())),
1132
+ }
1133
+ if isFip(site.caller) {
1134
+ verdicts.push((site.caller, site.node, "Array.with", verdict));
1109
1135
  }
1110
1136
  }
1111
1137
  // Mutable pass.
@@ -1114,7 +1140,9 @@ pub fn markUnique(src: &mut Source) -> UniqueReport {
1114
1140
  continue;
1115
1141
  }
1116
1142
  let ok = &okVars[ui];
1143
+ let mut spans: HashMap<NodeId, Span> = HashMap::new();
1117
1144
  let mut mark = |e: &mut Expr| {
1145
+ spans.insert(e.id, e.span);
1118
1146
  if withMarks.contains(&e.id) {
1119
1147
  if let ExprKind::Attribute(a) = &mut e.kind {
1120
1148
  if let AttrKind::Method(c) = &mut a.attr {
@@ -1141,6 +1169,10 @@ pub fn markUnique(src: &mut Source) -> UniqueReport {
1141
1169
  Item::Test(t) => mutBlock(&mut t.body, &mut mark),
1142
1170
  _ => {}
1143
1171
  }
1172
+ for (_, node, what, result) in verdicts.iter().filter(|v| v.0 == ui) {
1173
+ let span = spans.get(node).copied().unwrap_or_default();
1174
+ report.fipSites.push(SiteVerdict { itemIdx: u.itemIdx, span, what, result: result.clone() });
1175
+ }
1144
1176
  }
1145
1177
  report
1146
1178
  }
@@ -1702,6 +1734,7 @@ fn collectFacts(an: &mut An) {
1702
1734
  if let Some(s) = &cc.spread {
1703
1735
  spreads.push(SpreadSite {
1704
1736
  unit: ui,
1737
+ node: e.id,
1705
1738
  typeName: cc.type_name.clone(),
1706
1739
  var: varName(s).map(|v| v.to_string()),
1707
1740
  given,
@@ -1907,7 +1940,7 @@ fn solve(an: &An) {
1907
1940
  Err(m) => {
1908
1941
  let from = &an.units[site.caller].label;
1909
1942
  if why.is_none() {
1910
- why = Some(format!("{} (in a call of {} from {})", m, an.units[g].name, from));
1943
+ why = Some(format!("`{}` may be shared by a caller: {} (call of `{}` from {})", an.units[g].params[j].name, m, an.units[g].name, from));
1911
1944
  }
1912
1945
  }
1913
1946
  Ok(()) => okFrom = okFrom.or(Some(site.caller)),
compiler/core/tests/core/parser_test.rs CHANGED
@@ -825,6 +825,22 @@ fn abstractFunSetsFlag() {
825
825
  assert!(fns.iter().any(|f| f.name == "other" && !f.is_abstract));
826
826
  }
827
827
 
828
+ #[test]
829
+ fn fipFunSetsFlag() {
830
+ let s = parseRaw("fip fun a1() -> Int = 1\nfun b1() -> Int = 2\nstruct Sq(x: Int) =\n override fip fun d(self) -> Int = self.x\n");
831
+ let fns: Vec<&Fn> = s.items.iter().filter_map(|i| if let Item::Fn(f) = i { Some(f) } else { None }).collect();
832
+ assert!(fns.iter().any(|f| f.name == "a1" && f.is_fip && !f.is_override));
833
+ assert!(fns.iter().any(|f| f.name == "b1" && !f.is_fip));
834
+ assert!(fns.iter().any(|f| f.name == "d" && f.is_fip && f.is_override));
835
+ }
836
+
837
+ #[test]
838
+ fn fipStaysAnOrdinaryName() {
839
+ // a variable, a field and a field read called `fip` still parse
840
+ let s = parseRaw("struct Hold(fip: Int) =\n fun get(self) -> Int = self.fip\nfun main() -> Int =\n fip := 3\n fip + 1\n");
841
+ assert!(s.items.iter().all(|i| !matches!(i, Item::Fn(f) if f.is_fip)));
842
+ }
843
+
828
844
  // `try g()`, `g() catch 0`, `null` and `error.Bad` are not listed: they are now ordinary names, so they read as separate
829
845
  // expressions or a field access and fail in the checker (undefined name), not the parser.
830
846
  #[test]
compiler/core/tests/core/uniqueness_test.rs CHANGED
@@ -85,7 +85,7 @@ fn aliased_by_local_not_unique() {
85
85
  let s = format!("{PRE}fun bad(s: Pt) -> Pt =\n k := s\n Pt(..s, x: 1)\n");
86
86
  let (src, r) = run(&s);
87
87
  assert!(!proved(&r, "bad:3"));
88
- assert_eq!(reason(&r, "bad:3"), "s aliased by k");
88
+ assert_eq!(reason(&r, "bad:3"), "`s` is also held by `k`");
89
89
  assert!(!anyUnique(&src, "bad"));
90
90
  }
91
91
 
compiler/driver/src/lib.rs CHANGED
@@ -7,6 +7,7 @@ use plum_checker::{checkOnce, checkProgram, TypedInfo};
7
7
  use plum_core::ast::Source;
8
8
  use plum_core::closure_spec::Hints;
9
9
  use plum_core::diag::Diagnostic;
10
+ use std::cell::RefCell;
10
11
  use std::rc::Rc;
11
12
 
12
13
  /// Which passes run, and whether dropped or applied rewrites are explained on stderr.
@@ -46,6 +47,8 @@ pub struct Env<'a> {
46
47
  pub fbip: bool,
47
48
  /// Receiver types of method calls, read off the checked program (see `closureHints`).
48
49
  pub hints: &'a Hints,
50
+ /// Errors a pass found in the program it rewrote (`fip fun` sites that stayed copies).
51
+ pub errors: &'a RefCell<Vec<Diagnostic>>,
49
52
  }
50
53
 
51
54
  /// One rewrite pass: changes the program in place, returns whether anything changed.
@@ -68,6 +71,14 @@ pub fn fusionNotes(copy: &mut Source) -> Vec<String> {
68
71
 
69
72
  /// `fusionNotes`; with `fbip` off, uniqueness and cell reuse do not run, so nothing is updated in place.
70
73
  pub fn fusionNotesWith(copy: &mut Source, fbip: bool) -> Vec<String> {
74
+ fusionRun(copy, fbip).0
75
+ }
76
+
77
+ /// `fusionNotesWith`, and the errors of the `fip fun`s whose spreads or `Array.with` calls the
78
+ /// proof left as copies (see `plum_core::fip`). Needs `fbip`; without it nothing is enforced.
79
+ pub fn fusionRun(copy: &mut Source, fbip: bool) -> (Vec<String>, Vec<Diagnostic>) {
80
+ let origins = plum_core::fip::origins(copy);
81
+ let mut errors = Vec::new();
71
82
  let pure = plum_core::purity::pureFns(copy);
72
83
  let mut notes = plum_core::simplify::simplify(copy, &pure);
73
84
  notes.extend(plum_core::drive::drive(copy, &pure));
@@ -75,15 +86,17 @@ pub fn fusionNotesWith(copy: &mut Source, fbip: bool) -> Vec<String> {
75
86
  let rep = plum_core::uniqueness::markUnique(copy);
76
87
  notes.extend(rep.proved.iter().map(|l| format!("unique spread at {l}")));
77
88
  notes.extend(rep.unproved.iter().map(|(l, why)| format!("uniqueness: not in place {l}: {why}")));
89
+ errors = plum_core::fip::diagnostics(copy, &rep, &origins);
78
90
  notes.retain(|n| !n.is_empty());
79
91
  notes.extend(plum_core::reuse::markReuse(copy));
80
92
  }
81
93
  notes.retain(|n| !n.is_empty());
82
- notes
94
+ (notes, errors)
83
95
  }
84
96
 
85
97
  fn fusion(copy: &mut Source, env: &Env) -> bool {
86
- let notes = fusionNotesWith(copy, env.fbip);
98
+ let (notes, errors) = fusionRun(copy, env.fbip);
99
+ env.errors.borrow_mut().extend(errors);
87
100
  if env.explain {
88
101
  for n in &notes {
89
102
  eprintln!("fusion: {n}");
@@ -110,6 +123,8 @@ struct State {
110
123
  pre: Source,
111
124
  result: Result<(), Vec<Diagnostic>>,
112
125
  info: TypedInfo,
126
+ /// `fip fun` violations, kept even when the rewrite that found them is dropped.
127
+ fip: Vec<Diagnostic>,
113
128
  }
114
129
 
115
130
  /// Checks `source`, then runs the enabled passes of `PASSES` in order, each adopted only if
@@ -122,8 +137,11 @@ pub fn check(source: &Source, opts: &PassOptions) -> Result<TypedInfo, Vec<Diagn
122
137
  /// `check` with an explicit pass list.
123
138
  pub fn check_with(source: &Source, opts: &PassOptions, passes: &[Pass]) -> Result<TypedInfo, Vec<Diagnostic>> {
124
139
  let (result, info) = checkProgram(source);
125
- let mut st = State { pre: preUnionSource(source), result, info };
140
+ let mut st = State { pre: preUnionSource(source), result, info, fip: Vec::new() };
126
141
  if st.result.is_ok() {
142
+ if opts.explain && !(opts.fusion && opts.fbip) && !plum_core::fip::origins(&st.pre).isEmpty() {
143
+ eprintln!("fusion: fip fun is not enforced: it needs the uniqueness proof, which --no-fusion and --no-fbip switch off");
144
+ }
127
145
  for pass in passes.iter().filter(|p| (p.enabled)(opts)) {
128
146
  runPass(pass, source, opts, &mut st);
129
147
  }
@@ -132,6 +150,9 @@ pub fn check_with(source: &Source, opts: &PassOptions, passes: &[Pass]) -> Resul
132
150
  if let Some(lowered) = st.info.lowered.as_mut().and_then(Rc::get_mut) {
133
151
  plum_core::tail::markTailCalls(lowered);
134
152
  }
153
+ if st.result.is_ok() && !st.fip.is_empty() {
154
+ return Err(st.fip);
155
+ }
135
156
  st.result.map(|()| st.info)
136
157
  }
137
158
 
@@ -148,7 +169,8 @@ fn runPass(pass: &Pass, original: &Source, opts: &PassOptions, st: &mut State) {
148
169
  } else {
149
170
  Hints::new()
150
171
  };
172
+ let errors = RefCell::new(Vec::new());
151
- let env = Env { explain: opts.explain, fbip: opts.fbip, hints: &hints };
173
+ let env = Env { explain: opts.explain, fbip: opts.fbip, hints: &hints, errors: &errors };
152
174
  let mut copy = match pass.target {
153
175
  Target::Pre => st.pre.clone(),
154
176
  Target::Lowered => match st.info.lowered.as_ref() {
@@ -156,7 +178,13 @@ fn runPass(pass: &Pass, original: &Source, opts: &PassOptions, st: &mut State) {
156
178
  None => return,
157
179
  },
158
180
  };
159
- if !(pass.rewrite)(&mut copy, &env) {
181
+ let changed = (pass.rewrite)(&mut copy, &env);
182
+ for d in errors.into_inner() {
183
+ if !st.fip.contains(&d) {
184
+ st.fip.push(d);
185
+ }
186
+ }
187
+ if !changed {
160
188
  return;
161
189
  }
162
190
  // A pass builds nodes with no id and copies code; number the new ones, and in a debug
compiler/lsp/src/completion.rs CHANGED
@@ -647,6 +647,7 @@ mod tests {
647
647
  is_test: false,
648
648
  is_override: false,
649
649
  is_abstract: false,
650
+ is_fip: false,
650
651
  has_self: false,
651
652
  params: vec![],
652
653
  returns: None,
compiler/tooling/tree-sitter-plum/grammar.js CHANGED
@@ -249,6 +249,8 @@ module.exports = grammar({
249
249
  // `export fun foo(...) = ...` is a normal function that is also exported from
250
250
  // the wasm module under its own name, with the `extern` boundary ABI.
251
251
  optional(choice(field("externKw", "extern"), field("exportKw", "export"), field("overrideKw", "override"), field("abstractKw", "abstract"))),
252
+ // `fip fun`: a compile-time guarantee that the body's updates run in place.
253
+ optional(field("fipKw", "fip")),
252
254
  field("kw", "fun"),
253
255
  field("name", $.fn_identifier),
254
256
  // Declared type parameters: `fun map[B](..)`, `fun id[Elem](..)`.
compiler/tooling/tree-sitter-plum/queries/plum/format.scm CHANGED
@@ -132,6 +132,8 @@
132
132
 
133
133
  (fn "abstract" @append_space)
134
134
 
135
+ (fn "fip" @append_space)
136
+
135
137
  (fn "fun" @append_space)
136
138
 
137
139
  (if "if" @append_space)
compiler/tooling/tree-sitter-plum/queries/plum/highlights.scm CHANGED
@@ -95,6 +95,7 @@
95
95
  "export"
96
96
  "override"
97
97
  "abstract"
98
+ "fip"
98
99
  "fn"
99
100
  "test"
100
101
  ] @keyword
compiler/tooling/tree-sitter-plum/src/grammar.json CHANGED
Binary file
compiler/tooling/tree-sitter-plum/src/node-types.json CHANGED
Binary file
compiler/tooling/tree-sitter-plum/src/parser.c CHANGED
Binary file
compiler/tooling/tree-sitter-plum/test/corpus/fip.txt ADDED
@@ -0,0 +1,81 @@
1
+ ================================================================================
2
+ fip fun
3
+ ================================================================================
4
+
5
+ fip fun step(n: Int) -> Int = n
6
+
7
+ --------------------------------------------------------------------------------
8
+
9
+ (source
10
+ (fn
11
+ (fn_identifier)
12
+ (param
13
+ (var_identifier)
14
+ (type
15
+ (type_identifier)))
16
+ (type
17
+ (type_identifier))
18
+ (expression
19
+ (primary_expression
20
+ (var_identifier)))))
21
+
22
+ ================================================================================
23
+ override fip fun in a struct body
24
+ ================================================================================
25
+
26
+ struct P(x: Int, fip: Int) =
27
+ override fip fun size(self) -> Int = self.fip
28
+
29
+ --------------------------------------------------------------------------------
30
+
31
+ (source
32
+ (struct
33
+ (type_identifier)
34
+ (enum_named_field
35
+ (var_identifier)
36
+ (type
37
+ (type_identifier)))
38
+ (enum_named_field
39
+ (var_identifier)
40
+ (type
41
+ (type_identifier)))
42
+ (fn
43
+ (fn_identifier)
44
+ (self)
45
+ (type
46
+ (type_identifier))
47
+ (expression
48
+ (primary_expression
49
+ (attribute
50
+ (primary_expression
51
+ (self))
52
+ (fn_identifier)))))))
53
+
54
+ ================================================================================
55
+ fip as a variable name
56
+ ================================================================================
57
+
58
+ fun main() -> Int =
59
+ fip := 3
60
+ fip + 1
61
+
62
+ --------------------------------------------------------------------------------
63
+
64
+ (source
65
+ (fn
66
+ (fn_identifier)
67
+ (type
68
+ (type_identifier))
69
+ (body
70
+ (assign
71
+ (var_identifier)
72
+ (expression
73
+ (primary_expression
74
+ (integer))))
75
+ (expression
76
+ (primary_expression
77
+ (binary_operator
78
+ (primary_expression
79
+ (var_identifier))
80
+ (primary_expression
81
+ (integer))))))))
compiler/tooling/vscode-plum/syntaxes/plum.tmLanguage.json CHANGED
@@ -39,7 +39,7 @@
39
39
  "keywords": {
40
40
  "patterns": [
41
41
  {
42
- "match": "\\b(module|struct|enum|trait|primitive|fun|fn|extern|export|self|test)(?![A-Za-z0-9_])",
42
+ "match": "\\b(module|struct|enum|trait|primitive|fun|fn|extern|export|override|abstract|fip|self|test)(?![A-Za-z0-9_])",
43
43
  "name": "keyword.const.plum"
44
44
  },
45
45
  {
compiler/wasm-codegen/src/emit.rs CHANGED
@@ -64,6 +64,7 @@ impl<'a> Emit<'a> {
64
64
  is_test: false,
65
65
  is_override: false,
66
66
  is_abstract: false,
67
+ is_fip: false,
67
68
  has_self: true,
68
69
  params: Vec::new(),
69
70
  returns: None,
docs/plans/2026-11-06-fbip-stage1.md CHANGED
@@ -70,7 +70,7 @@ e.g. `v = v.bumped()` in a `for` loop, with `bumped(self) = Cell(..self, n: self
70
70
  **Test code:** `tests/fbip/aliasing.plum`: keep an alias then update; store in a list then update; capture in a closure; return both; a value in two fields; a shared value through a method; an override keeping `self`; update in only one branch. Each gives the copying answer, and matches `--no-fbip`. Plus positive tests in `uniqueness_test.rs`.
71
71
 
72
72
  ### T4 Array.with · Complexity 5 · Est. context 55k
73
- **Owns:** std/Array.plum, compiler/core/src/*.rs, compiler/core/tests/core/*.rs, compiler/wasm-codegen/src/*.rs, compiler/wasm-codegen/tests/wasm_codegen/**/*.rs, compiler/checker/src/*.rs, tests/fbip/*.plum
73
+ **Owns:** std/Array.plum, compiler/core/src/*.rs, compiler/core/tests/core/*.rs, compiler/wasm-codegen/src/*.rs, compiler/wasm-codegen/tests/wasm_codegen/**/*.rs, compiler/checker/src/*.rs, tests/fbip/*.plum, tests/fbip/fuzz/*
74
74
  **Components:** 2
75
75
  **LOC owned:** 180
76
76
  **Tests:** fast
tests/fbip/fip.plum ADDED
@@ -0,0 +1,75 @@
1
+ import std/Int
2
+ import std/Array
3
+
4
+ # `fip fun` forms that must compile: every spread and `Array.with` written in the body is
5
+ # proved in place. The sweep also runs this file with `--no-fbip` and `--no-fusion`, where `fip`
6
+ # is not enforced and the programs still give the same answers. Names are unique per scenario
7
+ # because the proof resolves methods and fields by name.
8
+
9
+ # a fip fun with no update at all is trivially true
10
+ fip fun plainSum(a: Int, b: Int) -> Int = a + b
11
+
12
+ # a fresh array written once
13
+ fip fun freshWrite() -> Array[Int] = Array.filled(3, 0).with(1, 7)
14
+
15
+ # a local array written in a loop
16
+ fip fun squares(n: Int) -> Array[Int] =
17
+ a := Array.filled(n, 0)
18
+ for i := range n
19
+ a = a.with(i, i * i)
20
+ a
21
+
22
+ # a spread of a parameter every caller builds fresh
23
+ struct Pt(px: Int, py: Int)
24
+
25
+ fip fun movePt(p: Pt, dx: Int) -> Pt = Pt(..p, px: p.px + dx)
26
+
27
+ fun walkPt(n: Int) -> Pt =
28
+ p := Pt(px: 0, py: 5)
29
+ for i := range n
30
+ p = movePt(p, 2)
31
+ p
32
+
33
+ # a fip method: self handed back by `v = v.push(..)`
34
+ struct Bag(items: Array[Int], count: Int) =
35
+ fip fun bagAdd(self, i: Int, x: Int) -> Bag = Bag(..self, items: self.items.with(i, x), count: self.count + 1)
36
+ fun bagAt(self, i: Int) -> Int = self.items.get(i)
37
+
38
+ fun fillBag(n: Int) -> Bag =
39
+ b := Bag(items: Array.filled(n, 0), count: 0)
40
+ for i := range n
41
+ b = b.bagAdd(i, i + 10)
42
+ b
43
+
44
+ # a fip fun whose update sits in a closure-free helper chain
45
+ fip fun bumpAt(xs: Array[Int], i: Int) -> Array[Int] = xs.with(i, xs.get(i) + 1)
46
+
47
+ # a generic fip fun
48
+ fip fun setFirst[Elem](xs: Array[Elem], x: Elem) -> Array[Elem] = xs.with(0, x)
49
+
50
+ test "fip: plain"
51
+ assert plainSum(2, 3) == 5
52
+
53
+ test "fip: fresh array write"
54
+ a := freshWrite()
55
+ assert a.get(0) == 0 && a.get(1) == 7
56
+
57
+ test "fip: loop of writes"
58
+ a := squares(5)
59
+ assert a.get(3) == 9 && a.get(4) == 16
60
+
61
+ test "fip: spread of a fresh parameter"
62
+ p := walkPt(4)
63
+ assert p.px == 8 && p.py == 5
64
+
65
+ test "fip: method"
66
+ b := fillBag(4)
67
+ assert b.bagAt(0) == 10 && b.bagAt(3) == 13 && b.count == 4
68
+
69
+ test "fip: read then write"
70
+ a := bumpAt(Array.filled(2, 4), 1)
71
+ assert a.get(1) == 5 && a.get(0) == 4
72
+
73
+ test "fip: generic"
74
+ a := setFirst(Array.filled(2, 1), 9)
75
+ assert a.get(0) == 9 && a.get(1) == 1
tests/fbip/fuzz/fuzz.sh ADDED
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # Differential fuzzer for the FBIP uniqueness proof.
3
+ # A wrong proof writes through a value another holder can see, so a generated
4
+ # program must give the same result with and without --no-fbip. Any DIFF is a
5
+ # real miscompile. Usage (after a build): tests/fbip/fuzz/fuzz.sh FROM TO (seeds)
6
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ root="$(cd "$here/../../.." && pwd)"
8
+ cd "$root" || exit 1
9
+ out=$(mktemp -d "${TMPDIR:-/tmp}/plum-fuzz.XXXXXX")
10
+ ok=0; diff=0; err=0
11
+ for i in $(seq "$1" "$2"); do
12
+ f="$out/p$i.plum"
13
+ perl "$here/gen.pl" "$i" "$f"
14
+ a=$(target/debug/plum test --lib-path . "$f" 2>&1)
15
+ if echo "$a" | grep -q "^error"; then
16
+ err=$((err+1)); echo "ERR $i: $(echo "$a" | head -1 | cut -c1-200)"; continue
17
+ fi
18
+ b=$(target/debug/plum test --no-fbip --lib-path . "$f" 2>&1)
19
+ if [ "$a" == "$b" ]; then ok=$((ok+1)); rm -f "$f"
20
+ else diff=$((diff+1)); echo "DIFF $i: $f"; fi
21
+ done
22
+ echo "done ok=$ok diff=$diff err=$err"
23
+ echo "$out"
24
+ [ "$diff" -eq 0 ]
tests/fbip/fuzz/gen.pl ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/perl
2
+ use strict; use warnings;
3
+ my ($seed, $out) = @ARGV;
4
+ srand($seed);
5
+ sub r { return int(rand($_[0])); }
6
+ sub pick { return $_[r(scalar @_)]; }
7
+ my @arr = ('a','b','c','d','e');
8
+ my @st = ('s','t','u');
9
+ sub aexpr { # Array[Int] expression (reads)
10
+ return pick(@arr);
11
+ }
12
+ sub iexpr {
13
+ my $k = r(9);
14
+ return r(5) if $k == 0;
15
+ return pick(@arr).".get(".r(3).")" if $k <= 2;
16
+ return pick(@st).".f.get(".r(3).")" if $k == 3;
17
+ return pick(@st).".g.get(".r(3).")" if $k == 4;
18
+ return pick(@st).".n" if $k == 5;
19
+ return pick(@st).".total()" if $k == 6;
20
+ return "m.f.get(".r(3).")" if $k == 7;
21
+ return pick(@arr).".length()";
22
+ }
23
+ sub idx { return r(3); }
24
+ my @stmts;
25
+ for my $n (1..(6+r(10))) {
26
+ my $A = pick(@arr); my $B = pick(@arr); my $S = pick(@st); my $T = pick(@st);
27
+ my $k = r(34);
28
+ my %alias = map { $_ => 1 } (2,8,9,10,17,18,19,20,21,22,25,26,4,15,12,1,30);
29
+ while ($alias{$k} && r(100) >= 12) { $k = r(34); }
30
+ my $s;
31
+ if ($k==0) { $s = "$A = $A.with(".idx().", ".iexpr().")"; }
32
+ elsif ($k==1) { $s = "$A = $B.with(".idx().", ".iexpr().")"; }
33
+ elsif ($k==2) { $s = "$A = $B"; }
34
+ elsif ($k==3) { $s = "$A = h1($A)"; }
35
+ elsif ($k==4) { $s = "$A = h1($B)"; }
36
+ elsif ($k==5) { $s = "$S = S(..$S, f: $S.f.with(".idx().", ".iexpr()."))"; }
37
+ elsif ($k==6) { $s = "$S = S(..$S, f: $S.f.with(".idx().", ".iexpr()."), n: ".iexpr().")"; }
38
+ elsif ($k==7) { $s = "$S = S(..$S, g: $S.g.with(".idx().", $S.f.get(".idx()."))), f: $S.f.with(".idx().", 1))"; $s = "$S = S(..$S, g: $S.g.with(".idx().", $S.f.get(".idx().")), f: $S.f.with(".idx().", 1))"; }
39
+ elsif ($k==8) { $s = "$S = $T"; }
40
+ elsif ($k==9) { $s = "$A = $S.f"; }
41
+ elsif ($k==10) { $s = "$S = S(f: $A, g: $B, n: ".r(4).")"; }
42
+ elsif ($k==11) { $s = "$S = S(f: $A, g: Array.filled(3, 0), n: ".r(4).")"; }
43
+ elsif ($k==12) { $s = "$S = $T.put(".idx().", ".iexpr().")"; }
44
+ elsif ($k==13) { $s = "$S = $S.put(".idx().", ".iexpr().")"; }
45
+ elsif ($k==14) { $s = "$S = upd($S, ".idx().")"; }
46
+ elsif ($k==15) { $s = "$S = upd($T, ".idx().")"; }
47
+ elsif ($k==16) { $s = "m.f = m.f.with(".idx().", ".iexpr().")"; }
48
+ elsif ($k==17) { $s = "m.f = $A"; }
49
+ elsif ($k==18) { $s = "$A = m.f"; }
50
+ elsif ($k==19) { $s = "xs.push($A)"; }
51
+ elsif ($k==20) { $s = "ys.push($S)"; }
52
+ elsif ($k==21) { $s = "$A = xs.at(".r(2).")"; }
53
+ elsif ($k==22) { $s = "$S = ys.at(".r(2).")"; }
54
+ elsif ($k==23) { $s = "$A = Array.filled(3, ".r(5).")"; }
55
+ elsif ($k==24) { $s = "$A = $A.resize(3)"; }
56
+ elsif ($k==25) { $s = "$S = $S.view().put(".idx().", ".iexpr().")"; }
57
+ elsif ($k==26) { $s = "$A = $S.fOf()"; }
58
+ elsif ($k==27) { $s = "$S = $S.bump()"; }
59
+ elsif ($k==28) { $s = "$S = S(..$S, f: $S.f.with(".idx().", $S.total()))"; }
60
+ elsif ($k==29) { $s = r(2) ? "$S = S(..$S, n: $S.total(), f: $S.f.with(".idx().", 7))" : "$S = S(..$S, f: $S.f.with(".idx().", 7), n: $S.total())"; }
61
+ elsif ($k==30) { $s = "$S = $S + $T"; }
62
+ elsif ($k==31) { $s = "$S = -$S"; }
63
+ elsif ($k==32) { $s = "$A = pass($A, $B)"; }
64
+ else { $s = "if ".iexpr()." > 1\n $A = $A.with(".idx().", 9)\n else\n $B = $A"; }
65
+ push @stmts, $s;
66
+ }
67
+ my $body = join("\n ", @stmts);
68
+ my $dump = " out := \"\"\n";
69
+ for my $v (@arr) { $dump .= " out = out + show3(\"$v\", $v.get(0), $v.get(1), $v.get(2))\n"; }
70
+ for my $v (@st) { $dump .= " out = out + show3(\"$v.f\", $v.f.get(0), $v.f.get(1), $v.f.get(2)) + show3(\"$v.g\", $v.g.get(0), $v.g.get(1), $v.g.get(2)) + \"$v.n=\" + $v.n.toStr() + \";\"\n"; }
71
+ $dump .= " out = out + show3(\"m.f\", m.f.get(0), m.f.get(1), m.f.get(2))" . join("", map { my $e=$_; " + show3(\"$e\", $e.get(0), $e.get(1), $e.get(2))" } ("xs.at(0)","xs.at(1)","ys.at(0).f","ys.at(1).f","ys.at(0).g")) . "\n assert out == \"\"\n";
72
+ open(my $fh, '>', $out) or die;
73
+ print $fh <<"EOP";
74
+ import std/Int
75
+ import std/Str
76
+ import std/Array
77
+ import std/Slice
78
+
79
+ struct S(f: Array[Int], g: Array[Int], n: Int) =
80
+ fun put(self, i: Int, x: Int) -> S = S(..self, f: self.f.with(i, x))
81
+ fun total(self) -> Int = self.f.get(0) + self.g.get(1) + self.n
82
+ fun view(self) -> S = self
83
+ fun fOf(self) -> Array[Int] = self.f
84
+ fun bump(self) -> S = S(..self, g: self.g.with(2, self.total()), n: self.n + 1)
85
+ fun add(self, o: S) -> S = S(..self, f: self.f.with(0, o.n))
86
+ fun neg(self) -> S = S(..self, g: self.g.with(0, 5))
87
+
88
+ struct M(mut f: Array[Int])
89
+
90
+ fun h1(x: Array[Int]) -> Array[Int] = x.with(0, x.get(1) + 1)
91
+
92
+ fun pass(x: Array[Int], y: Array[Int]) -> Array[Int] = if x.get(0) > 2 then x else y.with(1, 3)
93
+
94
+ fun upd(s: S, i: Int) -> S = S(..s, g: s.g.with(i, s.f.get(i)))
95
+
96
+ fun show3(name: Str, x: Int, y: Int, z: Int) -> Str =
97
+ name + "=" + x.toStr() + "," + y.toStr() + "," + z.toStr() + ";"
98
+
99
+ test "fuzz"
100
+ a := Array.filled(3, 1)
101
+ b := Array.filled(3, 2)
102
+ c := Array.filled(3, 3)
103
+ d := Array.filled(3, 30)
104
+ e := Array.filled(3, 31)
105
+ s := S(f: Array.filled(3, 4), g: Array.filled(3, 5), n: 1)
106
+ t := S(f: Array.filled(3, 6), g: Array.filled(3, 7), n: 2)
107
+ u := S(f: Array.filled(3, 16), g: Array.filled(3, 17), n: 5)
108
+ m := M(f: Array.filled(3, 8))
109
+ xs := Slice[Array[Int]]()
110
+ xs.push(Array.filled(3, 10))
111
+ xs.push(Array.filled(3, 11))
112
+ ys := Slice[S]()
113
+ ys.push(S(f: Array.filled(3, 12), g: Array.filled(3, 13), n: 3))
114
+ ys.push(S(f: Array.filled(3, 14), g: Array.filled(3, 15), n: 4))
115
+ $body
116
+ $dump
117
+ EOP
118
+ close $fh;