plum

#treesitter#compiler#wasm

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
# Design: cross-file import resolution

## Problem

Every `libs/std/*.plum` file declares `module std`, but there's no actual
module-resolution mechanism — `ast::Import { path }` is parsed and then
completely discarded; `plum-cli`'s `compile` command only ever reads and
compiles the single file it's given. `libs/std/list.plum` references
`Option`/`Node`; `Node` is declared in the same file, but `Option` lives in
`libs/std/option.plum` — so `list.plum` cannot type-check or compile standalone
today. This is the second (and, after this lands, last) half of README's
"Known gaps" bullet about `libs/std`'s `List`/`Map` not fully compiling.

Wiring up `List`'s remaining `todo` methods (`add`/`set`/`removeAt`/`remove`/
`clear`/`reverse`) is explicitly **out of scope** for this spec — it's the next,
separate cycle. This spec only makes cross-file `import` actually resolve, so
`list.plum` (with its currently-implemented methods; still-`todo` ones keep
trapping at runtime exactly as they do today) compiles standalone for the
first time.

## Scope

In scope:
- `import <path>` resolves `<path>` (a `/`-separated string, e.g. `std/option`)
  to a file on disk and merges that file's declarations into the compiling
  program.
- Transitive imports (A imports B, B imports C: A sees C's declarations too).
- Diamond imports (A imports both B and C, both import D: D's declarations
  appear exactly once, not duplicated).
- Import cycles (A imports B, B imports A) resolve without an infinite loop or
  a spurious error — this is normal, permitted structure, not a mistake.
- A clear, specific error for: an import path that doesn't resolve to any file;
  two merged files declaring the same top-level name.
- Adding the one missing `import std/option` line to `libs/std/list.plum` (the
  concrete fix this whole spec exists to enable) and proving, via a real
  on-disk test, that `list.plum` now type-checks and compiles standalone.

Out of scope:
- `List`'s own `todo` methods (separate, later cycle).
- A package manager, versioning, or any registry — resolution is purely
  path-based against one configurable root directory.
- Selective/aliased imports (`import std/option { Some, None }`, `import ... as
  x`) — the grammar only supports a bare path today: `import <path>` always
  pulls in everything that path's file declares.
- Re-validating or fixing the OTHER `libs/std/*.plum` files' existing `import`
  lines that reference nonexistent targets (e.g. `http.plum`'s `import
  std/path` and `import std/http/content_type`, neither of which corresponds
  to a real file in this repo). Those files are a rough, partial port from
  another language's stdlib pseudo-syntax (per their own `NOTE:` comments) and
  aren't this spec's concern — this spec makes `import` itself work
  correctly; it doesn't audit every existing library file's import list.
- `Module`/`module <name>` gets no new semantics — it stays exactly as
  decorative/unused as it is today. Resolution is driven entirely by
  `Import.path`, never by matching one file's `module` name against another's.

## Path resolution

`import std/option` resolves against a single configurable root directory
(the "lib path"): `<lib-path>/std/option.plum`. `plum-cli`'s `compile`
subcommand gains a `--lib-path <dir>` flag, defaulting to `./libs` (relative to
the current working directory) — matching today's actual `libs/std/*.plum`
layout with no extra configuration needed to build the standard library from
the repo root.

## Architecture

Add a loader ahead of the existing, **unchanged** `plum_checker::check_source`
/ `plum_wasm_codegen::compile_source` — those two functions still only ever
see a single, already-fully-merged `ast::Source` and need no awareness that
multiple files were involved. The loader lives in `plum-core` (alongside
`AstParser`, which it uses directly) as a new small module:

```rust
pub fn load_and_merge(entry: &Path, lib_path: &Path) -> Result<ast::Source, String>
```

Algorithm: parse `entry` into a `Source`. Walk its `imports` (and, recursively,
every newly-loaded file's own `imports`) with a `visited: HashSet<PathBuf>` of
*canonicalized* file paths already parsed. For each import path not yet
visited: resolve it to `<lib_path>/<path>.plum`, error clearly if that file
doesn't exist, otherwise parse it, mark it visited, append its `items` to the
running merged list (recording each item's name -> source file for collision
detection), and recurse into *its* imports before returning to the caller's
remaining imports. An already-visited import is silently skipped (this is what
makes diamond imports and cycles both "just work" — a cycle's second visit to
an already-in-progress file is indistinguishable, at this level, from a diamond
import's second visit to an already-finished one; both hit the `visited` check
and stop).

Name-collision detection: as items are appended, check each item's name (a
function/method key, class name, enum name, or const name — one extraction
function per `Item` variant, mirroring how `plum-checker`'s own
`build_global_tables` already walks `source.items`) against a running
`HashMap<String, PathBuf>` of name -> the file that first declared it; a name
seen again from a *different* file is an error naming both files. (Two
`name<Receiver>(...)` methods with the same method name but different
receivers are NOT a collision — key on `(receiver, name)` for methods exactly
the way `plum-checker`'s `MethodEnv` already does, so unrelated methods that
happen to share a name across files, e.g. two different classes each defining
`length`, don't falsely collide.)

The result: one `ast::Source` whose `items` is the union of every reachable
file's items (each appearing exactly once), which `plum-cli` hands to the
existing `check_source`/`compile_source` unchanged.

## `plum-cli` integration

`cmd_compile` always routes through `load_and_merge` (even a zero-import file
trivially returns just itself) — the special-cased single-file parse it does
today is removed, not kept as a fallback. `Compile` gains a `--lib-path <dir>`
argument (default `"libs"`), passed straight through to `load_and_merge`.

## Testing

- `plum-core` loader unit tests (using `tempfile`-style on-disk fixtures — real
  files in a temp directory, not in-memory strings, since the loader's whole
  job is filesystem resolution):
  - a file importing another file sees the imported file's declarations
  - transitive import (A -> B -> C) surfaces C's declarations to A
  - diamond import (A -> B, A -> C, B -> D, C -> D) includes D's declarations
    exactly once
  - an import cycle (A -> B -> A) resolves without hanging or erroring
  - an import path that resolves to no file is a clear, specific error
  - two merged files declaring the same top-level name is a clear, specific
    error naming both files
  - two different classes/methods across files sharing a method name (but
    different receivers) is NOT an error
- `libs/std/list.plum` gains the one new line: `import std/option` — a
  correct, useful fix on its own regardless of the point below.
- A new end-to-end `plum-cli` integration test (matching the existing
  `plum-cli/tests/compile_tests.rs` convention: invoke the actual built
  `plum` binary via `std::process::Command`, using
  `concat!(env!("CARGO_MANIFEST_DIR"), "/...")`-relative fixture paths) that
  proves the *loader mechanism* end-to-end: two small, purpose-built fixture
  files (one importing the other) compile successfully via `plum compile
  <entry> --lib-path <dir>`.
  **Not** in scope for this test: asserting that the real `libs/std/list.plum`
  fully type-checks/compiles standalone. It doesn't, independent of this
  spec — `list.plum`'s `join` method calls `Buffer()`, and `Buffer` isn't
  declared anywhere in this repo; more generally, `plum-checker` doesn't
  process `Item::Trait` at all yet (no trait-bounded dispatch, e.g. `a.toStr()`
  on a generic constrained to `Stringable`). Both are separate, unscoped gaps.
  This spec only makes the *import mechanism* correct and proven; it doesn't
  make every existing `libs/std` file type-check.

## README

Once this lands, the "Known gaps" bullet's cross-file-import clause is
removed entirely, leaving only the `List`-methods-still-`todo` clause (now
correctly describing it as blocked on nothing but not-yet-done work, since
both of its former prerequisites — variadic parameters and cross-file
imports — are complete).