plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
6dfb06a
— Peter John
2026-07-23T20:40:37+05:30
docs: add design spec for cross-file import resolution
docs/superpowers/specs/2026-07-23-cross-file-imports-design.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Design: cross-file import resolution
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Every `libs/std/*.plum` file declares `module std`, but there's no actual
|
|
6
|
+
module-resolution mechanism — `ast::Import { path }` is parsed and then
|
|
7
|
+
completely discarded; `plum-cli`'s `compile` command only ever reads and
|
|
8
|
+
compiles the single file it's given. `libs/std/list.plum` references
|
|
9
|
+
`Option`/`Node`; `Node` is declared in the same file, but `Option` lives in
|
|
10
|
+
`libs/std/option.plum` — so `list.plum` cannot type-check or compile standalone
|
|
11
|
+
today. This is the second (and, after this lands, last) half of README's
|
|
12
|
+
"Known gaps" bullet about `libs/std`'s `List`/`Map` not fully compiling.
|
|
13
|
+
|
|
14
|
+
Wiring up `List`'s remaining `todo` methods (`add`/`set`/`removeAt`/`remove`/
|
|
15
|
+
`clear`/`reverse`) is explicitly **out of scope** for this spec — it's the next,
|
|
16
|
+
separate cycle. This spec only makes cross-file `import` actually resolve, so
|
|
17
|
+
`list.plum` (with its currently-implemented methods; still-`todo` ones keep
|
|
18
|
+
trapping at runtime exactly as they do today) compiles standalone for the
|
|
19
|
+
first time.
|
|
20
|
+
|
|
21
|
+
## Scope
|
|
22
|
+
|
|
23
|
+
In scope:
|
|
24
|
+
- `import <path>` resolves `<path>` (a `/`-separated string, e.g. `std/option`)
|
|
25
|
+
to a file on disk and merges that file's declarations into the compiling
|
|
26
|
+
program.
|
|
27
|
+
- Transitive imports (A imports B, B imports C: A sees C's declarations too).
|
|
28
|
+
- Diamond imports (A imports both B and C, both import D: D's declarations
|
|
29
|
+
appear exactly once, not duplicated).
|
|
30
|
+
- Import cycles (A imports B, B imports A) resolve without an infinite loop or
|
|
31
|
+
a spurious error — this is normal, permitted structure, not a mistake.
|
|
32
|
+
- A clear, specific error for: an import path that doesn't resolve to any file;
|
|
33
|
+
two merged files declaring the same top-level name.
|
|
34
|
+
- Adding the one missing `import std/option` line to `libs/std/list.plum` (the
|
|
35
|
+
concrete fix this whole spec exists to enable) and proving, via a real
|
|
36
|
+
on-disk test, that `list.plum` now type-checks and compiles standalone.
|
|
37
|
+
|
|
38
|
+
Out of scope:
|
|
39
|
+
- `List`'s own `todo` methods (separate, later cycle).
|
|
40
|
+
- A package manager, versioning, or any registry — resolution is purely
|
|
41
|
+
path-based against one configurable root directory.
|
|
42
|
+
- Selective/aliased imports (`import std/option { Some, None }`, `import ... as
|
|
43
|
+
x`) — the grammar only supports a bare path today: `import <path>` always
|
|
44
|
+
pulls in everything that path's file declares.
|
|
45
|
+
- Re-validating or fixing the OTHER `libs/std/*.plum` files' existing `import`
|
|
46
|
+
lines that reference nonexistent targets (e.g. `http.plum`'s `import
|
|
47
|
+
std/path` and `import std/http/content_type`, neither of which corresponds
|
|
48
|
+
to a real file in this repo). Those files are a rough, partial port from
|
|
49
|
+
another language's stdlib pseudo-syntax (per their own `NOTE:` comments) and
|
|
50
|
+
aren't this spec's concern — this spec makes `import` itself work
|
|
51
|
+
correctly; it doesn't audit every existing library file's import list.
|
|
52
|
+
- `Module`/`module <name>` gets no new semantics — it stays exactly as
|
|
53
|
+
decorative/unused as it is today. Resolution is driven entirely by
|
|
54
|
+
`Import.path`, never by matching one file's `module` name against another's.
|
|
55
|
+
|
|
56
|
+
## Path resolution
|
|
57
|
+
|
|
58
|
+
`import std/option` resolves against a single configurable root directory
|
|
59
|
+
(the "lib path"): `<lib-path>/std/option.plum`. `plum-cli`'s `compile`
|
|
60
|
+
subcommand gains a `--lib-path <dir>` flag, defaulting to `./libs` (relative to
|
|
61
|
+
the current working directory) — matching today's actual `libs/std/*.plum`
|
|
62
|
+
layout with no extra configuration needed to build the standard library from
|
|
63
|
+
the repo root.
|
|
64
|
+
|
|
65
|
+
## Architecture
|
|
66
|
+
|
|
67
|
+
Add a loader ahead of the existing, **unchanged** `plum_checker::check_source`
|
|
68
|
+
/ `plum_wasm_codegen::compile_source` — those two functions still only ever
|
|
69
|
+
see a single, already-fully-merged `ast::Source` and need no awareness that
|
|
70
|
+
multiple files were involved. The loader lives in `plum-core` (alongside
|
|
71
|
+
`AstParser`, which it uses directly) as a new small module:
|
|
72
|
+
|
|
73
|
+
```rust
|
|
74
|
+
pub fn load_and_merge(entry: &Path, lib_path: &Path) -> Result<ast::Source, String>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Algorithm: parse `entry` into a `Source`. Walk its `imports` (and, recursively,
|
|
78
|
+
every newly-loaded file's own `imports`) with a `visited: HashSet<PathBuf>` of
|
|
79
|
+
*canonicalized* file paths already parsed. For each import path not yet
|
|
80
|
+
visited: resolve it to `<lib_path>/<path>.plum`, error clearly if that file
|
|
81
|
+
doesn't exist, otherwise parse it, mark it visited, append its `items` to the
|
|
82
|
+
running merged list (recording each item's name -> source file for collision
|
|
83
|
+
detection), and recurse into *its* imports before returning to the caller's
|
|
84
|
+
remaining imports. An already-visited import is silently skipped (this is what
|
|
85
|
+
makes diamond imports and cycles both "just work" — a cycle's second visit to
|
|
86
|
+
an already-in-progress file is indistinguishable, at this level, from a diamond
|
|
87
|
+
import's second visit to an already-finished one; both hit the `visited` check
|
|
88
|
+
and stop).
|
|
89
|
+
|
|
90
|
+
Name-collision detection: as items are appended, check each item's name (a
|
|
91
|
+
function/method key, class name, enum name, or const name — one extraction
|
|
92
|
+
function per `Item` variant, mirroring how `plum-checker`'s own
|
|
93
|
+
`build_global_tables` already walks `source.items`) against a running
|
|
94
|
+
`HashMap<String, PathBuf>` of name -> the file that first declared it; a name
|
|
95
|
+
seen again from a *different* file is an error naming both files. (Two
|
|
96
|
+
`name<Receiver>(...)` methods with the same method name but different
|
|
97
|
+
receivers are NOT a collision — key on `(receiver, name)` for methods exactly
|
|
98
|
+
the way `plum-checker`'s `MethodEnv` already does, so unrelated methods that
|
|
99
|
+
happen to share a name across files, e.g. two different classes each defining
|
|
100
|
+
`length`, don't falsely collide.)
|
|
101
|
+
|
|
102
|
+
The result: one `ast::Source` whose `items` is the union of every reachable
|
|
103
|
+
file's items (each appearing exactly once), which `plum-cli` hands to the
|
|
104
|
+
existing `check_source`/`compile_source` unchanged.
|
|
105
|
+
|
|
106
|
+
## `plum-cli` integration
|
|
107
|
+
|
|
108
|
+
`cmd_compile` always routes through `load_and_merge` (even a zero-import file
|
|
109
|
+
trivially returns just itself) — the special-cased single-file parse it does
|
|
110
|
+
today is removed, not kept as a fallback. `Compile` gains a `--lib-path <dir>`
|
|
111
|
+
argument (default `"libs"`), passed straight through to `load_and_merge`.
|
|
112
|
+
|
|
113
|
+
## Testing
|
|
114
|
+
|
|
115
|
+
- `plum-core` loader unit tests (using `tempfile`-style on-disk fixtures — real
|
|
116
|
+
files in a temp directory, not in-memory strings, since the loader's whole
|
|
117
|
+
job is filesystem resolution):
|
|
118
|
+
- a file importing another file sees the imported file's declarations
|
|
119
|
+
- transitive import (A -> B -> C) surfaces C's declarations to A
|
|
120
|
+
- diamond import (A -> B, A -> C, B -> D, C -> D) includes D's declarations
|
|
121
|
+
exactly once
|
|
122
|
+
- an import cycle (A -> B -> A) resolves without hanging or erroring
|
|
123
|
+
- an import path that resolves to no file is a clear, specific error
|
|
124
|
+
- two merged files declaring the same top-level name is a clear, specific
|
|
125
|
+
error naming both files
|
|
126
|
+
- two different classes/methods across files sharing a method name (but
|
|
127
|
+
different receivers) is NOT an error
|
|
128
|
+
- `libs/std/list.plum` gains the one new line: `import std/option`.
|
|
129
|
+
- A new end-to-end test (in whichever crate is the natural home for a
|
|
130
|
+
real-filesystem integration test — `plum-cli` or a new top-level
|
|
131
|
+
`tests/` — using `concat!(env!("CARGO_MANIFEST_DIR"), "/../libs")` or
|
|
132
|
+
equivalent to locate the real repo `libs/` directory) that loads and merges
|
|
133
|
+
`libs/std/list.plum` via `load_and_merge`, then runs the existing
|
|
134
|
+
`check_source` and `compile_source` against the merged result, asserting
|
|
135
|
+
both succeed — proving `list.plum` compiles standalone for the first time.
|
|
136
|
+
(Its still-`todo` methods compile to a runtime trap exactly as they do
|
|
137
|
+
today; this test only proves the *file* now resolves and compiles, not that
|
|
138
|
+
every method works — that's the next cycle's job.)
|
|
139
|
+
|
|
140
|
+
## README
|
|
141
|
+
|
|
142
|
+
Once this lands, the "Known gaps" bullet's cross-file-import clause is
|
|
143
|
+
removed entirely, leaving only the `List`-methods-still-`todo` clause (now
|
|
144
|
+
correctly describing it as blocked on nothing but not-yet-done work, since
|
|
145
|
+
both of its former prerequisites — variadic parameters and cross-file
|
|
146
|
+
imports — are complete).
|