plum

#treesitter#compiler#wasm

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

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


29313cbPeter John 2026-09-05T13:28:55+05:30
refactor(stdlib): move libs/std to top-level plum-std, gate Int/Float/Str imports
Files changed (42) hide show
  1. examples/basics.plum +3 -0
  2. examples/closures.plum +2 -0
  3. examples/control_flow.plum +1 -0
  4. examples/dop_visitor.plum +3 -1
  5. examples/functions.plum +1 -0
  6. examples/match.plum +2 -0
  7. examples/methods.plum +1 -0
  8. examples/oop_visitor.plum +3 -1
  9. examples/strings.plum +2 -0
  10. examples/testing.plum +1 -0
  11. examples/types.plum +3 -0
  12. plum-checker/tests/examples_test.rs +2 -2
  13. plum-cli/src/main.rs +5 -5
  14. plum-cli/tests/compile_tests.rs +6 -3
  15. plum-core/src/builtin_usage.rs +263 -0
  16. plum-core/src/lib.rs +1 -0
  17. plum-core/src/loader.rs +47 -2
  18. plum-core/tests/loader_test.rs +27 -20
  19. {libs/std → plum-std}/Array.plum +1 -0
  20. {libs/std/encoding → plum-std}/Base64.plum +1 -0
  21. {libs/std → plum-std}/Bool.plum +0 -0
  22. {libs/std → plum-std}/Buffer.plum +1 -0
  23. {libs/std → plum-std}/Byte.plum +1 -0
  24. {libs/std → plum-std}/ByteSlice.plum +1 -0
  25. {libs/std → plum-std}/Err.plum +2 -0
  26. {libs/std → plum-std}/Float.plum +0 -0
  27. libs/std/http.plum → plum-std/Http.plum +1 -0
  28. {libs/std → plum-std}/Int.plum +0 -0
  29. {libs/std → plum-std}/Json.plum +0 -0
  30. {libs/std → plum-std}/List.plum +1 -0
  31. {libs/std → plum-std}/Map.plum +2 -0
  32. {libs/std → plum-std}/Option.plum +1 -0
  33. libs/std/os.plum → plum-std/Os.plum +1 -0
  34. libs/std/regex.mi → plum-std/Regex.mi +0 -0
  35. {libs/std → plum-std}/Result.plum +1 -0
  36. {libs/std → plum-std}/Str.plum +1 -0
  37. libs/std/testing.plum → plum-std/Testing.plum +2 -0
  38. {libs/std → plum-std}/Time.plum +1 -0
  39. {libs/std → plum-std}/Uuid.plum +0 -0
  40. plum-wasm-codegen/src/lib.rs +1 -1
  41. plum-wasm-codegen/tests/examples_test.rs +5 -6
  42. scripts/test-plum.sh +8 -7
examples/basics.plum CHANGED
@@ -1,5 +1,8 @@
1
1
  module basics
2
2
  import std/Bool
3
+ import std/Int
4
+ import std/Float
5
+ import std/Str
3
6
 
4
7
  MAX_RETRIES = 3
5
8
  GOLDEN_RATIO = 1.61803
examples/closures.plum CHANGED
@@ -1,5 +1,7 @@
1
1
  import std/Str
2
2
  import std/Bool
3
+ import std/Int
4
+ import std/Float
3
5
 
4
6
  fun each(cb: fn(Int) -> Int) -> Int =
5
7
  cb(5)
examples/control_flow.plum CHANGED
@@ -1,5 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
+ import std/Int
3
4
 
4
5
  fun loopSum(limit: Int) -> Int =
5
6
  total := 0
examples/dop_visitor.plum CHANGED
@@ -36,8 +36,10 @@
36
36
  # `oop_visitor.plum`'s header), which tilts things further toward DOP
37
37
  # for anything that needs to treat the shapes polymorphically.
38
38
 
39
- import std/os
39
+ import std/Os
40
40
  import std/Bool
41
+ import std/Int
42
+ import std/Str
41
43
 
42
44
  enum Rating =
43
45
  | Good(name: Str)
examples/functions.plum CHANGED
@@ -1,5 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
+ import std/Int
3
4
 
4
5
  fun addInts(a: Int, b: Int) -> Int =
5
6
  a + b
examples/match.plum CHANGED
@@ -1,5 +1,7 @@
1
1
  import std/Option
2
2
  import std/Bool
3
+ import std/Int
4
+ import std/Str
3
5
 
4
6
  enum Color =
5
7
  | Red
examples/methods.plum CHANGED
@@ -1,5 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
+ import std/Int
3
4
 
4
5
  type Cat =
5
6
  name: Str
examples/oop_visitor.plum CHANGED
@@ -29,8 +29,10 @@
29
29
  # removes OOP's usual "call through one interface" benefit entirely,
30
30
  # which is a real cost specific to this language.
31
31
 
32
- import std/os
32
+ import std/Os
33
33
  import std/Bool
34
+ import std/Int
35
+ import std/Str
34
36
 
35
37
  enum Rating =
36
38
  | Good(name: Str)
examples/strings.plum CHANGED
@@ -1,4 +1,6 @@
1
1
  import std/Bool
2
+ import std/Int
3
+ import std/Str
2
4
 
3
5
  fun greet(name: Str) -> Str =
4
6
  "Hello, {name}!"
examples/testing.plum CHANGED
@@ -1,5 +1,6 @@
1
1
  import std/Str
2
2
  import std/Bool
3
+ import std/Int
3
4
 
4
5
  fun add(a: Int, b: Int) -> Int =
5
6
  a + b
examples/types.plum CHANGED
@@ -1,5 +1,8 @@
1
1
  import std/Option
2
2
  import std/Bool
3
+ import std/Int
4
+ import std/Float
5
+ import std/Str
3
6
 
4
7
  type Point =
5
8
  x: Int
plum-checker/tests/examples_test.rs CHANGED
@@ -6,7 +6,7 @@ fn examplesDir() -> std::path::PathBuf {
6
6
  }
7
7
 
8
8
  fn libPath() -> std::path::PathBuf {
9
- std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../libs")
9
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
10
10
  }
11
11
 
12
12
  fn exampleFiles() -> Vec<std::path::PathBuf> {
@@ -21,7 +21,7 @@ fn exampleFiles() -> Vec<std::path::PathBuf> {
21
21
  files
22
22
  }
23
23
 
24
- /// Every example must load (resolving its own `import`s against `../libs`,
24
+ /// Every example must load (resolving its own `import`s against the repo root,
25
25
  /// the real compilation path — several examples now genuinely `import` real
26
26
  /// stdlib types like `std/Bool`/`std/Str`, so a bare single-file parse would
27
27
  /// leave those names undeclared) and pass `checkSource`. These files exist
plum-cli/src/main.rs CHANGED
@@ -42,7 +42,7 @@ enum Command {
42
42
  #[arg(short, long)]
43
43
  output: Option<std::path::PathBuf>,
44
44
  /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
45
- #[arg(long, default_value = "libs")]
45
+ #[arg(long, default_value = ".")]
46
46
  lib_path: std::path::PathBuf,
47
47
  },
48
48
  /// Compile a Plum source file to WASM and immediately run it under wasmtime
@@ -50,7 +50,7 @@ enum Command {
50
50
  /// Source file to compile and run
51
51
  file: std::path::PathBuf,
52
52
  /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
53
- #[arg(long, default_value = "libs")]
53
+ #[arg(long, default_value = ".")]
54
54
  lib_path: std::path::PathBuf,
55
55
  },
56
56
  /// Compile a Plum source file to WASM and embed it into a wasmtime-backed
@@ -62,7 +62,7 @@ enum Command {
62
62
  #[arg(short, long)]
63
63
  output: Option<std::path::PathBuf>,
64
64
  /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
65
- #[arg(long, default_value = "libs")]
65
+ #[arg(long, default_value = ".")]
66
66
  lib_path: std::path::PathBuf,
67
67
  },
68
68
  /// Compile a Plum source file's `test` blocks and run them under wasmtime
@@ -70,7 +70,7 @@ enum Command {
70
70
  /// Source file to compile and test
71
71
  file: std::path::PathBuf,
72
72
  /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
73
- #[arg(long, default_value = "libs")]
73
+ #[arg(long, default_value = ".")]
74
74
  lib_path: std::path::PathBuf,
75
75
  },
76
76
  /// Install Plum syntax highlighting into an editor's configuration
@@ -408,7 +408,7 @@ fn hostImports(store: &mut wasmtime::Store<()>, module: &wasmtime::Module) -> Re
408
408
  }
409
409
 
410
410
  /// Performs one blocking HTTP round trip for `plum::rawHttpRequest` and packs
411
- /// the outcome into the wire format `libs/std/http.plum`'s `request` expects
411
+ /// the outcome into the wire format `plum-std/Http.plum`'s `request` expects
412
412
  /// (see that extern's doc comment): `"1\x01<status>\x01<headers>\x00<body>"`
413
413
  /// on success, `"0\x01\x01<message>\x00"` for a transport-level failure. A
414
414
  /// non-2xx HTTP status (404, 500, ...) is NOT a failure here — it's still a
plum-cli/tests/compile_tests.rs CHANGED
@@ -25,7 +25,10 @@ fn freshTempDir(name: &str) -> std::path::PathBuf {
25
25
  fn compileSimpleAddProducesWasm() {
26
26
  let dir = freshTempDir("simple_add");
27
27
  let src_path = dir.join("simple_add.plum");
28
+ // `Unit` (unlike `Int`/`Float`/`Str`) needs no `import std/...` — see
29
+ // `plum_core::loader::checkBuiltinImports` — which keeps this fixture
30
+ // free of the repo's real stdlib.
28
- std::fs::write(&src_path, "fun add(a: Int, b: Int) -> Int =\n a + b\n").unwrap();
31
+ std::fs::write(&src_path, "fun add(a: Unit, b: Unit) -> Unit =\n a\n").unwrap();
29
32
  let out_path = dir.join("simple_add.wasm");
30
33
  let status = Command::new(plumBin())
31
34
  .args(["compile", src_path.to_str().unwrap(), "-o", out_path.to_str().unwrap()])
@@ -39,9 +42,9 @@ fn compileSimpleAddProducesWasm() {
39
42
  #[test]
40
43
  fn compileWithImportResolvesViaLibPath() {
41
44
  let dir = freshTempDir("import_fixtures");
42
- std::fs::write(dir.join("helper.plum"), "fun helperValue() -> Int =\n 42\n").unwrap();
45
+ std::fs::write(dir.join("helper.plum"), "fun helperValue(u: Unit) -> Unit =\n u\n").unwrap();
43
46
  let src_path = dir.join("main.plum");
44
- std::fs::write(&src_path, "module fixtures\n\nimport helper\n\nfun main() -> Int =\n helperValue()\n").unwrap();
47
+ std::fs::write(&src_path, "module fixtures\n\nimport helper\n\nfun main(u: Unit) -> Unit =\n helperValue(u)\n").unwrap();
45
48
  let out_path = dir.join("main.wasm");
46
49
  let status = Command::new(plumBin())
47
50
  .args([
plum-core/src/builtin_usage.rs ADDED
@@ -0,0 +1,263 @@
1
+ use std::collections::HashSet;
2
+
3
+ use crate::ast::{
4
+ Arg, AssignTarget, AttrKind, CasePattern, Expr, FnBody, Item, ParamType, Source, Stmt,
5
+ StringPart, Type,
6
+ };
7
+
8
+ /// The builtin type names this walk tracks: primitive value types with no
9
+ /// enum-variant/name-resolution step that naturally fails when unimported
10
+ /// (unlike `Bool`, whose `True`/`False` variants simply don't resolve without
11
+ /// `import std/Bool`) — so nothing else catches a missing `import std/Int` /
12
+ /// `import std/Float` / `import std/Str`.
13
+ const TRACKED_TYPES: [&str; 3] = ["Int", "Float", "Str"];
14
+
15
+ fn isTracked(name: &str) -> bool {
16
+ TRACKED_TYPES.contains(&name)
17
+ }
18
+
19
+ /// Every builtin type name (`Int`, `Float`, `Str` — literal or type-annotation)
20
+ /// mentioned anywhere in `source` — used by `loader::loadAndMerge` to enforce
21
+ /// that a file using one of them explicitly `import`s it, exactly like every
22
+ /// other stdlib type (`Bool`, ...) already must be.
23
+ pub fn usedBuiltinTypeNames(source: &Source) -> HashSet<String> {
24
+ let mut names = HashSet::new();
25
+ for item in &source.items {
26
+ match item {
27
+ Item::Class(c) => {
28
+ for f in &c.fields {
29
+ walkType(&f.ty, &mut names);
30
+ }
31
+ }
32
+ Item::Trait(t) => {
33
+ for m in &t.methods {
34
+ for p in &m.params {
35
+ walkParamType(&p.ty, &mut names);
36
+ }
37
+ if let Some(r) = &m.returns {
38
+ walkType(r, &mut names);
39
+ }
40
+ }
41
+ }
42
+ Item::Enum(e) => {
43
+ for p in &e.params {
44
+ walkType(&p.ty, &mut names);
45
+ }
46
+ for v in &e.variants {
47
+ for f in &v.fields {
48
+ walkType(f, &mut names);
49
+ }
50
+ for val in &v.values {
51
+ walkExpr(val, &mut names);
52
+ }
53
+ }
54
+ }
55
+ Item::Fn(f) => {
56
+ for p in &f.params {
57
+ walkParamType(&p.ty, &mut names);
58
+ if let Some(d) = &p.default {
59
+ walkExpr(d, &mut names);
60
+ }
61
+ }
62
+ if let Some(r) = &f.returns {
63
+ walkType(r, &mut names);
64
+ }
65
+ match &f.body {
66
+ FnBody::Expr(e) => walkExpr(e, &mut names),
67
+ FnBody::Block(b) => walkStmts(&b.stmts, &mut names),
68
+ FnBody::Extern => {}
69
+ }
70
+ }
71
+ Item::Const(c) => walkExpr(&c.value, &mut names),
72
+ Item::Test(t) => walkStmts(&t.body.stmts, &mut names),
73
+ }
74
+ }
75
+ names
76
+ }
77
+
78
+ fn walkType(ty: &Type, names: &mut HashSet<String>) {
79
+ if isTracked(&ty.name) {
80
+ names.insert(ty.name.clone());
81
+ }
82
+ for g in &ty.generics {
83
+ walkType(g, names);
84
+ }
85
+ }
86
+
87
+ fn walkParamType(pt: &ParamType, names: &mut HashSet<String>) {
88
+ match pt {
89
+ ParamType::Type(t) => walkType(t, names),
90
+ ParamType::Variadic(t) => walkType(t, names),
91
+ ParamType::Fn(params, ret) => {
92
+ for p in params {
93
+ walkType(p, names);
94
+ }
95
+ if let Some(r) = ret {
96
+ walkType(r, names);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ fn walkStmts(stmts: &[Stmt], names: &mut HashSet<String>) {
103
+ for s in stmts {
104
+ walkStmt(s, names);
105
+ }
106
+ }
107
+
108
+ fn walkStmt(stmt: &Stmt, names: &mut HashSet<String>) {
109
+ match stmt {
110
+ Stmt::Assign(a) => {
111
+ for t in &a.targets {
112
+ if let AssignTarget::Field(obj, _) = t {
113
+ walkExpr(obj, names);
114
+ }
115
+ }
116
+ for v in &a.values {
117
+ walkExpr(v, names);
118
+ }
119
+ }
120
+ Stmt::Break | Stmt::Continue | Stmt::Todo => {}
121
+ Stmt::Assert(check) => walkExpr(&check.cond, names),
122
+ Stmt::For(f) => {
123
+ walkExpr(&f.iter, names);
124
+ walkStmts(&f.body.stmts, names);
125
+ }
126
+ Stmt::While(w) => {
127
+ walkExpr(&w.condition, names);
128
+ walkStmts(&w.body.stmts, names);
129
+ }
130
+ Stmt::If(i) => {
131
+ walkExpr(&i.condition, names);
132
+ walkStmts(&i.body.stmts, names);
133
+ for ei in &i.else_ifs {
134
+ walkExpr(&ei.condition, names);
135
+ walkStmts(&ei.body.stmts, names);
136
+ }
137
+ if let Some(e) = &i.else_ {
138
+ walkStmts(&e.stmts, names);
139
+ }
140
+ }
141
+ Stmt::Match(m) => {
142
+ for s in &m.subjects {
143
+ walkExpr(s, names);
144
+ }
145
+ for c in &m.cases {
146
+ for p in &c.patterns {
147
+ walkCasePattern(p, names);
148
+ }
149
+ if let Some(g) = &c.guard {
150
+ walkExpr(g, names);
151
+ }
152
+ walkStmts(&c.body.stmts, names);
153
+ }
154
+ }
155
+ Stmt::Return(e) => {
156
+ if let Some(e) = e {
157
+ walkExpr(e, names);
158
+ }
159
+ }
160
+ Stmt::Expr(e) => walkExpr(e, names),
161
+ }
162
+ }
163
+
164
+ fn walkCasePattern(pat: &CasePattern, names: &mut HashSet<String>) {
165
+ match pat {
166
+ CasePattern::Class { fields, .. } => {
167
+ for f in fields {
168
+ walkCasePattern(f, names);
169
+ }
170
+ }
171
+ CasePattern::Int(_) => {
172
+ names.insert("Int".to_string());
173
+ }
174
+ CasePattern::Float(_) => {
175
+ names.insert("Float".to_string());
176
+ }
177
+ CasePattern::String(_) => {
178
+ names.insert("Str".to_string());
179
+ }
180
+ CasePattern::Name(_) | CasePattern::Wildcard => {}
181
+ }
182
+ }
183
+
184
+ fn walkExpr(expr: &Expr, names: &mut HashSet<String>) {
185
+ match expr {
186
+ Expr::Binary(b) => {
187
+ walkExpr(&b.left, names);
188
+ walkExpr(&b.right, names);
189
+ }
190
+ Expr::Unary(u) => walkExpr(&u.operand, names),
191
+ Expr::Bool(b) => {
192
+ walkExpr(&b.left, names);
193
+ walkExpr(&b.right, names);
194
+ }
195
+ Expr::Not(e) => walkExpr(e, names),
196
+ Expr::Compare(c) => {
197
+ walkExpr(&c.left, names);
198
+ walkExpr(&c.right, names);
199
+ }
200
+ Expr::Ternary(t) => {
201
+ walkExpr(&t.condition, names);
202
+ walkExpr(&t.then, names);
203
+ walkExpr(&t.else_, names);
204
+ }
205
+ Expr::FnCall(call) => {
206
+ if isTracked(&call.name) {
207
+ names.insert(call.name.clone());
208
+ }
209
+ for a in &call.args {
210
+ walkArg(a, names);
211
+ }
212
+ }
213
+ Expr::ClassCall(c) => {
214
+ if isTracked(&c.type_name) {
215
+ names.insert(c.type_name.clone());
216
+ }
217
+ for g in &c.generics {
218
+ walkType(g, names);
219
+ }
220
+ for f in &c.fields {
221
+ walkExpr(&f.value, names);
222
+ }
223
+ }
224
+ Expr::Attribute(a) => {
225
+ walkExpr(&a.object, names);
226
+ if let AttrKind::Method(call) = &a.attr {
227
+ for arg in &call.args {
228
+ walkArg(arg, names);
229
+ }
230
+ }
231
+ }
232
+ Expr::Paren(e) => walkExpr(e, names),
233
+ Expr::String(s) => {
234
+ names.insert("Str".to_string());
235
+ for part in &s.parts {
236
+ if let StringPart::Interp(e) = part {
237
+ walkExpr(e, names);
238
+ }
239
+ }
240
+ }
241
+ Expr::Int(_) => {
242
+ names.insert("Int".to_string());
243
+ }
244
+ Expr::Float(_) => {
245
+ names.insert("Float".to_string());
246
+ }
247
+ Expr::Self_ | Expr::Var(_) => {}
248
+ Expr::TypeName(name) => {
249
+ if isTracked(name) {
250
+ names.insert(name.clone());
251
+ }
252
+ }
253
+ Expr::Closure(c) => walkStmts(&c.body.stmts, names),
254
+ }
255
+ }
256
+
257
+ fn walkArg(arg: &Arg, names: &mut HashSet<String>) {
258
+ match arg {
259
+ Arg::Positional(e) => walkExpr(e, names),
260
+ Arg::Keyword { value, .. } => walkExpr(value, names),
261
+ Arg::Pair { value, .. } => walkExpr(value, names),
262
+ }
263
+ }
plum-core/src/lib.rs CHANGED
@@ -3,6 +3,7 @@
3
3
  #![allow(non_snake_case)]
4
4
 
5
5
  pub mod ast;
6
+ pub mod builtin_usage;
6
7
  pub mod parser;
7
8
  pub mod formatter;
8
9
  pub mod loader;
plum-core/src/loader.rs CHANGED
@@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet};
2
2
  use std::path::{Path, PathBuf};
3
3
 
4
4
  use crate::ast::{Import, Item, Source};
5
+ use crate::builtin_usage::usedBuiltinTypeNames;
5
6
  use crate::parser::AstParser;
6
7
 
7
8
  /// Resolves `entry`'s `import` statements (transitively, and tolerating
@@ -11,11 +12,14 @@ use crate::parser::AstParser;
11
12
  /// fields are not otherwise used once their `items` have been merged in) —
12
13
  /// only `items` accumulates across files.
13
14
  ///
14
- /// `import "std/option"` resolves to `<lib_path>/std/option.plum`.
15
+ /// `import "std/option"` resolves to `<lib_path>/plum-std/option.plum` — the
16
+ /// on-disk stdlib directory is `plum-std`, but source files keep writing
17
+ /// `std/...` imports, so the `std/` prefix is remapped here.
15
18
  pub fn loadAndMerge(entry: &Path, lib_path: &Path) -> Result<Source, String> {
16
19
  let entry_canon = std::fs::canonicalize(entry)
17
20
  .map_err(|e| format!("cannot read entry file '{}': {}", entry.display(), e))?;
18
21
  let entry_source = parseFile(&entry_canon)?;
22
+ checkBuiltinImports(&entry_canon, &entry_source)?;
19
23
 
20
24
  let mut visited: HashSet<PathBuf> = HashSet::new();
21
25
  let mut items: Vec<Item> = Vec::new();
@@ -42,7 +46,11 @@ fn loadImport(
42
46
  items: &mut Vec<Item>,
43
47
  names: &mut HashMap<String, PathBuf>,
44
48
  ) -> Result<(), String> {
49
+ let resolved_path = match import.path.strip_prefix("std/") {
50
+ Some(rest) => format!("plum-std/{}", rest),
51
+ None => import.path.clone(),
52
+ };
45
- let target = lib_path.join(format!("{}.plum", import.path));
53
+ let target = lib_path.join(format!("{}.plum", resolved_path));
46
54
  let target_canon = std::fs::canonicalize(&target)
47
55
  .map_err(|_| format!("import '{}': no such file '{}'", import.path, target.display()))?;
48
56
 
@@ -53,6 +61,7 @@ fn loadImport(
53
61
  }
54
62
 
55
63
  let source = parseFile(&target_canon)?;
64
+ checkBuiltinImports(&target_canon, &source)?;
56
65
  mergeItems(&target_canon, &source, items, names)?;
57
66
  for nested in &source.imports {
58
67
  loadImport(nested, lib_path, visited, items, names)?;
@@ -60,6 +69,42 @@ fn loadImport(
60
69
  Ok(())
61
70
  }
62
71
 
72
+ /// `Int`/`Float`/`Str` are primitive value types baked directly into the type
73
+ /// system (unlike `Bool`, which is an ordinary enum whose `True`/`False`
74
+ /// variants simply fail to resolve when `std/Bool` isn't imported), so
75
+ /// nothing naturally stops a file from using an `Int`/`Float` literal, a
76
+ /// bare string literal, or a matching type annotation without ever importing
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
79
+ /// `Int.plum`/`Float.plum`/`Str.plum` themselves, which declare the type
80
+ /// rather than import it.
81
+ fn checkBuiltinImports(path: &Path, source: &Source) -> Result<(), String> {
82
+ let used = usedBuiltinTypeNames(source);
83
+ for name in ["Int", "Float", "Str"] {
84
+ if !used.contains(name) {
85
+ continue;
86
+ }
87
+ let declares_self = source
88
+ .items
89
+ .iter()
90
+ .any(|i| matches!(i, Item::Class(c) if c.name == name));
91
+ if declares_self {
92
+ continue;
93
+ }
94
+ let import_path = format!("std/{}", name);
95
+ let already_imported = source.imports.iter().any(|imp| imp.path == import_path);
96
+ if !already_imported {
97
+ return Err(format!(
98
+ "'{}' uses type '{}' but doesn't import it: add `import {}`",
99
+ path.display(),
100
+ name,
101
+ import_path
102
+ ));
103
+ }
104
+ }
105
+ Ok(())
106
+ }
107
+
63
108
  fn parseFile(path: &Path) -> Result<Source, String> {
64
109
  let text = std::fs::read_to_string(path)
65
110
  .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?;
plum-core/tests/loader_test.rs CHANGED
@@ -6,6 +6,13 @@ use tempfile::TempDir;
6
6
  /// Writes `name` (without a directory prefix — always placed directly under
7
7
  /// the temp dir's `lib_path` subdirectory) with `contents`, creating parent
8
8
  /// directories as needed (so `"std/option"` works).
9
+ ///
10
+ /// Fixture bodies deliberately use `Unit` (not `Int`/`Float`/`Str`) params and
11
+ /// return types — `plum_core::loader::checkBuiltinImports` requires any real
12
+ /// `Int`/`Float`/`Str` use to `import std/Int`/`import std/Float`/`import
13
+ /// std/Str`, which these synthetic, stdlib-free temp dirs don't have; `Unit`
14
+ /// has no such requirement, so it keeps these fixtures focused on
15
+ /// loader/import-graph mechanics rather than the builtin-import policy.
9
16
  fn writeLibFile(lib_path: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
10
17
  let path = lib_path.join(format!("{}.plum", name));
11
18
  fs::create_dir_all(path.parent().unwrap()).unwrap();
@@ -17,8 +24,8 @@ fn writeLibFile(lib_path: &std::path::Path, name: &str, contents: &str) -> std::
17
24
  fn importSeesImportedDeclarations() {
18
25
  let dir = TempDir::new().unwrap();
19
26
  let lib_path = dir.path().join("libs");
20
- writeLibFile(&lib_path, "helper", "module fixtures\n\nfun helperFn() -> Int =\n 42\n");
27
+ writeLibFile(&lib_path, "helper", "module fixtures\n\nfun helperFn(u: Unit) -> Unit =\n u\n");
21
- let entry = writeLibFile(&lib_path, "main", "module fixtures\n\nimport helper\n\nfun main() -> Int =\n helperFn()\n");
28
+ let entry = writeLibFile(&lib_path, "main", "module fixtures\n\nimport helper\n\nfun main(u: Unit) -> Unit =\n helperFn(u)\n");
22
29
 
23
30
  let merged = loadAndMerge(&entry, &lib_path).expect("loadAndMerge failed");
24
31
  let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
@@ -33,9 +40,9 @@ fn importSeesImportedDeclarations() {
33
40
  fn transitiveImportSurfacesGrandchildDeclarations() {
34
41
  let dir = TempDir::new().unwrap();
35
42
  let lib_path = dir.path().join("libs");
36
- writeLibFile(&lib_path, "c", "module fixtures\n\nfun cFn() -> Int =\n 3\n");
43
+ writeLibFile(&lib_path, "c", "module fixtures\n\nfun cFn(u: Unit) -> Unit =\n u\n");
37
- writeLibFile(&lib_path, "b", "module fixtures\n\nimport c\n\nfun bFn() -> Int =\n cFn()\n");
44
+ writeLibFile(&lib_path, "b", "module fixtures\n\nimport c\n\nfun bFn(u: Unit) -> Unit =\n cFn(u)\n");
38
- let entry = writeLibFile(&lib_path, "a", "module fixtures\n\nimport b\n\nfun main() -> Int =\n bFn()\n");
45
+ let entry = writeLibFile(&lib_path, "a", "module fixtures\n\nimport b\n\nfun main(u: Unit) -> Unit =\n bFn(u)\n");
39
46
 
40
47
  let merged = loadAndMerge(&entry, &lib_path).expect("loadAndMerge failed");
41
48
  let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
@@ -49,10 +56,10 @@ fn transitiveImportSurfacesGrandchildDeclarations() {
49
56
  fn diamondImportIncludesSharedDependencyOnce() {
50
57
  let dir = TempDir::new().unwrap();
51
58
  let lib_path = dir.path().join("libs");
52
- writeLibFile(&lib_path, "d", "module fixtures\n\nfun dFn() -> Int =\n 4\n");
59
+ writeLibFile(&lib_path, "d", "module fixtures\n\nfun dFn(u: Unit) -> Unit =\n u\n");
53
- writeLibFile(&lib_path, "b", "module fixtures\n\nimport d\n\nfun bFn() -> Int =\n dFn()\n");
60
+ writeLibFile(&lib_path, "b", "module fixtures\n\nimport d\n\nfun bFn(u: Unit) -> Unit =\n dFn(u)\n");
54
- writeLibFile(&lib_path, "c", "module fixtures\n\nimport d\n\nfun cFn() -> Int =\n dFn()\n");
61
+ writeLibFile(&lib_path, "c", "module fixtures\n\nimport d\n\nfun cFn(u: Unit) -> Unit =\n dFn(u)\n");
55
- let entry = writeLibFile(&lib_path, "a", "module fixtures\n\nimport b\nimport c\n\nfun main() -> Int =\n bFn() + cFn()\n");
62
+ let entry = writeLibFile(&lib_path, "a", "module fixtures\n\nimport b\nimport c\n\nfun main(u: Unit) -> Unit =\n bFn(cFn(u))\n");
56
63
 
57
64
  let merged = loadAndMerge(&entry, &lib_path).expect("loadAndMerge failed");
58
65
  let d_count = merged.items.iter().filter(|i| matches!(i, plum_core::ast::Item::Fn(f) if f.name == "dFn")).count();
@@ -63,8 +70,8 @@ fn diamondImportIncludesSharedDependencyOnce() {
63
70
  fn importCycleResolvesWithoutHangingOrErroring() {
64
71
  let dir = TempDir::new().unwrap();
65
72
  let lib_path = dir.path().join("libs");
66
- writeLibFile(&lib_path, "b", "module fixtures\n\nimport a\n\nfun bFn() -> Int =\n 1\n");
73
+ writeLibFile(&lib_path, "b", "module fixtures\n\nimport a\n\nfun bFn(u: Unit) -> Unit =\n u\n");
67
- let entry = writeLibFile(&lib_path, "a", "module fixtures\n\nimport b\n\nfun main() -> Int =\n bFn()\n");
74
+ let entry = writeLibFile(&lib_path, "a", "module fixtures\n\nimport b\n\nfun main(u: Unit) -> Unit =\n bFn(u)\n");
68
75
 
69
76
  let merged = loadAndMerge(&entry, &lib_path).expect("import cycle should resolve, not error");
70
77
  let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
@@ -79,7 +86,7 @@ fn importCycleResolvesWithoutHangingOrErroring() {
79
86
  fn unresolvableImportIsAClearError() {
80
87
  let dir = TempDir::new().unwrap();
81
88
  let lib_path = dir.path().join("libs");
82
- let entry = writeLibFile(&lib_path, "main", "module fixtures\n\nimport does_not_exist\n\nfun main() -> Int =\n 0\n");
89
+ let entry = writeLibFile(&lib_path, "main", "module fixtures\n\nimport does_not_exist\n\nfun main(u: Unit) -> Unit =\n u\n");
83
90
 
84
91
  let result = loadAndMerge(&entry, &lib_path);
85
92
  assert!(result.is_err());
@@ -91,8 +98,8 @@ fn unresolvableImportIsAClearError() {
91
98
  fn duplicateTopLevelNameAcrossFilesIsAClearError() {
92
99
  let dir = TempDir::new().unwrap();
93
100
  let lib_path = dir.path().join("libs");
94
- writeLibFile(&lib_path, "helper", "module fixtures\n\nfun sharedFn() -> Int =\n 1\n");
101
+ writeLibFile(&lib_path, "helper", "module fixtures\n\nfun sharedFn(u: Unit) -> Unit =\n u\n");
95
- let entry = writeLibFile(&lib_path, "main", "module fixtures\n\nimport helper\n\nfun sharedFn() -> Int =\n 2\n");
102
+ let entry = writeLibFile(&lib_path, "main", "module fixtures\n\nimport helper\n\nfun sharedFn(u: Unit) -> Unit =\n u\n");
96
103
 
97
104
  let result = loadAndMerge(&entry, &lib_path);
98
105
  assert!(result.is_err());
@@ -108,9 +115,9 @@ fn sameMethodNameOnDifferentReceiversAcrossFilesIsNotACollision() {
108
115
  module fixtures
109
116
 
110
117
  type Cat =
111
- age: Int
118
+ age: Unit
112
119
 
113
- fun length(self) -> Int =
120
+ fun length(self) -> Unit =
114
121
  self.age
115
122
  ");
116
123
  let entry = writeLibFile(&lib_path, "main", "\
@@ -119,13 +126,13 @@ module fixtures
119
126
  import cat
120
127
 
121
128
  type Box =
122
- items: Int
129
+ items: Unit
123
130
 
124
- fun length(self) -> Int =
131
+ fun length(self) -> Unit =
125
132
  self.items
126
133
 
127
- fun main() -> Int =
134
+ fun main(u: Unit) -> Unit =
128
- 0
135
+ u
129
136
  ");
130
137
 
131
138
  let merged = loadAndMerge(&entry, &lib_path).expect("same method name on different receivers should not collide");
{libs/std → plum-std}/Array.plum RENAMED
@@ -1,5 +1,6 @@
1
1
  module std
2
2
  import std/Bool
3
+ import std/Int
3
4
 
4
5
  # A fixed-length, O(1)-indexable array of `T`, backed directly by a wasm-gc
5
6
  # `array<anyref>` — every `Array[T]` specialization (`Array$Int`, `Array$Str`,
{libs/std/encoding → plum-std}/Base64.plum RENAMED
@@ -3,6 +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
7
 
7
8
  # The Base64 package contains support for doing Base64 binary-to-text encodings.
8
9
  #
{libs/std → plum-std}/Bool.plum RENAMED
File without changes
{libs/std → plum-std}/Buffer.plum RENAMED
@@ -2,6 +2,7 @@ module std
2
2
  import std/ByteSlice
3
3
  import std/Str
4
4
  import std/Bool
5
+ import std/Int
5
6
 
6
7
  # A Buffer is a growable, mutable sequence of bytes for efficiently building
7
8
  # up a `Str` piece by piece — modeled on Go's `bytes.Buffer`. Backed by a
{libs/std → plum-std}/Byte.plum RENAMED
@@ -1,6 +1,7 @@
1
1
  module std
2
2
  import std/Int
3
3
  import std/Bool
4
+ import std/Str
4
5
 
5
6
  # Byte is an unsigned 8-bit value (0-255) — Plum's counterpart to Go's
6
7
  # `byte`/`uint8`. `Byte(x)` converts an `Int` (wrapping, not trapping, if `x`
{libs/std → plum-std}/ByteSlice.plum RENAMED
@@ -2,6 +2,7 @@ module std
2
2
  import std/Byte
3
3
  import std/Str
4
4
  import std/Bool
5
+ import std/Int
5
6
 
6
7
  # ByteSlice is `[]Byte` — Plum's counterpart to Go's byte slice: a
7
8
  # fixed-length, mutable sequence of raw bytes backed directly by a wasm-gc
{libs/std → plum-std}/Err.plum RENAMED
@@ -1,5 +1,7 @@
1
1
  module std
2
2
  import std/Bool
3
+ import std/Int
4
+ import std/Str
3
5
 
4
6
  # This is used to represent an error value across the language
5
7
  trait Err =
{libs/std → plum-std}/Float.plum RENAMED
File without changes
libs/std/http.plum → plum-std/Http.plum RENAMED
@@ -8,6 +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
12
 
12
13
  # An HTTP response: status code, response headers, and the raw response
13
14
  # body as a `Str` (a byte array, so binary bodies round-trip intact).
{libs/std → plum-std}/Int.plum RENAMED
File without changes
{libs/std → plum-std}/Json.plum RENAMED
File without changes
{libs/std → plum-std}/List.plum RENAMED
@@ -4,6 +4,7 @@ import std/Option
4
4
  import std/Buffer
5
5
  import std/Int
6
6
  import std/Bool
7
+ import std/Str
7
8
 
8
9
  # A node stores the data in a list and contains pointers to the previous and next sibling nodes
9
10
  type Node[T] =
{libs/std → plum-std}/Map.plum RENAMED
@@ -3,6 +3,8 @@ import std/List
3
3
  import std/Option
4
4
  import std/Array
5
5
  import std/Bool
6
+ import std/Int
7
+ import std/Str
6
8
 
7
9
  # Any type usable as a `Map` key needs a `hash`, so keys landing in different
8
10
  # buckets (`hash(k) % BUCKET_COUNT`) can be told apart in O(1) without a full
{libs/std → plum-std}/Option.plum RENAMED
@@ -3,6 +3,7 @@ module std
3
3
  import std/Result
4
4
  import std/Str
5
5
  import std/Bool
6
+ import std/Int
6
7
 
7
8
  # Option[T] represents a value that may or may not be present — Plum's
8
9
  # counterpart to Rust's `Option`/Go's "zero value or ok bool" idiom.
libs/std/os.plum → plum-std/Os.plum RENAMED
@@ -4,6 +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
8
 
8
9
  # NOTE: the original file modeled `stdin`/`stdout`/`stderr` as bare top-level
9
10
  # variable bindings and a `File(...)` constructor, but this language has no
libs/std/regex.mi → plum-std/Regex.mi RENAMED
File without changes
{libs/std → plum-std}/Result.plum RENAMED
@@ -2,6 +2,7 @@ module std
2
2
  import std/Option
3
3
  import std/Str
4
4
  import std/Bool
5
+ import std/Int
5
6
 
6
7
  # Result[T, E] represents either success (`Ok`, carrying a `T`) or failure
7
8
  # (`Err`, carrying an `E`) — used throughout std for fallible operations
{libs/std → plum-std}/Str.plum RENAMED
@@ -3,6 +3,7 @@ module std
3
3
  import std/List
4
4
  import std/Buffer
5
5
  import std/Bool
6
+ import std/Int
6
7
 
7
8
  # Any type that can be converted to a str needs to implement this trait
8
9
  trait ToStr =
libs/std/testing.plum → plum-std/Testing.plum RENAMED
@@ -4,6 +4,8 @@ import std/Option
4
4
  import std/Result
5
5
  import std/List
6
6
  import std/Bool
7
+ import std/Int
8
+ import std/Str
7
9
 
8
10
  # Small jest/rspec-style assertion helpers for use inside `test` blocks, e.g.
9
11
  #
{libs/std → plum-std}/Time.plum RENAMED
@@ -2,6 +2,7 @@ module std
2
2
  import std/Int
3
3
  import std/Str
4
4
  import std/Bool
5
+ import std/Float
5
6
 
6
7
  # A raw, host-provided wall-clock reading: milliseconds since the Unix epoch.
7
8
  extern fun rawNowMillis() -> Int
{libs/std → plum-std}/Uuid.plum RENAMED
File without changes
plum-wasm-codegen/src/lib.rs CHANGED
@@ -1156,7 +1156,7 @@ fn compileSourceInner(source: &ast::Source, extra_exports: &[(String, String)])
1156
1156
  CURRENT_GC_TYPES.with(|c| *c.borrow_mut() = Some(gc_types.clone()));
1157
1157
  }
1158
1158
 
1159
- // Register every `extern fun` (e.g. `libs/std/os.plum`'s `printLn`) as a
1159
+ // Register every `extern fun` (e.g. `plum-std/Os.plum`'s `printLn`) as a
1160
1160
  // genuine wasm import BEFORE any function (including the `start` function
1161
1161
  // set up right below) — imports must occupy the low end of the function
1162
1162
  // index space for every later `addFunction`'s index arithmetic to stay
plum-wasm-codegen/tests/examples_test.rs CHANGED
@@ -6,15 +6,14 @@ fn examplesDir() -> std::path::PathBuf {
6
6
  }
7
7
 
8
8
  fn libPath() -> std::path::PathBuf {
9
- std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../libs")
9
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
10
10
  }
11
11
 
12
12
  /// Loads `name` via the REAL compilation path (`loadAndMerge`, resolving its
13
- /// own `import`s against `../libs` and merging in `Str`'s always-implicit
14
- /// `std/str` prelude — see `plum-core::loader::loadAndMerge`'s doc comment)
13
+ /// own `import`s against the repo root — see `plum-core::loader::loadAndMerge`'s
15
- /// rather than parsing it in isolation — several example files now genuinely
14
+ /// doc comment) rather than parsing it in isolation — several example files
16
- /// `import` real stdlib types (e.g. `std/option`), so a bare single-file
15
+ /// now genuinely `import` real stdlib types (e.g. `std/option`), so a bare
17
- /// parse would leave those names undeclared.
16
+ /// single-file parse would leave those names undeclared.
18
17
  fn parseFile(name: &str) -> plum_core::ast::Source {
19
18
  let path = examplesDir().join(name);
20
19
  plum_core::loadAndMerge(&path, &libPath())
scripts/test-plum.sh CHANGED
@@ -5,13 +5,14 @@
5
5
  # unit-test shortcut. Most of what used to be Rust `#[test]` functions in
6
6
  # plum-wasm-codegen/tests/codegen_tests.rs (compile a snippet, run it,
7
7
  # assert on the result) now live as `test` blocks co-located with the Plum
8
- # source they exercise — stdlib-shaped regression tests inside libs/std/*.plum
8
+ # source they exercise — stdlib-shaped regression tests inside
9
- # itself, and core-language-feature tests inside the matching examples/*.plum
9
+ # plum-std/*.plum itself, and core-language-feature tests inside the
10
- # (closures.plum, match.plum, functions.plum, types.plum, control_flow.plum) —
10
+ # matching examples/*.plum (closures.plum, match.plum, functions.plum,
11
+ # types.plum, control_flow.plum) — a Plum-language behavior is verified in
11
- # a Plum-language behavior is verified in Plum itself, not re-described in Rust.
12
+ # Plum itself, not re-described in Rust.
12
13
  #
13
14
  # `plum test` follows a file's `import`s transitively (same as `plum run`), so
14
- # running it on one libs/std file also re-runs every test in whatever it
15
+ # running it on one plum-std file also re-runs every test in whatever it
15
16
  # imports — harmless duplication, not a correctness issue.
16
17
  #
17
18
  # Files with no `test` block just report "no tests found" and are skipped
@@ -38,7 +39,7 @@ PLUM_BIN="target/debug/plum"
38
39
  # unrelated to `test`/`assert` regressions.
39
40
  is_known_incomplete() {
40
41
  case "$(basename "$1")" in
41
- http.plum) return 0 ;;
42
+ Http.plum) return 0 ;;
42
43
  *) return 1 ;;
43
44
  esac
44
45
  }
@@ -46,7 +47,7 @@ is_known_incomplete() {
46
47
  failed=0
47
48
  files_run=0
48
49
 
49
- for src in libs/std/*.plum examples/*.plum; do
50
+ for src in plum-std/*.plum examples/*.plum; do
50
51
  [ -f "$src" ] || continue
51
52
  if is_known_incomplete "$src"; then
52
53
  echo "=== $src === (skipped: known incomplete, see README's Known gaps)"