plum

#treesitter#compiler#wasm

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

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


8a7f6adPeter John 2026-07-23T21:23:15+05:30
feat(plum-core): add load_and_merge cross-file import loader
Cargo.lock CHANGED
@@ -532,6 +532,12 @@ version = "0.3.0"
532
532
  source = "registry+https://github.com/rust-lang/crates.io-index"
533
533
  checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
534
534
 
535
+ [[package]]
536
+ name = "fastrand"
537
+ version = "2.5.0"
538
+ source = "registry+https://github.com/rust-lang/crates.io-index"
539
+ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
540
+
535
541
  [[package]]
536
542
  name = "find-msvc-tools"
537
543
  version = "0.1.9"
@@ -1027,6 +1033,7 @@ dependencies = [
1027
1033
  name = "plum-core"
1028
1034
  version = "0.1.0"
1029
1035
  dependencies = [
1036
+ "tempfile",
1030
1037
  "topiary-core",
1031
1038
  "topiary-tree-sitter-facade",
1032
1039
  "tree-sitter",
@@ -1408,6 +1415,19 @@ version = "0.12.16"
1408
1415
  source = "registry+https://github.com/rust-lang/crates.io-index"
1409
1416
  checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
1410
1417
 
1418
+ [[package]]
1419
+ name = "tempfile"
1420
+ version = "3.27.0"
1421
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1422
+ checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1423
+ dependencies = [
1424
+ "fastrand",
1425
+ "getrandom 0.4.3",
1426
+ "once_cell",
1427
+ "rustix 1.1.4",
1428
+ "windows-sys 0.61.2",
1429
+ ]
1430
+
1411
1431
  [[package]]
1412
1432
  name = "termcolor"
1413
1433
  version = "1.4.1"
plum-core/Cargo.toml CHANGED
@@ -8,3 +8,6 @@ tree-sitter = "0.26"
8
8
  tree-sitter-plum = { path = "../tooling/tree-sitter-plum" }
9
9
  topiary-core = "0.7.3"
10
10
  topiary-tree-sitter-facade = "0.7.3"
11
+
12
+ [dev-dependencies]
13
+ tempfile = "3"
plum-core/src/lib.rs CHANGED
@@ -1,6 +1,8 @@
1
1
  pub mod ast;
2
2
  pub mod parser;
3
3
  pub mod formatter;
4
+ pub mod loader;
4
5
 
5
6
  pub use formatter::{format_source, format_source_with_opts, FormatterError};
6
7
  pub use parser::AstParser;
8
+ pub use loader::load_and_merge;
plum-core/src/loader.rs ADDED
@@ -0,0 +1,114 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use crate::ast::{Import, Item, Source};
5
+ use crate::parser::AstParser;
6
+
7
+ /// Resolves `entry`'s `import` statements (transitively, and tolerating
8
+ /// diamond imports and cycles) against `lib_path`, and merges every reachable
9
+ /// file's top-level items into a single `Source`. The result's `module` and
10
+ /// `imports` fields are `entry`'s own (imported files' `module`/`imports`
11
+ /// fields are not otherwise used once their `items` have been merged in) —
12
+ /// only `items` accumulates across files.
13
+ ///
14
+ /// `import "std/option"` resolves to `<lib_path>/std/option.plum`.
15
+ pub fn load_and_merge(entry: &Path, lib_path: &Path) -> Result<Source, String> {
16
+ let entry_canon = std::fs::canonicalize(entry)
17
+ .map_err(|e| format!("cannot read entry file '{}': {}", entry.display(), e))?;
18
+ let entry_source = parse_file(&entry_canon)?;
19
+
20
+ let mut visited: HashSet<PathBuf> = HashSet::new();
21
+ let mut items: Vec<Item> = Vec::new();
22
+ let mut names: HashMap<String, PathBuf> = HashMap::new();
23
+
24
+ visited.insert(entry_canon.clone());
25
+ merge_items(&entry_canon, &entry_source, &mut items, &mut names)?;
26
+ for import in &entry_source.imports {
27
+ load_import(import, lib_path, &mut visited, &mut items, &mut names)?;
28
+ }
29
+
30
+ Ok(Source {
31
+ module: entry_source.module,
32
+ imports: entry_source.imports,
33
+ items,
34
+ })
35
+ }
36
+
37
+ fn load_import(
38
+ import: &Import,
39
+ lib_path: &Path,
40
+ visited: &mut HashSet<PathBuf>,
41
+ items: &mut Vec<Item>,
42
+ names: &mut HashMap<String, PathBuf>,
43
+ ) -> Result<(), String> {
44
+ let target = lib_path.join(format!("{}.plum", import.path));
45
+ let target_canon = std::fs::canonicalize(&target)
46
+ .map_err(|_| format!("import '{}': no such file '{}'", import.path, target.display()))?;
47
+
48
+ if !visited.insert(target_canon.clone()) {
49
+ // Already loaded — a diamond import or a cycle. Either way, its items
50
+ // are already in `items`; nothing more to do.
51
+ return Ok(());
52
+ }
53
+
54
+ let source = parse_file(&target_canon)?;
55
+ merge_items(&target_canon, &source, items, names)?;
56
+ for nested in &source.imports {
57
+ load_import(nested, lib_path, visited, items, names)?;
58
+ }
59
+ Ok(())
60
+ }
61
+
62
+ fn parse_file(path: &Path) -> Result<Source, String> {
63
+ let text = std::fs::read_to_string(path)
64
+ .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?;
65
+ let mut parser = tree_sitter::Parser::new();
66
+ parser
67
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
68
+ .map_err(|e| format!("language error: {}", e))?;
69
+ let tree = parser
70
+ .parse(&text, None)
71
+ .ok_or_else(|| format!("parse failed for '{}'", path.display()))?;
72
+ let ap = AstParser::new(&text);
73
+ Ok(ap.parse_source(tree.root_node()))
74
+ }
75
+
76
+ fn merge_items(
77
+ file: &Path,
78
+ source: &Source,
79
+ items: &mut Vec<Item>,
80
+ names: &mut HashMap<String, PathBuf>,
81
+ ) -> Result<(), String> {
82
+ for item in &source.items {
83
+ let key = item_name_key(item);
84
+ if let Some(existing_file) = names.get(&key) {
85
+ return Err(format!(
86
+ "duplicate declaration '{}': declared in both '{}' and '{}'",
87
+ key,
88
+ existing_file.display(),
89
+ file.display()
90
+ ));
91
+ }
92
+ names.insert(key, file.to_path_buf());
93
+ items.push(item.clone());
94
+ }
95
+ Ok(())
96
+ }
97
+
98
+ /// A collision key that mirrors `plum-checker`'s own separation of
99
+ /// namespaces: methods are keyed by `(receiver, name)` (so `length<Cat>` and
100
+ /// `length<Box>` never collide, exactly like `plum_checker::MethodEnv`),
101
+ /// while classes/traits/enums/consts/free-functions are each their own
102
+ /// flat, kind-qualified namespace.
103
+ fn item_name_key(item: &Item) -> String {
104
+ match item {
105
+ Item::Class(c) => format!("class::{}", c.name),
106
+ Item::Trait(t) => format!("trait::{}", t.name),
107
+ Item::Enum(e) => format!("enum::{}", e.name),
108
+ Item::Const(c) => format!("const::{}", c.name),
109
+ Item::Fn(f) => match &f.type_param {
110
+ Some(recv) => format!("method::{}::{}", recv, f.name),
111
+ None => format!("fn::{}", f.name),
112
+ },
113
+ }
114
+ }
plum-core/tests/loader_test.rs ADDED
@@ -0,0 +1,133 @@
1
+ use plum_core::load_and_merge;
2
+ use std::fs;
3
+ use tempfile::TempDir;
4
+
5
+ /// Writes `name` (without a directory prefix — always placed directly under
6
+ /// the temp dir's `lib_path` subdirectory) with `contents`, creating parent
7
+ /// directories as needed (so `"std/option"` works).
8
+ fn write_lib_file(lib_path: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
9
+ let path = lib_path.join(format!("{}.plum", name));
10
+ fs::create_dir_all(path.parent().unwrap()).unwrap();
11
+ fs::write(&path, contents).unwrap();
12
+ path
13
+ }
14
+
15
+ #[test]
16
+ fn import_sees_imported_declarations() {
17
+ let dir = TempDir::new().unwrap();
18
+ let lib_path = dir.path().join("libs");
19
+ write_lib_file(&lib_path, "helper", "module fixtures\n\nhelperFn() -> Int =\n 42\n");
20
+ let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport helper\n\nmain() -> Int =\n helperFn()\n");
21
+
22
+ let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
23
+ let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
24
+ plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
25
+ _ => None,
26
+ }).collect();
27
+ assert!(names.contains(&"main"), "expected 'main' in merged items, got {:?}", names);
28
+ assert!(names.contains(&"helperFn"), "expected 'helperFn' in merged items, got {:?}", names);
29
+ }
30
+
31
+ #[test]
32
+ fn transitive_import_surfaces_grandchild_declarations() {
33
+ let dir = TempDir::new().unwrap();
34
+ let lib_path = dir.path().join("libs");
35
+ write_lib_file(&lib_path, "c", "module fixtures\n\ncFn() -> Int =\n 3\n");
36
+ write_lib_file(&lib_path, "b", "module fixtures\n\nimport c\n\nbFn() -> Int =\n cFn()\n");
37
+ let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\n\nmain() -> Int =\n bFn()\n");
38
+
39
+ let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
40
+ let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
41
+ plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
42
+ _ => None,
43
+ }).collect();
44
+ assert!(names.contains(&"cFn"), "expected transitively-imported 'cFn', got {:?}", names);
45
+ }
46
+
47
+ #[test]
48
+ fn diamond_import_includes_shared_dependency_once() {
49
+ let dir = TempDir::new().unwrap();
50
+ let lib_path = dir.path().join("libs");
51
+ write_lib_file(&lib_path, "d", "module fixtures\n\ndFn() -> Int =\n 4\n");
52
+ write_lib_file(&lib_path, "b", "module fixtures\n\nimport d\n\nbFn() -> Int =\n dFn()\n");
53
+ write_lib_file(&lib_path, "c", "module fixtures\n\nimport d\n\ncFn() -> Int =\n dFn()\n");
54
+ let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\nimport c\n\nmain() -> Int =\n bFn() + cFn()\n");
55
+
56
+ let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
57
+ let d_count = merged.items.iter().filter(|i| matches!(i, plum_core::ast::Item::Fn(f) if f.name == "dFn")).count();
58
+ assert_eq!(d_count, 1, "expected 'dFn' exactly once in a diamond import, got {}", d_count);
59
+ }
60
+
61
+ #[test]
62
+ fn import_cycle_resolves_without_hanging_or_erroring() {
63
+ let dir = TempDir::new().unwrap();
64
+ let lib_path = dir.path().join("libs");
65
+ write_lib_file(&lib_path, "b", "module fixtures\n\nimport a\n\nbFn() -> Int =\n 1\n");
66
+ let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\n\nmain() -> Int =\n bFn()\n");
67
+
68
+ let merged = load_and_merge(&entry, &lib_path).expect("import cycle should resolve, not error");
69
+ let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
70
+ plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
71
+ _ => None,
72
+ }).collect();
73
+ assert!(names.contains(&"main"));
74
+ assert!(names.contains(&"bFn"));
75
+ }
76
+
77
+ #[test]
78
+ fn unresolvable_import_is_a_clear_error() {
79
+ let dir = TempDir::new().unwrap();
80
+ let lib_path = dir.path().join("libs");
81
+ let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport does_not_exist\n\nmain() -> Int =\n 0\n");
82
+
83
+ let result = load_and_merge(&entry, &lib_path);
84
+ assert!(result.is_err());
85
+ let msg = result.unwrap_err();
86
+ assert!(msg.contains("does_not_exist"), "expected error to name the missing import, got: {}", msg);
87
+ }
88
+
89
+ #[test]
90
+ fn duplicate_top_level_name_across_files_is_a_clear_error() {
91
+ let dir = TempDir::new().unwrap();
92
+ let lib_path = dir.path().join("libs");
93
+ write_lib_file(&lib_path, "helper", "module fixtures\n\nsharedFn() -> Int =\n 1\n");
94
+ let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport helper\n\nsharedFn() -> Int =\n 2\n");
95
+
96
+ let result = load_and_merge(&entry, &lib_path);
97
+ assert!(result.is_err());
98
+ let msg = result.unwrap_err();
99
+ assert!(msg.contains("sharedFn"), "expected error to name the duplicate 'sharedFn', got: {}", msg);
100
+ }
101
+
102
+ #[test]
103
+ fn same_method_name_on_different_receivers_across_files_is_not_a_collision() {
104
+ let dir = TempDir::new().unwrap();
105
+ let lib_path = dir.path().join("libs");
106
+ write_lib_file(&lib_path, "cat", "\
107
+ module fixtures
108
+
109
+ type Cat =
110
+ age: Int
111
+
112
+ length<Cat>(self) -> Int =
113
+ self.age
114
+ ");
115
+ let entry = write_lib_file(&lib_path, "main", "\
116
+ module fixtures
117
+
118
+ import cat
119
+
120
+ type Box =
121
+ items: Int
122
+
123
+ length<Box>(self) -> Int =
124
+ self.items
125
+
126
+ main() -> Int =
127
+ 0
128
+ ");
129
+
130
+ let merged = load_and_merge(&entry, &lib_path).expect("same method name on different receivers should not collide");
131
+ let method_count = merged.items.iter().filter(|i| matches!(i, plum_core::ast::Item::Fn(f) if f.name == "length")).count();
132
+ assert_eq!(method_count, 2, "expected both 'length' methods to survive the merge, got {}", method_count);
133
+ }