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