plum

#treesitter#compiler#wasm

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

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


02af96d — Peter John 2026-11-06T17:04:10+05:30
Simpler language: modules
compiler/checker/src/program.rs CHANGED
@@ -84,17 +84,19 @@ pub(crate) fn checkSourceFull(source: &ast::Source) -> (CheckResult<()>, TypedIn
84
84
  (result, info)
85
85
  }
86
86
 
87
- /// The `Io` capability rules: no top-level `extern fun`, and the primitive `Io` has no constructor.
87
+ /// The `Io` capability rules: a module-level `extern fun` takes `io: Io` first, and the
88
+ /// primitive `Io` has no constructor.
88
89
  pub(crate) fn ioCapabilityErrors(source: &ast::Source) -> Vec<CheckError> {
89
90
  let mut errors = Vec::new();
90
- // Host calls are declared only as `extern fun` methods on `primitive Io`.
91
+ // Host calls are `extern fun` methods on `primitive Io`, or module-level `extern fun`s
92
+ // whose first parameter is the capability (`io: Io`).
91
93
  for item in &source.items {
92
94
  if let ast::Item::Fn(f) = item {
93
- if f.is_extern && f.type_param.is_none() {
95
+ if f.is_extern && f.type_param.is_none() && !ast::takesIoFirst(f) {
94
96
  errors.push(CheckError::at(
95
97
  f.line,
96
98
  f.column,
97
- "host calls are declared as `extern fun` methods on `primitive Io`".to_string(),
99
+ "host calls are declared as `extern fun` methods on `primitive Io`, or as a module-level `extern fun` with `io: Io` as its first parameter".to_string(),
98
100
  ));
99
101
  }
100
102
  }
compiler/checker/tests/checker/main.rs CHANGED
@@ -4,6 +4,7 @@ mod checker_tests;
4
4
  mod closure_hint_test;
5
5
  mod diagnostic_tests;
6
6
  mod io_primitive_tests;
7
+ mod modules_tests;
7
8
  mod explicit_generics_tests;
8
9
  mod features;
9
10
  mod fip_test;
compiler/checker/tests/checker/modules_tests.rs ADDED
@@ -0,0 +1,114 @@
1
+ //! Modules: `import a/b` gives `b.f()` and `b.Const`; module-level constants and externs.
2
+ //! The module files live in `tests/modules/` (`mathx` and `shapes` both define `clamp`,
3
+ //! `twice` and `Limit`); the programs under test are written to a temp dir.
4
+ use plum_checker::checkProgram;
5
+
6
+ fn temp(name: &str, src: &str) -> std::path::PathBuf {
7
+ let dir = std::env::temp_dir().join(format!("plum_modules_{}_{}", name, std::process::id()));
8
+ std::fs::create_dir_all(&dir).unwrap();
9
+ let path = dir.join("main.plum");
10
+ std::fs::write(&path, src).unwrap();
11
+ path
12
+ }
13
+
14
+ /// Loads and checks `src` (as `main.plum`, imports against the repo root); the errors are one text.
15
+ fn run(name: &str, src: &str) -> Result<(), String> {
16
+ let path = temp(name, src);
17
+ let loaded = plum_core::loadAndMergeDiag(&path, &plum_test_support::repoRoot());
18
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
19
+ let source = loaded.map_err(|ds| ds.iter().map(|d| d.to_string()).collect::<Vec<_>>().join(" | "))?;
20
+ checkProgram(&source).0.map_err(|ds| ds.iter().map(|d| d.message.clone()).collect::<Vec<_>>().join(" | "))
21
+ }
22
+
23
+ const BOTH: &str = "import std/Int\nimport tests/modules/mathx\nimport tests/modules/shapes\n\n";
24
+
25
+ #[test]
26
+ fn qualified_calls_and_constants_check() {
27
+ run("ok", &format!("{BOTH}fun f() -> Int =\n mathx.clamp(5) + shapes.clamp(5) + mathx.Limit + shapes.Limit\n")).unwrap();
28
+ }
29
+
30
+ #[test]
31
+ fn an_unqualified_use_of_a_name_two_modules_define_is_ambiguous() {
32
+ let e = run("ambig", &format!("{BOTH}fun f() -> Int =\n clamp(5)\n")).unwrap_err();
33
+ assert!(e.contains("'clamp' is defined in several modules (mathx, shapes)"), "{e}");
34
+ assert!(!e.contains('@'), "{e}");
35
+ }
36
+
37
+ #[test]
38
+ fn an_unqualified_constant_two_modules_define_is_ambiguous() {
39
+ let e = run("ambigc", &format!("{BOTH}fun f() -> Int =\n Limit\n")).unwrap_err();
40
+ assert!(e.contains("'Limit' is defined in several modules"), "{e}");
41
+ }
42
+
43
+ #[test]
44
+ fn a_file_prefers_its_own_definition_of_a_shared_name() {
45
+ run("own", "import std/Int\nimport tests/modules/mathx\n\nfun clamp(x: Int) -> Int = x\n\nfun f() -> Int =\n clamp(500) + mathx.clamp(500)\n").unwrap();
46
+ }
47
+
48
+ #[test]
49
+ fn an_unqualified_use_resolves_to_the_one_direct_import() {
50
+ run("direct", "import std/Int\nimport tests/modules/mathx\n\nfun f() -> Int =\n clamp(500)\n").unwrap();
51
+ }
52
+
53
+ #[test]
54
+ fn a_module_without_the_function_is_an_error() {
55
+ let e = run("nofn", &format!("{BOTH}fun f() -> Int =\n mathx.nope(1)\n")).unwrap_err();
56
+ assert!(e.contains("module 'mathx' has no function 'nope'"), "{e}");
57
+ }
58
+
59
+ #[test]
60
+ fn a_module_without_the_constant_is_an_error() {
61
+ let e = run("noconst", &format!("{BOTH}fun f() -> Int =\n mathx.Missing\n")).unwrap_err();
62
+ assert!(e.contains("module 'mathx' has no constant 'Missing'"), "{e}");
63
+ }
64
+
65
+ #[test]
66
+ fn errors_show_the_source_name_not_the_renamed_one() {
67
+ let e = run("srcname", "import std/Int\nimport tests/modules/mathx\n\nfun clamp(x: Int) -> Int = true\n").unwrap_err();
68
+ assert!(e.contains("clamp"), "{e}");
69
+ assert!(!e.contains('@'), "{e}");
70
+ }
71
+
72
+ #[test]
73
+ fn a_local_named_like_a_module_wins() {
74
+ run("local", &format!("{BOTH}fun f(mathx: Int) -> Int =\n mathx + shapes.clamp(5)\n")).unwrap();
75
+ }
76
+
77
+ #[test]
78
+ fn constants_that_read_each_other_in_a_cycle_are_an_error() {
79
+ let e = run("cycle", "import std/Int\n\nA = B + 1\nB = A + 1\n\nfun f() -> Int = A\n").unwrap_err();
80
+ assert!(e.contains("cycle"), "{e}");
81
+ assert!(e.contains("A -> B -> A") || e.contains("B -> A -> B"), "{e}");
82
+ }
83
+
84
+ #[test]
85
+ fn a_constant_reading_itself_is_a_cycle() {
86
+ let e = run("self", "import std/Int\n\nA = A + 1\n\nfun f() -> Int = A\n").unwrap_err();
87
+ assert!(e.contains("cycle"), "{e}");
88
+ }
89
+
90
+ #[test]
91
+ fn constants_declared_out_of_order_check() {
92
+ run("order", "import std/Int\n\nC = B + 1\nB = A + 1\nA = 1\n\nfun f() -> Int = C\n").unwrap();
93
+ }
94
+
95
+ #[test]
96
+ fn an_extern_with_io_first_checks() {
97
+ run("ext", "import std/Io\nimport std/Float\n\nextern fun rawNowMillis(io: Io) -> Float\n\nfun f(io: Io) -> Float = rawNowMillis(io)\n").unwrap();
98
+ }
99
+
100
+ #[test]
101
+ fn an_extern_without_io_first_is_rejected() {
102
+ let e = run("extno", "import std/Float\n\nextern fun rawNowMillis() -> Float\n").unwrap_err();
103
+ assert!(e.contains("with `io: Io` as its first parameter"), "{e}");
104
+ let e = run("extint", "import std/Int\nimport std/Float\n\nextern fun rawNowMillis(x: Int) -> Float\n").unwrap_err();
105
+ assert!(e.contains("with `io: Io` as its first parameter"), "{e}");
106
+ }
107
+
108
+ #[test]
109
+ fn primitive_outside_std_is_rejected_for_a_built_in_and_for_an_extension() {
110
+ let e = run("primint", "primitive Int =\n fun f(self) -> Byte =\n self\n").unwrap_err();
111
+ assert!(e.contains("only allowed in std"), "{e}");
112
+ let e = run("primext", "import std/Int\n\nstruct Pt(x: Int)\n\nprimitive Pt =\n fun g(self) -> Int = 1\n").unwrap_err();
113
+ assert!(e.contains("only allowed in std"), "{e}");
114
+ }
compiler/core/src/ast.rs CHANGED
@@ -328,6 +328,12 @@ pub const BUILTIN_PRIMITIVES: [&str; 7] = ["Int", "Float", "Bool", "Str", "Byte"
328
328
  /// `primitive Io =` (std/Io.plum); extern methods are allowed nowhere else.
329
329
  pub const IO_TYPE: &str = "Io";
330
330
 
331
+ /// Whether `f`'s first parameter is the `Io` capability (`io: Io`): what a module-level
332
+ /// `extern fun` needs, and what its host import does not receive.
333
+ pub fn takesIoFirst(f: &Fn) -> bool {
334
+ matches!(f.params.first(), Some(Param { ty: ParamType::Type(t), .. }) if t.name == IO_TYPE && t.generics.is_empty())
335
+ }
336
+
331
337
  /// Separator of an associated item's mangled global name: `Float.Pi`.
332
338
  pub const MEMBER_SEP: &str = ".";
333
339
 
compiler/core/src/diag.rs CHANGED
@@ -32,7 +32,8 @@ pub struct Diagnostic {
32
32
 
33
33
  impl Diagnostic {
34
34
  pub fn new(kind: DiagKind, file: impl Into<String>, line: usize, column: usize, message: impl Into<String>) -> Self {
35
+ let message: String = message.into();
35
- Diagnostic { file: file.into(), line, column, end_line: 0, end_column: 0, kind, message: message.into() }
36
+ Diagnostic { file: file.into(), line, column, end_line: 0, end_column: 0, kind, message: sourceName(&message) }
36
37
  }
37
38
 
38
39
  /// The same diagnostic with the end of the failing node's span (1-based, exclusive).
@@ -58,3 +59,32 @@ impl fmt::Display for Diagnostic {
58
59
  }
59
60
  }
60
61
  }
62
+
63
+ /// `text` with the module mark of renamed definitions removed (`clamp@mathx` -> `clamp`), so
64
+ /// messages show the names as written in the source (see `modules`).
65
+ pub fn sourceName(text: &str) -> String {
66
+ if !text.contains(crate::modules::MODULE_MARK) {
67
+ return text.to_string();
68
+ }
69
+ let isId = |c: char| c.is_alphanumeric() || c == '_';
70
+ let chars: Vec<char> = text.chars().collect();
71
+ let mut out = String::with_capacity(text.len());
72
+ let mut i = 0;
73
+ while i < chars.len() {
74
+ let c = chars[i];
75
+ let marked = c == crate::modules::MODULE_MARK
76
+ && i > 0
77
+ && isId(chars[i - 1])
78
+ && chars.get(i + 1).is_some_and(|n| n.is_alphabetic() || *n == '_');
79
+ if marked {
80
+ i += 1;
81
+ while i < chars.len() && isId(chars[i]) {
82
+ i += 1;
83
+ }
84
+ continue;
85
+ }
86
+ out.push(c);
87
+ i += 1;
88
+ }
89
+ out
90
+ }
compiler/core/src/lib.rs CHANGED
@@ -19,6 +19,7 @@ pub mod layout;
19
19
  pub mod loader;
20
20
  pub mod lower;
21
21
  pub mod main_result;
22
+ pub mod modules;
22
23
  pub mod parser;
23
24
  pub mod purity;
24
25
  pub mod rename;
compiler/core/src/loader.rs CHANGED
@@ -67,12 +67,14 @@ pub fn loadAndMergeWithOverridesDiag(
67
67
  collectImport(import, lib_path, overrides, &mut visited, &mut files)?;
68
68
  }
69
69
  shadowStdTypes(&mut files, lib_path)?;
70
+ resolveFileModules(&mut files, lib_path)?;
70
71
  for f in &files {
71
72
  mergeItems(&f.path, &f.source, &mut items, &mut names, &mut prims, lib_path)?;
72
73
  }
73
74
  let entry_source = files.swap_remove(0).source;
74
75
 
75
76
  dropExtensions(&items, &mut prims)?;
77
+ crate::modules::orderConsts(&mut items)?;
76
78
  let mut merged = Source {
77
79
  file: entry_source.file,
78
80
  module: entry_source.module,
@@ -192,6 +194,31 @@ fn collectImport(
192
194
  Ok(())
193
195
  }
194
196
 
197
+ /// Gives each file's `import a/b` the name `b` for that module's functions and constants
198
+ /// (see `modules`), renaming the ones several files define.
199
+ fn resolveFileModules(files: &mut [Loaded], lib_path: &Path) -> Loaded_<()> {
200
+ let index: HashMap<PathBuf, usize> = files.iter().enumerate().map(|(i, f)| (f.path.clone(), i)).collect();
201
+ let infos: Vec<crate::modules::ModuleInfo> = files
202
+ .iter()
203
+ .map(|f| crate::modules::ModuleInfo {
204
+ name: f.path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(),
205
+ path: displayPath(&f.path),
206
+ imports: f
207
+ .source
208
+ .imports
209
+ .iter()
210
+ .filter_map(|imp| {
211
+ let target = resolveImport(imp, lib_path).ok()?;
212
+ let alias = imp.path.rsplit('/').next().unwrap_or(&imp.path).to_string();
213
+ Some((alias, *index.get(&target)?))
214
+ })
215
+ .collect(),
216
+ })
217
+ .collect();
218
+ let mut sources: Vec<&mut Source> = files.iter_mut().map(|f| &mut f.source).collect();
219
+ crate::modules::resolveModules(infos, &mut sources)
220
+ }
221
+
195
222
  fn declaredTypes(source: &Source) -> Vec<String> {
196
223
  source
197
224
  .items
compiler/core/src/modules.rs ADDED
@@ -0,0 +1,376 @@
1
+ //! Modules. `import a/b` makes `b` a name for that file's top-level functions and
2
+ //! constants: `b.f()`, `b.Const`. Types and enum variants stay unqualified.
3
+ //!
4
+ //! Two files may define the same function or constant name. Such a name is renamed per
5
+ //! module while the files are merged (`clamp` in `mathx` becomes `clamp@mathx`), so every
6
+ //! later pass keeps seeing plain global names and needs no change; diagnostics print the
7
+ //! source name (see `diag::sourceName`). A name only one file defines keeps its name, and
8
+ //! stays callable unqualified (the fallback T5 removes).
9
+ //!
10
+ //! Resolution of a use in file F, for a name defined in several files:
11
+ //! - `m.f()` / `m.C` with `m` an import of F (and not a local of the function): that module's;
12
+ //! - an unqualified use: F's own definition, else the one F imports directly, else an error.
13
+ //!
14
+ //! A local named like an import wins over the module (the locals of a function are its
15
+ //! parameters and every binder in its body).
16
+ use crate::ast::*;
17
+ use crate::diag::{DiagKind, Diagnostic};
18
+ use crate::rename::{renameStmts, Name};
19
+ use crate::visit::{visitBlock, visitExpr};
20
+ use std::collections::{HashMap, HashSet};
21
+
22
+ /// Separates a source name from the module of a renamed definition: `clamp@mathx`.
23
+ pub const MODULE_MARK: char = '@';
24
+
25
+ /// What the loader knows about one file.
26
+ pub struct ModuleInfo {
27
+ /// The file's stem: the name its importers use.
28
+ pub name: String,
29
+ /// For diagnostics.
30
+ pub path: String,
31
+ /// `(name, file index)` of each direct import.
32
+ pub imports: Vec<(String, usize)>,
33
+ }
34
+
35
+ /// A top-level function a module may rename: not a method, host import, export or `main`.
36
+ fn renamable(f: &Fn) -> bool {
37
+ f.type_param.is_none() && !f.is_extern && !f.is_export && !f.is_test && f.name != "main"
38
+ }
39
+
40
+ fn isModuleConst(c: &Const) -> bool {
41
+ !c.name.contains(MEMBER_SEP)
42
+ }
43
+
44
+ struct World {
45
+ infos: Vec<ModuleInfo>,
46
+ /// Per file: every top-level fn name (any kind), and every module-level const name.
47
+ fns: Vec<HashSet<String>>,
48
+ consts: Vec<HashSet<String>>,
49
+ /// Names defined in two or more files (renamable fns / consts only).
50
+ fnCollide: HashSet<String>,
51
+ constCollide: HashSet<String>,
52
+ }
53
+
54
+ fn mangle(name: &str, module: &str) -> String {
55
+ format!("{name}{MODULE_MARK}{module}")
56
+ }
57
+
58
+ impl World {
59
+ fn global(&self, file: usize, name: &str, collide: &HashSet<String>) -> String {
60
+ if collide.contains(name) {
61
+ mangle(name, &self.infos[file].name)
62
+ } else {
63
+ name.to_string()
64
+ }
65
+ }
66
+ }
67
+
68
+ pub fn resolveModules(infos: Vec<ModuleInfo>, sources: &mut [&mut Source]) -> Result<(), Vec<Diagnostic>> {
69
+ let n = sources.len();
70
+ let mut fns = vec![HashSet::new(); n];
71
+ let mut consts = vec![HashSet::new(); n];
72
+ let mut fnCount: HashMap<String, usize> = HashMap::new();
73
+ let mut constCount: HashMap<String, usize> = HashMap::new();
74
+ for (i, s) in sources.iter().enumerate() {
75
+ let mut ownFns = HashSet::new();
76
+ let mut ownConsts = HashSet::new();
77
+ for item in &s.items {
78
+ match item {
79
+ Item::Fn(f) if f.type_param.is_none() => {
80
+ if renamable(f) && ownFns.insert(f.name.clone()) {
81
+ *fnCount.entry(f.name.clone()).or_default() += 1;
82
+ }
83
+ fns[i].insert(f.name.clone());
84
+ }
85
+ Item::Const(c) if isModuleConst(c) => {
86
+ if ownConsts.insert(c.name.clone()) {
87
+ *constCount.entry(c.name.clone()).or_default() += 1;
88
+ }
89
+ consts[i].insert(c.name.clone());
90
+ }
91
+ _ => {}
92
+ }
93
+ }
94
+ }
95
+ let collide = |m: &HashMap<String, usize>| -> HashSet<String> {
96
+ m.iter().filter(|(_, &c)| c > 1).map(|(k, _)| k.clone()).collect()
97
+ };
98
+ let world = World { fnCollide: collide(&fnCount), constCollide: collide(&constCount), infos, fns, consts };
99
+ // A program whose files import nothing has no `m.f()` to resolve.
100
+ if world.fnCollide.is_empty() && world.constCollide.is_empty() && world.infos.iter().all(|i| i.imports.is_empty()) {
101
+ return Ok(());
102
+ }
103
+ let mut errors = Vec::new();
104
+ for (i, s) in sources.iter_mut().enumerate() {
105
+ rewriteFile(&world, i, s, &mut errors);
106
+ }
107
+ if errors.is_empty() {
108
+ Ok(())
109
+ } else {
110
+ Err(errors)
111
+ }
112
+ }
113
+
114
+ #[derive(Clone, Copy)]
115
+ enum Kind {
116
+ Fn,
117
+ Const,
118
+ }
119
+
120
+ struct Cx<'a> {
121
+ world: &'a World,
122
+ file: usize,
123
+ aliases: HashMap<String, usize>,
124
+ locals: HashSet<String>,
125
+ errors: &'a mut Vec<Diagnostic>,
126
+ }
127
+
128
+ impl Cx<'_> {
129
+ fn error(&mut self, line: usize, column: usize, message: String) {
130
+ let path = self.world.infos[self.file].path.clone();
131
+ self.errors.push(Diagnostic::new(DiagKind::Resolution, path, line, column, message));
132
+ }
133
+
134
+ /// Which file an unqualified use of the colliding `name` means; `None` after an error.
135
+ fn pick(&mut self, name: &str, kind: Kind, line: usize, column: usize) -> Option<usize> {
136
+ let world = self.world;
137
+ let defines = |f: usize| match kind {
138
+ Kind::Fn => world.fns[f].contains(name),
139
+ Kind::Const => world.consts[f].contains(name),
140
+ };
141
+ if defines(self.file) {
142
+ return Some(self.file);
143
+ }
144
+ let direct: Vec<usize> = world.infos[self.file]
145
+ .imports
146
+ .iter()
147
+ .map(|(_, t)| *t)
148
+ .filter(|t| *t != self.file && defines(*t))
149
+ .collect();
150
+ if direct.len() == 1 {
151
+ return Some(direct[0]);
152
+ }
153
+ let mut owners: Vec<String> =
154
+ (0..world.infos.len()).filter(|f| defines(*f)).map(|f| world.infos[f].name.clone()).collect();
155
+ owners.sort();
156
+ let hint = owners.first().cloned().unwrap_or_default();
157
+ self.error(
158
+ line,
159
+ column,
160
+ format!("'{name}' is defined in several modules ({}): write {hint}.{name}", owners.join(", ")),
161
+ );
162
+ None
163
+ }
164
+
165
+ fn visit(&mut self, e: &mut Expr) {
166
+ let (line, column) = (e.span.startLine, e.span.startColumn);
167
+ let world = self.world;
168
+ match &mut e.kind {
169
+ ExprKind::Attribute(a) => {
170
+ let ExprKind::Var(m) = &a.object.kind else { return };
171
+ if self.locals.contains(m) {
172
+ return;
173
+ }
174
+ let Some(&target) = self.aliases.get(m) else { return };
175
+ let module = m.clone();
176
+ let replacement = match &a.attr {
177
+ AttrKind::Method(call) => {
178
+ if !world.fns[target].contains(&call.name) {
179
+ self.error(call.line, call.column, format!("module '{module}' has no function '{}'", call.name));
180
+ return;
181
+ }
182
+ let mut call = call.clone();
183
+ call.name = world.global(target, &call.name, &world.fnCollide);
184
+ Expr::FnCall(call)
185
+ }
186
+ AttrKind::Field(name) => {
187
+ if !world.consts[target].contains(name) {
188
+ self.error(line, column, format!("module '{module}' has no constant '{name}'"));
189
+ return;
190
+ }
191
+ Expr::TypeName(world.global(target, name, &world.constCollide))
192
+ }
193
+ };
194
+ *e = replacement.at(e);
195
+ }
196
+ ExprKind::FnCall(c) if world.fnCollide.contains(&c.name) && !self.locals.contains(&c.name) => {
197
+ let (name, l, col) = (c.name.clone(), c.line, c.column);
198
+ if let Some(t) = self.pick(&name, Kind::Fn, l, col) {
199
+ if let ExprKind::FnCall(c) = &mut e.kind {
200
+ c.name = world.global(t, &name, &world.fnCollide);
201
+ }
202
+ }
203
+ }
204
+ ExprKind::Var(n) if world.fnCollide.contains(n.as_str()) && !self.locals.contains(n.as_str()) => {
205
+ let name = n.clone();
206
+ if let Some(t) = self.pick(&name, Kind::Fn, line, column) {
207
+ e.kind = ExprKind::Var(world.global(t, &name, &world.fnCollide));
208
+ }
209
+ }
210
+ ExprKind::TypeName(n) if world.constCollide.contains(n.as_str()) => {
211
+ let name = n.clone();
212
+ if let Some(t) = self.pick(&name, Kind::Const, line, column) {
213
+ e.kind = ExprKind::TypeName(world.global(t, &name, &world.constCollide));
214
+ }
215
+ }
216
+ _ => {}
217
+ }
218
+ }
219
+ }
220
+
221
+ /// Every name a body declares: parameters, `:=` targets, loop binders, pattern names and
222
+ /// closure parameters.
223
+ fn bodyLocals(params: &[Param], stmts: &[Stmt]) -> HashSet<String> {
224
+ let mut out: HashSet<String> = params.iter().map(|p| p.name.clone()).collect();
225
+ let mut stmts = stmts.to_vec();
226
+ renameStmts(&mut stmts, &mut |n, kind| {
227
+ if matches!(kind, Name::Decl | Name::Pat) {
228
+ out.insert(n.clone());
229
+ }
230
+ });
231
+ out
232
+ }
233
+
234
+ /// The names a function declares anywhere in its parameters and body (see `bodyLocals`).
235
+ pub fn fnLocals(f: &Fn) -> HashSet<String> {
236
+ let body = match &f.body {
237
+ FnBody::Block(b) => b.stmts.clone(),
238
+ FnBody::Expr(e) => vec![Stmt::Expr(e.clone(), 0, 0)],
239
+ FnBody::Extern => Vec::new(),
240
+ };
241
+ bodyLocals(&f.params, &body)
242
+ }
243
+
244
+ fn rewriteFile(world: &World, file: usize, source: &mut Source, errors: &mut Vec<Diagnostic>) {
245
+ for item in &mut source.items {
246
+ match item {
247
+ Item::Fn(f) if renamable(f) && world.fnCollide.contains(&f.name) => {
248
+ f.name = mangle(&f.name, &world.infos[file].name);
249
+ }
250
+ Item::Const(c) if isModuleConst(c) && world.constCollide.contains(&c.name) => {
251
+ c.name = mangle(&c.name, &world.infos[file].name);
252
+ }
253
+ _ => {}
254
+ }
255
+ }
256
+ let aliases: HashMap<String, usize> = world.infos[file]
257
+ .imports
258
+ .iter()
259
+ .filter(|(_, t)| *t != file)
260
+ .map(|(a, t)| (a.clone(), *t))
261
+ .collect();
262
+ let mut cx = Cx { world, file, aliases, locals: HashSet::new(), errors };
263
+ for item in &mut source.items {
264
+ match item {
265
+ Item::Fn(f) => {
266
+ cx.locals = fnLocals(f);
267
+ for p in &mut f.params {
268
+ if let Some(d) = &mut p.default {
269
+ visitExpr(d, &mut |e| cx.visit(e));
270
+ }
271
+ }
272
+ match &mut f.body {
273
+ FnBody::Block(b) => visitBlock(b, &mut |e| cx.visit(e)),
274
+ FnBody::Expr(e) => visitExpr(e, &mut |e| cx.visit(e)),
275
+ FnBody::Extern => {}
276
+ }
277
+ }
278
+ Item::Test(t) => {
279
+ cx.locals = bodyLocals(&[], &t.body.stmts);
280
+ visitBlock(&mut t.body, &mut |e| cx.visit(e));
281
+ }
282
+ Item::Const(c) => {
283
+ cx.locals.clear();
284
+ visitExpr(&mut c.value, &mut |e| cx.visit(e));
285
+ }
286
+ Item::Enum(_) => {}
287
+ }
288
+ }
289
+ }
290
+
291
+ /// Puts each constant after the constants its initializer reads (the slots of the
292
+ /// constants keep their places among the other items), and rejects a cycle.
293
+ pub fn orderConsts(items: &mut [Item]) -> Result<(), Vec<Diagnostic>> {
294
+ let slots: Vec<usize> = (0..items.len()).filter(|i| matches!(items[*i], Item::Const(_))).collect();
295
+ if slots.is_empty() {
296
+ return Ok(());
297
+ }
298
+ let index: HashMap<String, usize> = slots
299
+ .iter()
300
+ .enumerate()
301
+ .filter_map(|(k, s)| match &items[*s] {
302
+ Item::Const(c) => Some((c.name.clone(), k)),
303
+ _ => None,
304
+ })
305
+ .collect();
306
+ let deps: Vec<Vec<usize>> = slots
307
+ .iter()
308
+ .map(|s| {
309
+ let Item::Const(c) = &items[*s] else { unreachable!() };
310
+ let mut value = c.value.clone();
311
+ let mut found = Vec::new();
312
+ visitExpr(&mut value, &mut |e| {
313
+ if let ExprKind::TypeName(n) = &e.kind {
314
+ if let Some(k) = index.get(n) {
315
+ found.push(*k);
316
+ }
317
+ }
318
+ });
319
+ found
320
+ })
321
+ .collect();
322
+ // 0 = new, 1 = on the stack, 2 = done.
323
+ let mut state = vec![0u8; slots.len()];
324
+ let mut order: Vec<usize> = Vec::new();
325
+ fn dfs(k: usize, deps: &[Vec<usize>], state: &mut [u8], order: &mut Vec<usize>, path: &mut Vec<usize>) -> Option<Vec<usize>> {
326
+ match state[k] {
327
+ 2 => return None,
328
+ 1 => {
329
+ let at = path.iter().position(|p| *p == k).unwrap_or(0);
330
+ let mut cycle = path[at..].to_vec();
331
+ cycle.push(k);
332
+ return Some(cycle);
333
+ }
334
+ _ => {}
335
+ }
336
+ state[k] = 1;
337
+ path.push(k);
338
+ for d in &deps[k] {
339
+ if let Some(c) = dfs(*d, deps, state, order, path) {
340
+ return Some(c);
341
+ }
342
+ }
343
+ path.pop();
344
+ state[k] = 2;
345
+ order.push(k);
346
+ None
347
+ }
348
+ for k in 0..slots.len() {
349
+ if let Some(cycle) = dfs(k, &deps, &mut state, &mut order, &mut Vec::new()) {
350
+ let name = |k: usize| match &items[slots[k]] {
351
+ Item::Const(c) => c.name.clone(),
352
+ _ => String::new(),
353
+ };
354
+ let line = match &items[slots[cycle[0]]] {
355
+ Item::Const(c) => c.line,
356
+ _ => 0,
357
+ };
358
+ let text = cycle.iter().map(|k| name(*k)).collect::<Vec<_>>().join(" -> ");
359
+ return Err(vec![Diagnostic::new(
360
+ DiagKind::Resolution,
361
+ "",
362
+ line,
363
+ 1,
364
+ format!("constants refer to each other in a cycle: {text}"),
365
+ )]);
366
+ }
367
+ }
368
+ if order.iter().enumerate().all(|(i, k)| i == *k) {
369
+ return Ok(());
370
+ }
371
+ let taken: Vec<Item> = slots.iter().map(|s| items[*s].clone()).collect();
372
+ for (slot, k) in slots.iter().zip(order) {
373
+ items[*slot] = taken[k].clone();
374
+ }
375
+ Ok(())
376
+ }
compiler/core/src/parser.rs CHANGED
@@ -160,6 +160,11 @@ impl<'a> AstParser<'a> {
160
160
  items.push(Item::Fn(self.parseFn(child)));
161
161
  topLevelOrder.push(TopLevelKind::Other);
162
162
  }
163
+ // A module-level constant `ArenaW = 800.0`: a global under its own name.
164
+ "const" => {
165
+ items.push(Item::Const(self.parseConst(child)));
166
+ topLevelOrder.push(TopLevelKind::Other);
167
+ }
163
168
  "test" => {
164
169
  items.push(Item::Test(self.parseTest(child)));
165
170
  topLevelOrder.push(TopLevelKind::Test);
compiler/core/tests/core/formatter_test.rs CHANGED
@@ -413,3 +413,12 @@ fn formatFnGenericsStayTight() {
413
413
  assert!(out.contains("fun pair[A, Elem](a: A, b: Elem) -> A = a"), "{out}");
414
414
  assert_eq!(formatSource(&out).expect("second"), out);
415
415
  }
416
+
417
+ /// A module-level constant is its own line and keeps a blank line the source had.
418
+ #[test]
419
+ fn moduleLevelConstantsFormat() {
420
+ let input = "import std/Float\n\nArenaW = 800.0\nArenaH = 600.0\n\nArea = ArenaW * ArenaH\n\nfun f() -> Float =\n mathx.Limit\n";
421
+ let out = formatSource(input).expect("formats");
422
+ assert_eq!(out, input);
423
+ assert_eq!(formatSource(&out).expect("formats twice"), out);
424
+ }
compiler/core/tests/core/loader_test.rs CHANGED
@@ -179,18 +179,13 @@ fn unresolvableImportIsAClearError() {
179
179
  }
180
180
 
181
181
  #[test]
182
- fn duplicateTopLevelNameAcrossFilesIsAClearError() {
182
+ fn duplicateTopLevelNameInOneFileIsAClearError() {
183
183
  let dir = TempDir::new().unwrap();
184
184
  let lib_path = dir.path().join("libs");
185
- writeLibFile(
186
- &lib_path,
187
- "helper",
188
- "module fixtures\n\nfun sharedFn(u: Unit) -> Unit =\n u\n",
189
- );
190
185
  let entry = writeLibFile(
191
186
  &lib_path,
192
187
  "main",
193
- "module fixtures\n\nimport helper\n\nfun sharedFn(u: Unit) -> Unit =\n u\n",
188
+ "module fixtures\n\nfun sharedFn(u: Unit) -> Unit =\n u\n\nfun sharedFn(u: Unit) -> Unit =\n u\n",
194
189
  );
195
190
 
196
191
  let result = loadAndMergeDiag(&entry, &lib_path);
@@ -203,6 +198,34 @@ fn duplicateTopLevelNameAcrossFilesIsAClearError() {
203
198
  );
204
199
  }
205
200
 
201
+ #[test]
202
+ fn sameTopLevelNameInTwoModulesIsNotACollision() {
203
+ let dir = TempDir::new().unwrap();
204
+ let lib_path = dir.path().join("libs");
205
+ writeLibFile(&lib_path, "helper", "module fixtures\n\nfun sharedFn(u: Unit) -> Unit =\n u\n");
206
+ let entry = writeLibFile(
207
+ &lib_path,
208
+ "main",
209
+ "module fixtures\n\nimport helper\n\nfun sharedFn(u: Unit) -> Unit =\n helper.sharedFn(u)\n",
210
+ );
211
+ let merged = loadAndMergeDiag(&entry, &lib_path).expect("two modules may share a function name");
212
+ let names: Vec<String> = merged
213
+ .items
214
+ .iter()
215
+ .filter_map(|i| match i {
216
+ plum_core::ast::Item::Fn(f) => Some(f.name.clone()),
217
+ _ => None,
218
+ })
219
+ .collect();
220
+ assert_eq!(names.len(), 2);
221
+ assert_ne!(names[0], names[1], "{names:?}");
222
+ }
223
+
224
+ #[test]
225
+ fn diagnosticsShowTheSourceNameOfARenamedDefinition() {
226
+ assert_eq!(plum_core::diag::sourceName("fn 'clamp@mathx': bad; also [email protected] and x @ y"), "fn 'clamp': bad; also a.c and x @ y");
227
+ }
228
+
206
229
  #[test]
207
230
  fn sameMethodNameOnDifferentReceiversAcrossFilesIsNotACollision() {
208
231
  let dir = TempDir::new().unwrap();
@@ -262,7 +285,6 @@ fn removedSyntaxIsAParseError() {
262
285
  "fun f(u: Unit) -> Unit??\n u\n",
263
286
  "fun f(u: Unit) -> Unit? =\n u\n",
264
287
  "fun f(u: Unit) -> Unit? ! [X] =\n u\n",
265
- "Pi = 3\n",
266
288
  "extend Foo =\n fun f() -> Unit =\n todo\n",
267
289
  ] {
268
290
  let dir = TempDir::new().unwrap();
compiler/driver/src/lib.rs CHANGED
@@ -99,7 +99,7 @@ fn fusion(copy: &mut Source, env: &Env) -> bool {
99
99
  env.errors.borrow_mut().extend(errors);
100
100
  if env.explain {
101
101
  for n in &notes {
102
- eprintln!("fusion: {n}");
102
+ eprintln!("fusion: {}", plum_core::diag::sourceName(n));
103
103
  }
104
104
  }
105
105
  !notes.is_empty()
compiler/lsp/src/completion.rs CHANGED
@@ -75,7 +75,7 @@ pub fn topLevelItems(source: &ast::Source) -> Vec<CompletionItem> {
75
75
  match item {
76
76
  ast::Item::Fn(f) if f.type_param.is_none() => {
77
77
  items.push(CompletionItem {
78
- label: f.name.clone(),
78
+ label: plum_core::diag::sourceName(&f.name),
79
79
  kind: Some(CompletionItemKind::FUNCTION),
80
80
  detail: Some("fn".to_string()),
81
81
  ..Default::default()
@@ -99,7 +99,7 @@ pub fn topLevelItems(source: &ast::Source) -> Vec<CompletionItem> {
99
99
  }
100
100
  ast::Item::Const(c) => {
101
101
  items.push(CompletionItem {
102
- label: c.name.clone(),
102
+ label: plum_core::diag::sourceName(&c.name),
103
103
  kind: Some(CompletionItemKind::CONSTANT),
104
104
  ..Default::default()
105
105
  });
@@ -160,6 +160,39 @@ pub fn staticItems(source: &ast::Source, owner: &str) -> Vec<CompletionItem> {
160
160
  items
161
161
  }
162
162
 
163
+ /// Completion items for `module.`: the top-level functions and constants of the file
164
+ /// `import .../module` names (read from its own text, so each keeps its source name).
165
+ pub fn moduleItems(libPath: &Path, importPath: &str) -> Vec<CompletionItem> {
166
+ let Ok(text) = std::fs::read_to_string(libPath.join(format!("{importPath}.plum"))) else {
167
+ return Vec::new();
168
+ };
169
+ let mut parser = tree_sitter::Parser::new();
170
+ if parser.set_language(&tree_sitter_plum::LANGUAGE.into()).is_err() {
171
+ return Vec::new();
172
+ }
173
+ let Some(tree) = parser.parse(&text, None) else { return Vec::new() };
174
+ let module = plum_core::AstParser::new(&text).parseSourceRaw(tree.root_node());
175
+ let mut items = Vec::new();
176
+ for item in &module.items {
177
+ match item {
178
+ ast::Item::Fn(f) if f.type_param.is_none() && !f.is_test => items.push(CompletionItem {
179
+ label: f.name.clone(),
180
+ kind: Some(CompletionItemKind::FUNCTION),
181
+ detail: Some(format!("fun {importPath}.{}", f.name)),
182
+ ..Default::default()
183
+ }),
184
+ ast::Item::Const(c) if !c.name.contains(ast::MEMBER_SEP) => items.push(CompletionItem {
185
+ label: c.name.clone(),
186
+ kind: Some(CompletionItemKind::CONSTANT),
187
+ detail: Some(format!("{importPath}.{}", c.name)),
188
+ ..Default::default()
189
+ }),
190
+ _ => {}
191
+ }
192
+ }
193
+ items
194
+ }
195
+
163
196
  /// Constructor parameters (name, type text) of struct `owner`: its own fields.
164
197
  /// Fields set by `init` are not constructor parameters. `own` is the unlowered
165
198
  /// parse of the document (header intact); `merged` is the fallback.
compiler/lsp/src/main.rs CHANGED
@@ -325,14 +325,24 @@ fn memberCompletionFor(
325
325
  cursor_line: usize,
326
326
  receiver: &str,
327
327
  ) -> Vec<CompletionItem> {
328
+ let tree = Backend::parseTree(text);
329
+ let own_source = plum_core::AstParser::new(text).parseSourceRaw(tree.root_node());
330
+ // `module.`: an imported module's functions and constants, unless a local has that name.
331
+ // Before the load: the placeholder member (`zzzzzz`) is not a function of the module.
332
+ let isModuleName = receiver.chars().next().is_some_and(|c| c.is_ascii_lowercase());
333
+ if let Some(imp) = own_source.imports.iter().filter(|_| isModuleName).find(|i| i.path.rsplit('/').next() == Some(receiver)) {
334
+ let isLocal = completion::enclosingFn(&own_source, cursor_line)
335
+ .is_some_and(|f| plum_core::modules::fnLocals(f).contains(receiver));
336
+ if !isLocal {
337
+ return completion::moduleItems(libPath, &imp.path);
338
+ }
339
+ }
328
340
  let canon = std::fs::canonicalize(path).unwrap_or(path.to_path_buf());
329
341
  let mut overrides = HashMap::new();
330
342
  overrides.insert(canon, text.to_string());
331
343
  let Ok(merged) = plum_core::loadAndMergeWithOverridesDiag(path, libPath, &overrides) else {
332
344
  return Vec::new();
333
345
  };
334
- let tree = Backend::parseTree(text);
335
- let own_source = plum_core::AstParser::new(text).parseSourceRaw(tree.root_node());
336
346
  // `Type.`: its associated consts and static functions.
337
347
  if receiver
338
348
  .chars()
@@ -690,6 +700,31 @@ mod tests {
690
700
  let _ = std::fs::remove_dir_all(&dir);
691
701
  }
692
702
 
703
+ /// `module.` offers the functions and constants of the imported module (source names), and a
704
+ /// local named like the module gets its own members instead.
705
+ #[test]
706
+ fn lsp_completes_a_module_and_shows_source_names() {
707
+ let dir = std::env::temp_dir().join(format!("plum-lsp-module-{}", std::process::id()));
708
+ std::fs::create_dir_all(&dir).unwrap();
709
+ let path = dir.join("t.plum");
710
+ let text = "import std/Int\nimport tests/modules/mathx\nimport tests/modules/shapes\n\nfun main() =\n x := mathx.zzzzzz\n";
711
+ std::fs::write(&path, text).unwrap();
712
+ let labels: Vec<String> = memberCompletionFor(&path, &repoRoot(), text, 6, "mathx")
713
+ .into_iter()
714
+ .map(|i| i.label)
715
+ .collect();
716
+ for want in ["clamp", "twice", "onlyHere", "Limit", "Double"] {
717
+ assert!(labels.iter().any(|l| l == want), "missing {want} in {labels:?}");
718
+ }
719
+ assert!(!labels.iter().any(|l| l.contains('@')), "{labels:?}");
720
+ let ok = "import std/Int\nimport tests/modules/mathx\nimport tests/modules/shapes\n\nfun clamp(x: Int) -> Int = x\n\nfun main() =\n _ := mathx.clamp(1)\n";
721
+ std::fs::write(&path, ok).unwrap();
722
+ let merged = plum_core::loadAndMergeDiag(&path, &repoRoot()).expect("loads");
723
+ let top: Vec<String> = completion::topLevelItems(&merged).into_iter().map(|i| i.label).collect();
724
+ assert!(top.iter().any(|l| l == "clamp") && !top.iter().any(|l| l.contains('@')), "{top:?}");
725
+ let _ = std::fs::remove_dir_all(&dir);
726
+ }
727
+
693
728
  #[test]
694
729
  fn keywordsCurrent() {
695
730
  let labels: Vec<String> = completion::keywordItems()
compiler/tooling/tree-sitter-plum/grammar.js CHANGED
@@ -77,7 +77,7 @@ module.exports = grammar({
77
77
  seq(
78
78
  optional($.module),
79
79
  repeat($.import),
80
- repeat(choice($.enum, $.struct, $.primitive, $.fn, $.test)),
80
+ repeat(choice($.enum, $.struct, $.primitive, $.fn, $.const, $.test)),
81
81
  ),
82
82
 
83
83
  module: ($) => seq("module", $.mod_identifier),
compiler/tooling/tree-sitter-plum/queries/plum/format.scm CHANGED
@@ -12,6 +12,9 @@
12
12
  (source
13
13
  (fn) @allow_blank_line_before)
14
14
 
15
+ (source
16
+ (const) @allow_blank_line_before)
17
+
15
18
  (source
16
19
  (enum) @allow_blank_line_before)
17
20
 
compiler/tooling/tree-sitter-plum/queries/plum/highlights.scm CHANGED
@@ -12,6 +12,11 @@
12
12
  object: (primary_expression (type_identifier))
13
13
  member: (type_identifier) @constant)
14
14
 
15
+ ; `module.Name`: a constant of an imported module.
16
+ (attribute
17
+ object: (primary_expression (var_identifier))
18
+ member: (type_identifier) @constant)
19
+
15
20
  ; (mod_identifier) @constant
16
21
  (type_identifier) @type
17
22
  (fn_identifier) @function
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/const.txt CHANGED
@@ -185,3 +185,29 @@ fun main() =
185
185
  (type_identifier)))
186
186
  (primary_expression
187
187
  (float)))))))))
188
+
189
+ ================================================================================
190
+ module-level const
191
+ ================================================================================
192
+
193
+ ArenaW = 800.0
194
+
195
+ fun f() -> Float =
196
+ ArenaW
197
+
198
+ --------------------------------------------------------------------------------
199
+
200
+ (source
201
+ (const
202
+ (type_identifier)
203
+ (expression
204
+ (primary_expression
205
+ (float))))
206
+ (fn
207
+ (fn_identifier)
208
+ (type
209
+ (type_identifier))
210
+ (body
211
+ (expression
212
+ (primary_expression
213
+ (type_identifier))))))
compiler/tooling/vscode-plum/syntaxes/plum.tmLanguage.json CHANGED
@@ -102,8 +102,20 @@
102
102
  }
103
103
  },
104
104
  {
105
+ "comment": "module constant access: module.Name",
106
+ "match": "\\b([a-z_][A-Za-z0-9_]*)\\.([A-Z][A-Za-z0-9]+)\\b",
107
+ "captures": {
108
+ "1": {
109
+ "name": "variable.other.plum"
110
+ },
111
+ "2": {
112
+ "name": "constant.other.plum"
113
+ }
114
+ }
115
+ },
116
+ {
105
- "comment": "constant declarations: Name = expr",
117
+ "comment": "constant declarations: Name = expr (module level or in a type body)",
106
- "match": "^\\s+([A-Z][A-Za-z0-9]+)(?=\\s*=(?!=))",
118
+ "match": "^\\s*([A-Z][A-Za-z0-9]+)(?=\\s*=(?!=))",
107
119
  "captures": {
108
120
  "1": {
109
121
  "name": "constant.other.plum"
compiler/wasm-codegen/src/driver.rs CHANGED
@@ -391,7 +391,8 @@ impl<'a> Setup<'a> {
391
391
  }
392
392
  // An extern METHOD (of the primitive `Io`) takes its receiver as the first
393
393
  // wrapper param; the host import has no receiver.
394
+ // A module-level extern's `io: Io` is the capability too: not passed to the host.
394
- let skip = usize::from(f.type_param.is_some() && f.has_self);
395
+ let skip = usize::from((f.type_param.is_some() && f.has_self) || (f.type_param.is_none() && ast::takesIoFirst(f)));
395
396
  let key = if f.type_param.is_some() { fnKey(f) } else { f.name.clone() };
396
397
  if f.type_param.is_none() {
397
398
  known_fns.insert(f.name.clone());
tests/modules/constants.plum ADDED
@@ -0,0 +1,30 @@
1
+ import std/Int
2
+ import std/Float
3
+ import std/Str
4
+ import std/List
5
+
6
+ # Module-level constants: any pure expression, read in dependency order.
7
+
8
+ Third = Second * 3
9
+ Second = First + 1
10
+ First = 4
11
+ ArenaW = 800.0
12
+ ArenaArea = ArenaW * ArenaH
13
+ ArenaH = 600.0
14
+ Greeting = "hello " + "there"
15
+ Boxes = [Second, Third]
16
+
17
+ test "constants read each other in dependency order"
18
+ assert First == 4
19
+ assert Second == 5
20
+ assert Third == 15
21
+
22
+ test "a float constant"
23
+ assert ArenaW == 800.0
24
+ assert ArenaArea == 480000.0
25
+
26
+ test "a string constant"
27
+ assert Greeting == "hello there"
28
+
29
+ test "a constant holding a list"
30
+ assert Boxes.length() == 2
tests/modules/host_extern.plum ADDED
@@ -0,0 +1,11 @@
1
+ import std/Io
2
+ import std/Float
3
+
4
+ # A module-level `extern fun` takes `io: Io` first and binds to the host by name,
5
+ # the same route the `extern fun` methods of `Io` use.
6
+ extern fun rawNowMillis(io: Io) -> Float
7
+
8
+ fun nowIsLater(io: Io) -> Bool = rawNowMillis(io) > 1000.0
9
+
10
+ test "a module-level extern calls the host"
11
+ assert nowIsLater(io)
tests/modules/mathx.plum ADDED
@@ -0,0 +1,12 @@
1
+ import std/Int
2
+
3
+ # A module other files import as `mathx`. Its `clamp` and `Limit` are also defined by `shapes`.
4
+
5
+ Limit = 10
6
+ Double = Limit * 2
7
+
8
+ fun clamp(x: Int) -> Int = if x > Limit then Limit else x
9
+
10
+ fun twice(x: Int) -> Int = clamp(x) * 2
11
+
12
+ fun onlyHere(x: Int) -> Int = x + 1
tests/modules/shapes.plum ADDED
@@ -0,0 +1,10 @@
1
+ import std/Int
2
+
3
+ # The second module that defines `clamp` and `Limit`.
4
+
5
+ Limit = 100
6
+ Doubled = Limit + Limit
7
+
8
+ fun clamp(x: Int) -> Int = if x > Limit then Limit else x
9
+
10
+ fun twice(x: Int) -> Int = clamp(x) * 2
tests/modules/two_modules.plum ADDED
@@ -0,0 +1,34 @@
1
+ import std/Int
2
+ import tests/modules/mathx
3
+ import tests/modules/shapes
4
+
5
+ # Two modules define `clamp`, `twice` and `Limit`: each is reached by its module name.
6
+
7
+ fun viaFunctionValue(f: fn(Int) -> Int, x: Int) -> Int = f(x)
8
+
9
+ test "the same function name in two modules"
10
+ assert mathx.clamp(50) == 10
11
+ assert shapes.clamp(50) == 50
12
+ assert shapes.clamp(500) == 100
13
+
14
+ test "a function calls its own module's colliding helper"
15
+ assert mathx.twice(50) == 20
16
+ assert shapes.twice(50) == 100
17
+
18
+ test "the same constant name in two modules"
19
+ assert mathx.Limit == 10
20
+ assert shapes.Limit == 100
21
+
22
+ test "a constant may read another constant of its module"
23
+ assert mathx.Double == 20
24
+ assert shapes.Doubled == 200
25
+
26
+ test "a name only one module defines is reached through its module"
27
+ assert mathx.onlyHere(1) == 2
28
+
29
+ test "a qualified function can be passed as a value after a call"
30
+ assert viaFunctionValue(|x: Int| mathx.clamp(x), 99) == 10
31
+
32
+ test "a local named like a module wins over the module"
33
+ mathx := |x: Int| x + 1000
34
+ assert mathx(1) == 1001