plum

#treesitter#compiler#wasm

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

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


docs/superpowers/plans/2026-07-23-cross-file-imports.md
2172082 1
# Cross-File Import Resolution Implementation Plan
2172082 2
2172082 3
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
2172082 4
2172082 5
**Goal:** Make `import <path>` actually resolve to a file on disk and merge its declarations into the compiling program, so files split across `libs/std/*.plum` can reference each other.
2172082 6
2172082 7
**Architecture:** Add a `load_and_merge(entry, lib_path)` loader in `plum-core` that recursively resolves and parses `import` targets (tracking visited files so diamond imports and cycles both resolve cleanly) and merges every reachable file's items into one `ast::Source`, erroring on duplicate top-level names. `plum-checker::check_source` and `plum_wasm_codegen::compile_source` are completely unchanged — they only ever see the one merged `Source`. `plum-cli`'s `compile` subcommand routes through the loader and gains a `--lib-path` flag.
2172082 8
2172082 9
**Tech Stack:** Rust, tree-sitter (parsing, unchanged), std::fs/std::path for file resolution. No grammar changes — `import <path>` already parses into `ast::Import { path }`.
2172082 10
2172082 11
## Global Constraints
2172082 12
2172082 13
- Spec: `docs/superpowers/specs/2026-07-23-cross-file-imports-design.md`
2172082 14
- `import std/option` resolves to `<lib-path>/std/option.plum`. `--lib-path` defaults to `./libs` (relative to CWD).
2172082 15
- In scope: transitive imports, diamond imports (no duplication), import cycles (resolve without hanging or erroring), a clear error for an unresolvable import path, a clear error for two files declaring the same top-level name.
2172082 16
- Out of scope: selective/aliased imports, a package manager/registry, fixing OTHER `libs/std/*.plum` files' pre-existing broken `import` lines (e.g. `http.plum`'s `import std/path`), any new semantics for `module <name>`.
2172082 17
- **Known, deliberately accepted scope boundary**: the real `libs/std/list.plum` does NOT fully type-check standalone even after this plan — its `join` method calls `Buffer()`, which is declared nowhere in this repo, and `plum-checker` doesn't process `Item::Trait` at all (no trait-bounded dispatch). Both are separate, unscoped gaps. This plan proves the *loader mechanism* with small fixture files, not with the real, currently-broken `list.plum`.
2172082 18
- Run `cargo build --workspace` and `cargo test --workspace` after every task that touches Rust code — all pre-existing tests must keep passing throughout.
2172082 19
2172082 20
---
2172082 21
2172082 22
### Task 1: `plum-core` — `load_and_merge` loader
2172082 23
2172082 24
**Files:**
2172082 25
- Create: `plum-core/src/loader.rs`
2172082 26
- Modify: `plum-core/src/lib.rs` (export the new module)
2172082 27
- Modify: `plum-core/Cargo.toml` (add a dev-dependency for on-disk test fixtures)
2172082 28
- Test: `plum-core/tests/loader_test.rs`
2172082 29
2172082 30
**Interfaces:**
2172082 31
- Consumes: `crate::ast::{Source, Item, Import}` (existing), `crate::parser::AstParser` (existing), `tree_sitter`/`tree_sitter_plum` (existing dependencies of this crate).
2172082 32
- Produces: `pub fn load_and_merge(entry: &std::path::Path, lib_path: &std::path::Path) -> Result<ast::Source, String>`. Task 2 (`plum-cli`) calls this directly; Task 3's test also calls it.
2172082 33
2172082 34
- [ ] **Step 1: Add the `tempfile` dev-dependency**
2172082 35
2172082 36
`plum-core/Cargo.toml` currently has no `[dev-dependencies]` section. Add one:
2172082 37
2172082 38
```toml
2172082 39
[dev-dependencies]
2172082 40
tempfile = "3"
2172082 41
```
2172082 42
2172082 43
- [ ] **Step 2: Write the failing loader tests**
2172082 44
2172082 45
Create `plum-core/tests/loader_test.rs`:
2172082 46
2172082 47
```rust
2172082 48
use plum_core::load_and_merge;
2172082 49
use std::fs;
2172082 50
use tempfile::TempDir;
2172082 51
2172082 52
/// Writes `name` (without a directory prefix — always placed directly under
2172082 53
/// the temp dir's `lib_path` subdirectory) with `contents`, creating parent
2172082 54
/// directories as needed (so `"std/option"` works).
2172082 55
fn write_lib_file(lib_path: &std::path::Path, name: &str, contents: &str) -> std::path::PathBuf {
2172082 56
    let path = lib_path.join(format!("{}.plum", name));
2172082 57
    fs::create_dir_all(path.parent().unwrap()).unwrap();
2172082 58
    fs::write(&path, contents).unwrap();
2172082 59
    path
2172082 60
}
2172082 61
2172082 62
#[test]
2172082 63
fn import_sees_imported_declarations() {
2172082 64
    let dir = TempDir::new().unwrap();
2172082 65
    let lib_path = dir.path().join("libs");
2172082 66
    write_lib_file(&lib_path, "helper", "module fixtures\n\nhelperFn() -> Int =\n  42\n");
2172082 67
    let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport helper\n\nmain() -> Int =\n  helperFn()\n");
2172082 68
2172082 69
    let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
2172082 70
    let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
2172082 71
        plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
2172082 72
        _ => None,
2172082 73
    }).collect();
2172082 74
    assert!(names.contains(&"main"), "expected 'main' in merged items, got {:?}", names);
2172082 75
    assert!(names.contains(&"helperFn"), "expected 'helperFn' in merged items, got {:?}", names);
2172082 76
}
2172082 77
2172082 78
#[test]
2172082 79
fn transitive_import_surfaces_grandchild_declarations() {
2172082 80
    let dir = TempDir::new().unwrap();
2172082 81
    let lib_path = dir.path().join("libs");
2172082 82
    write_lib_file(&lib_path, "c", "module fixtures\n\ncFn() -> Int =\n  3\n");
2172082 83
    write_lib_file(&lib_path, "b", "module fixtures\n\nimport c\n\nbFn() -> Int =\n  cFn()\n");
2172082 84
    let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\n\nmain() -> Int =\n  bFn()\n");
2172082 85
2172082 86
    let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
2172082 87
    let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
2172082 88
        plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
2172082 89
        _ => None,
2172082 90
    }).collect();
2172082 91
    assert!(names.contains(&"cFn"), "expected transitively-imported 'cFn', got {:?}", names);
2172082 92
}
2172082 93
2172082 94
#[test]
2172082 95
fn diamond_import_includes_shared_dependency_once() {
2172082 96
    let dir = TempDir::new().unwrap();
2172082 97
    let lib_path = dir.path().join("libs");
2172082 98
    write_lib_file(&lib_path, "d", "module fixtures\n\ndFn() -> Int =\n  4\n");
2172082 99
    write_lib_file(&lib_path, "b", "module fixtures\n\nimport d\n\nbFn() -> Int =\n  dFn()\n");
2172082 100
    write_lib_file(&lib_path, "c", "module fixtures\n\nimport d\n\ncFn() -> Int =\n  dFn()\n");
2172082 101
    let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\nimport c\n\nmain() -> Int =\n  bFn() + cFn()\n");
2172082 102
2172082 103
    let merged = load_and_merge(&entry, &lib_path).expect("load_and_merge failed");
2172082 104
    let d_count = merged.items.iter().filter(|i| matches!(i, plum_core::ast::Item::Fn(f) if f.name == "dFn")).count();
2172082 105
    assert_eq!(d_count, 1, "expected 'dFn' exactly once in a diamond import, got {}", d_count);
2172082 106
}
2172082 107
2172082 108
#[test]
2172082 109
fn import_cycle_resolves_without_hanging_or_erroring() {
2172082 110
    let dir = TempDir::new().unwrap();
2172082 111
    let lib_path = dir.path().join("libs");
2172082 112
    write_lib_file(&lib_path, "b", "module fixtures\n\nimport a\n\nbFn() -> Int =\n  1\n");
2172082 113
    let entry = write_lib_file(&lib_path, "a", "module fixtures\n\nimport b\n\nmain() -> Int =\n  bFn()\n");
2172082 114
2172082 115
    let merged = load_and_merge(&entry, &lib_path).expect("import cycle should resolve, not error");
2172082 116
    let names: Vec<&str> = merged.items.iter().filter_map(|i| match i {
2172082 117
        plum_core::ast::Item::Fn(f) => Some(f.name.as_str()),
2172082 118
        _ => None,
2172082 119
    }).collect();
2172082 120
    assert!(names.contains(&"main"));
2172082 121
    assert!(names.contains(&"bFn"));
2172082 122
}
2172082 123
2172082 124
#[test]
2172082 125
fn unresolvable_import_is_a_clear_error() {
2172082 126
    let dir = TempDir::new().unwrap();
2172082 127
    let lib_path = dir.path().join("libs");
2172082 128
    let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport does_not_exist\n\nmain() -> Int =\n  0\n");
2172082 129
2172082 130
    let result = load_and_merge(&entry, &lib_path);
2172082 131
    assert!(result.is_err());
2172082 132
    let msg = result.unwrap_err();
2172082 133
    assert!(msg.contains("does_not_exist"), "expected error to name the missing import, got: {}", msg);
2172082 134
}
2172082 135
2172082 136
#[test]
2172082 137
fn duplicate_top_level_name_across_files_is_a_clear_error() {
2172082 138
    let dir = TempDir::new().unwrap();
2172082 139
    let lib_path = dir.path().join("libs");
2172082 140
    write_lib_file(&lib_path, "helper", "module fixtures\n\nsharedFn() -> Int =\n  1\n");
2172082 141
    let entry = write_lib_file(&lib_path, "main", "module fixtures\n\nimport helper\n\nsharedFn() -> Int =\n  2\n");
2172082 142
2172082 143
    let result = load_and_merge(&entry, &lib_path);
2172082 144
    assert!(result.is_err());
2172082 145
    let msg = result.unwrap_err();
2172082 146
    assert!(msg.contains("sharedFn"), "expected error to name the duplicate 'sharedFn', got: {}", msg);
2172082 147
}
2172082 148
2172082 149
#[test]
2172082 150
fn same_method_name_on_different_receivers_across_files_is_not_a_collision() {
2172082 151
    let dir = TempDir::new().unwrap();
2172082 152
    let lib_path = dir.path().join("libs");
2172082 153
    write_lib_file(&lib_path, "cat", "\
2172082 154
module fixtures
2172082 155
2172082 156
type Cat =
2172082 157
  age: Int
2172082 158
2172082 159
length<Cat>(self) -> Int =
2172082 160
  self.age
2172082 161
");
2172082 162
    let entry = write_lib_file(&lib_path, "main", "\
2172082 163
module fixtures
2172082 164
2172082 165
import cat
2172082 166
2172082 167
type Box =
2172082 168
  items: Int
2172082 169
2172082 170
length<Box>(self) -> Int =
2172082 171
  self.items
2172082 172
2172082 173
main() -> Int =
2172082 174
  0
2172082 175
");
2172082 176
2172082 177
    let merged = load_and_merge(&entry, &lib_path).expect("same method name on different receivers should not collide");
2172082 178
    let method_count = merged.items.iter().filter(|i| matches!(i, plum_core::ast::Item::Fn(f) if f.name == "length")).count();
2172082 179
    assert_eq!(method_count, 2, "expected both 'length' methods to survive the merge, got {}", method_count);
2172082 180
}
2172082 181
```
2172082 182
2172082 183
- [ ] **Step 3: Run the tests to verify they fail**
2172082 184
2172082 185
Run: `cargo test -p plum-core --test loader_test 2>&1 | tail -60`
2172082 186
Expected: FAIL to compile — `plum_core::load_and_merge` doesn't exist yet.
2172082 187
2172082 188
- [ ] **Step 4: Implement `plum-core/src/loader.rs`**
2172082 189
2172082 190
```rust
2172082 191
use std::collections::{HashMap, HashSet};
2172082 192
use std::path::{Path, PathBuf};
2172082 193
2172082 194
use crate::ast::{Import, Item, Source};
2172082 195
use crate::parser::AstParser;
2172082 196
2172082 197
/// Resolves `entry`'s `import` statements (transitively, and tolerating
2172082 198
/// diamond imports and cycles) against `lib_path`, and merges every reachable
2172082 199
/// file's top-level items into a single `Source`. The result's `module` and
2172082 200
/// `imports` fields are `entry`'s own (imported files' `module`/`imports`
2172082 201
/// fields are not otherwise used once their `items` have been merged in) —
2172082 202
/// only `items` accumulates across files.
2172082 203
///
2172082 204
/// `import "std/option"` resolves to `<lib_path>/std/option.plum`.
2172082 205
pub fn load_and_merge(entry: &Path, lib_path: &Path) -> Result<Source, String> {
2172082 206
    let entry_canon = std::fs::canonicalize(entry)
2172082 207
        .map_err(|e| format!("cannot read entry file '{}': {}", entry.display(), e))?;
2172082 208
    let entry_source = parse_file(&entry_canon)?;
2172082 209
2172082 210
    let mut visited: HashSet<PathBuf> = HashSet::new();
2172082 211
    let mut items: Vec<Item> = Vec::new();
2172082 212
    let mut names: HashMap<String, PathBuf> = HashMap::new();
2172082 213
2172082 214
    visited.insert(entry_canon.clone());
2172082 215
    merge_items(&entry_canon, &entry_source, &mut items, &mut names)?;
2172082 216
    for import in &entry_source.imports {
2172082 217
        load_import(import, lib_path, &mut visited, &mut items, &mut names)?;
2172082 218
    }
2172082 219
2172082 220
    Ok(Source {
2172082 221
        module: entry_source.module,
2172082 222
        imports: entry_source.imports,
2172082 223
        items,
2172082 224
    })
2172082 225
}
2172082 226
2172082 227
fn load_import(
2172082 228
    import: &Import,
2172082 229
    lib_path: &Path,
2172082 230
    visited: &mut HashSet<PathBuf>,
2172082 231
    items: &mut Vec<Item>,
2172082 232
    names: &mut HashMap<String, PathBuf>,
2172082 233
) -> Result<(), String> {
2172082 234
    let target = lib_path.join(format!("{}.plum", import.path));
2172082 235
    let target_canon = std::fs::canonicalize(&target)
2172082 236
        .map_err(|_| format!("import '{}': no such file '{}'", import.path, target.display()))?;
2172082 237
2172082 238
    if !visited.insert(target_canon.clone()) {
2172082 239
        // Already loaded — a diamond import or a cycle. Either way, its items
2172082 240
        // are already in `items`; nothing more to do.
2172082 241
        return Ok(());
2172082 242
    }
2172082 243
2172082 244
    let source = parse_file(&target_canon)?;
2172082 245
    merge_items(&target_canon, &source, items, names)?;
2172082 246
    for nested in &source.imports {
2172082 247
        load_import(nested, lib_path, visited, items, names)?;
2172082 248
    }
2172082 249
    Ok(())
2172082 250
}
2172082 251
2172082 252
fn parse_file(path: &Path) -> Result<Source, String> {
2172082 253
    let text = std::fs::read_to_string(path)
2172082 254
        .map_err(|e| format!("cannot read '{}': {}", path.display(), e))?;
2172082 255
    let mut parser = tree_sitter::Parser::new();
2172082 256
    parser
2172082 257
        .set_language(&tree_sitter_plum::LANGUAGE.into())
2172082 258
        .map_err(|e| format!("language error: {}", e))?;
2172082 259
    let tree = parser
2172082 260
        .parse(&text, None)
2172082 261
        .ok_or_else(|| format!("parse failed for '{}'", path.display()))?;
2172082 262
    let ap = AstParser::new(&text);
2172082 263
    Ok(ap.parse_source(tree.root_node()))
2172082 264
}
2172082 265
2172082 266
fn merge_items(
2172082 267
    file: &Path,
2172082 268
    source: &Source,
2172082 269
    items: &mut Vec<Item>,
2172082 270
    names: &mut HashMap<String, PathBuf>,
2172082 271
) -> Result<(), String> {
2172082 272
    for item in &source.items {
2172082 273
        let key = item_name_key(item);
2172082 274
        if let Some(existing_file) = names.get(&key) {
2172082 275
            return Err(format!(
2172082 276
                "duplicate declaration '{}': declared in both '{}' and '{}'",
2172082 277
                key,
2172082 278
                existing_file.display(),
2172082 279
                file.display()
2172082 280
            ));
2172082 281
        }
2172082 282
        names.insert(key, file.to_path_buf());
2172082 283
        items.push(item.clone());
2172082 284
    }
2172082 285
    Ok(())
2172082 286
}
2172082 287
2172082 288
/// A collision key that mirrors `plum-checker`'s own separation of
2172082 289
/// namespaces: methods are keyed by `(receiver, name)` (so `length<Cat>` and
2172082 290
/// `length<Box>` never collide, exactly like `plum_checker::MethodEnv`),
2172082 291
/// while classes/traits/enums/consts/free-functions are each their own
2172082 292
/// flat, kind-qualified namespace.
2172082 293
fn item_name_key(item: &Item) -> String {
2172082 294
    match item {
2172082 295
        Item::Class(c) => format!("class::{}", c.name),
2172082 296
        Item::Trait(t) => format!("trait::{}", t.name),
2172082 297
        Item::Enum(e) => format!("enum::{}", e.name),
2172082 298
        Item::Const(c) => format!("const::{}", c.name),
2172082 299
        Item::Fn(f) => match &f.type_param {
2172082 300
            Some(recv) => format!("method::{}::{}", recv, f.name),
2172082 301
            None => format!("fn::{}", f.name),
2172082 302
        },
2172082 303
    }
2172082 304
}
2172082 305
```
2172082 306
2172082 307
- [ ] **Step 5: Export the new module**
2172082 308
2172082 309
In `plum-core/src/lib.rs`, replace:
2172082 310
2172082 311
```rust
2172082 312
pub mod ast;
2172082 313
pub mod parser;
2172082 314
pub mod formatter;
2172082 315
2172082 316
pub use formatter::{format_source, format_source_with_opts, FormatterError};
2172082 317
pub use parser::AstParser;
2172082 318
```
2172082 319
2172082 320
with:
2172082 321
2172082 322
```rust
2172082 323
pub mod ast;
2172082 324
pub mod parser;
2172082 325
pub mod formatter;
2172082 326
pub mod loader;
2172082 327
2172082 328
pub use formatter::{format_source, format_source_with_opts, FormatterError};
2172082 329
pub use parser::AstParser;
2172082 330
pub use loader::load_and_merge;
2172082 331
```
2172082 332
2172082 333
- [ ] **Step 6: Run the tests to verify they pass**
2172082 334
2172082 335
Run: `cargo test -p plum-core --test loader_test 2>&1 | tail -80`
2172082 336
Expected: all 7 tests PASS.
2172082 337
2172082 338
- [ ] **Step 7: Run the full workspace test suite**
2172082 339
2172082 340
Run: `cargo test --workspace 2>&1 | tail -100`
2172082 341
Expected: all pre-existing tests still PASS (this task only adds a new module and a new dev-dependency; nothing existing is touched).
2172082 342
2172082 343
- [ ] **Step 8: Commit**
2172082 344
2172082 345
```bash
2172082 346
git add plum-core/src/loader.rs plum-core/src/lib.rs plum-core/Cargo.toml plum-core/tests/loader_test.rs Cargo.lock
2172082 347
git commit -m "feat(plum-core): add load_and_merge cross-file import loader"
2172082 348
```
2172082 349
2172082 350
---
2172082 351
2172082 352
### Task 2: `plum-cli` — `--lib-path` and loader integration
2172082 353
2172082 354
**Files:**
2172082 355
- Modify: `plum-cli/src/main.rs` (`Command::Compile` variant, `cmd_compile`)
2172082 356
- Test: `plum-cli/tests/compile_tests.rs`
2172082 357
- Test fixtures: `test/import_fixtures/helper.plum`, `test/import_fixtures/main.plum` (new)
2172082 358
2172082 359
**Interfaces:**
2172082 360
- Consumes: `plum_core::load_and_merge(entry: &Path, lib_path: &Path) -> Result<ast::Source, String>` (Task 1).
2172082 361
- Produces: `plum compile <file> --lib-path <dir>` resolves imports. No new public Rust functions (this is the CLI's own binary entry point).
2172082 362
2172082 363
- [ ] **Step 1: Write the failing CLI integration test and its fixtures**
2172082 364
2172082 365
Create `test/import_fixtures/helper.plum`:
2172082 366
2172082 367
```
2172082 368
module fixtures
2172082 369
2172082 370
helperValue() -> Int =
2172082 371
  42
2172082 372
```
2172082 373
2172082 374
Create `test/import_fixtures/main.plum`:
2172082 375
2172082 376
```
2172082 377
module fixtures
2172082 378
2172082 379
import helper
2172082 380
2172082 381
main() -> Int =
2172082 382
  helperValue()
2172082 383
```
2172082 384
2172082 385
Add to `plum-cli/tests/compile_tests.rs` (this file already has a `plum_bin()` helper — reuse it, don't redefine it):
2172082 386
2172082 387
```rust
2172082 388
#[test]
2172082 389
fn compile_with_import_resolves_via_lib_path() {
2172082 390
    let src_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../test/import_fixtures/main.plum");
2172082 391
    let lib_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../test/import_fixtures");
2172082 392
    let out_path = std::env::temp_dir().join(format!("plum_test_import_{}.wasm", std::process::id()));
2172082 393
    let out_path_str = out_path.to_str().unwrap();
2172082 394
    let status = Command::new(plum_bin())
2172082 395
        .args(["compile", src_path, "--lib-path", lib_path, "-o", out_path_str])
2172082 396
        .status()
2172082 397
        .expect("failed to run plum compile");
2172082 398
    assert!(status.success(), "plum compile exited with: {}", status);
2172082 399
    let bytes = std::fs::read(out_path).expect("output wasm not found");
2172082 400
    assert_eq!(&bytes[0..4], b"\0asm");
2172082 401
}
2172082 402
```
2172082 403
2172082 404
- [ ] **Step 2: Run the test to verify it fails**
2172082 405
2172082 406
Run: `cargo test -p plum-cli compile_with_import_resolves_via_lib_path 2>&1 | tail -40`
2172082 407
Expected: FAIL — `--lib-path` isn't a recognized flag yet (clap error), or (if you haven't rebuilt) the import is silently dropped and `helperValue` is undefined, producing a type-check error.
2172082 408
2172082 409
- [ ] **Step 3: Add `--lib-path` and route `cmd_compile` through the loader**
2172082 410
2172082 411
In `plum-cli/src/main.rs`, the `Compile` variant currently reads:
2172082 412
2172082 413
```rust
2172082 414
    /// Compile a Plum source file to WASM
2172082 415
    Compile {
2172082 416
        /// Source file to compile
2172082 417
        file: std::path::PathBuf,
2172082 418
        /// Output path (default: input with .wasm extension)
2172082 419
        #[arg(short, long)]
2172082 420
        output: Option<std::path::PathBuf>,
2172082 421
    },
2172082 422
```
2172082 423
2172082 424
Replace it with:
2172082 425
2172082 426
```rust
2172082 427
    /// Compile a Plum source file to WASM
2172082 428
    Compile {
2172082 429
        /// Source file to compile
2172082 430
        file: std::path::PathBuf,
2172082 431
        /// Output path (default: input with .wasm extension)
2172082 432
        #[arg(short, long)]
2172082 433
        output: Option<std::path::PathBuf>,
2172082 434
        /// Root directory `import <path>` is resolved against (`import std/x` -> `<lib-path>/std/x.plum`)
2172082 435
        #[arg(long, default_value = "libs")]
2172082 436
        lib_path: std::path::PathBuf,
2172082 437
    },
2172082 438
```
2172082 439
2172082 440
The dispatch in `run()` currently reads:
2172082 441
2172082 442
```rust
2172082 443
        Command::Compile { file, output } => cmd_compile(file, output),
2172082 444
```
2172082 445
2172082 446
Replace with:
2172082 447
2172082 448
```rust
2172082 449
        Command::Compile { file, output, lib_path } => cmd_compile(file, output, lib_path),
2172082 450
```
2172082 451
2172082 452
`cmd_compile` currently reads (~line 106-137):
2172082 453
2172082 454
```rust
2172082 455
fn cmd_compile(file: std::path::PathBuf, output: Option<std::path::PathBuf>) -> Result<()> {
2172082 456
    let source = fs::read_to_string(&file)
2172082 457
        .with_context(|| format!("failed to read {}", file.display()))?;
2172082 458
2172082 459
    // Parse
2172082 460
    let mut parser = tree_sitter::Parser::new();
2172082 461
    parser
2172082 462
        .set_language(&tree_sitter_plum::LANGUAGE.into())
2172082 463
        .map_err(|e| anyhow::anyhow!("language error: {e}"))?;
2172082 464
    let tree = parser
2172082 465
        .parse(&source, None)
2172082 466
        .ok_or_else(|| anyhow::anyhow!("parse failed"))?;
2172082 467
    let ap = AstParser::new(&source);
2172082 468
    let ast = ap.parse_source(tree.root_node());
2172082 469
2172082 470
    // Type check
2172082 471
    if let Err(errors) = plum_checker::check_source(&ast) {
2172082 472
        for e in &errors {
2172082 473
            eprintln!("type error: {}", e.message);
2172082 474
        }
2172082 475
        process::exit(1);
2172082 476
    }
2172082 477
2172082 478
    // Codegen
2172082 479
    let wasm_bytes = plum_wasm_codegen::compile_source(&ast)
2172082 480
        .map_err(|e| anyhow::anyhow!("codegen error: {e}"))?;
2172082 481
2172082 482
    // Write output
2172082 483
    let out_path = output.unwrap_or_else(|| file.with_extension("wasm"));
2172082 484
    fs::write(&out_path, &wasm_bytes)
2172082 485
        .with_context(|| format!("failed to write {}", out_path.display()))?;
2172082 486
```
2172082 487
2172082 488
Replace the parse block (everything from `let source = fs::read_to_string...` through `let ast = ap.parse_source(tree.root_node());`) with a single call to the loader, and add the new `lib_path` parameter:
2172082 489
2172082 490
```rust
2172082 491
fn cmd_compile(
2172082 492
    file: std::path::PathBuf,
2172082 493
    output: Option<std::path::PathBuf>,
2172082 494
    lib_path: std::path::PathBuf,
2172082 495
) -> Result<()> {
2172082 496
    let ast = plum_core::load_and_merge(&file, &lib_path)
2172082 497
        .map_err(|e| anyhow::anyhow!("{e}"))?;
2172082 498
2172082 499
    // Type check
2172082 500
    if let Err(errors) = plum_checker::check_source(&ast) {
2172082 501
        for e in &errors {
2172082 502
            eprintln!("type error: {}", e.message);
2172082 503
        }
2172082 504
        process::exit(1);
2172082 505
    }
2172082 506
2172082 507
    // Codegen
2172082 508
    let wasm_bytes = plum_wasm_codegen::compile_source(&ast)
2172082 509
        .map_err(|e| anyhow::anyhow!("codegen error: {e}"))?;
2172082 510
2172082 511
    // Write output
2172082 512
    let out_path = output.unwrap_or_else(|| file.with_extension("wasm"));
2172082 513
    fs::write(&out_path, &wasm_bytes)
2172082 514
        .with_context(|| format!("failed to write {}", out_path.display()))?;
2172082 515
```
2172082 516
2172082 517
(Leave the rest of the function — whatever follows the `fs::write` call — unchanged.)
2172082 518
2172082 519
`AstParser`, `tree_sitter::Parser`, and `tree_sitter_plum::LANGUAGE` are used in `plum-cli/src/main.rs` ONLY inside the block you just replaced in `cmd_compile` — `cmd_format` does not use them. After this edit, remove the now-unused `use plum_core::AstParser;` import near the top of the file (~line 9). The `tree_sitter`/`tree_sitter_plum` crates were referenced only via their fully-qualified paths (`tree_sitter::Parser::new()`, `tree_sitter_plum::LANGUAGE`) with no `use` import for them, so there's no `use` line to remove for those — but confirm with `cargo build -p plum-cli 2>&1 | grep -i warning` that no unused-import warning remains after removing the `AstParser` import.
2172082 520
2172082 521
- [ ] **Step 4: Run the test to verify it passes**
2172082 522
2172082 523
Run: `cargo test -p plum-cli compile_with_import_resolves_via_lib_path 2>&1 | tail -40`
2172082 524
Expected: PASS.
2172082 525
2172082 526
- [ ] **Step 5: Run the full plum-cli test suite**
2172082 527
2172082 528
Run: `cargo test -p plum-cli 2>&1 | tail -60`
2172082 529
Expected: all tests PASS, including the pre-existing `compile_simple_add_produces_wasm` (which passes no `--lib-path`, exercising the new flag's default value — `simple_add.plum` has no imports, so the loader trivially returns just that one file, and the default `./libs` never needs to exist for a file with no imports since `load_and_merge` only ever touches `lib_path` when resolving an actual `import`).
2172082 530
2172082 531
- [ ] **Step 6: Run the full workspace test suite**
2172082 532
2172082 533
Run: `cargo test --workspace 2>&1 | tail -100`
2172082 534
Expected: all tests PASS.
2172082 535
2172082 536
- [ ] **Step 7: Commit**
2172082 537
2172082 538
```bash
2172082 539
git add plum-cli/src/main.rs plum-cli/tests/compile_tests.rs test/import_fixtures/
2172082 540
git commit -m "feat(plum-cli): resolve import via --lib-path using the new loader"
2172082 541
```
2172082 542
2172082 543
---
2172082 544
2172082 545
### Task 3: `libs/std/list.plum` — add the missing import
2172082 546
2172082 547
**Files:**
2172082 548
- Modify: `libs/std/list.plum`
2172082 549
2172082 550
**Interfaces:**
2172082 551
- Consumes: nothing new (this task doesn't touch Rust code).
2172082 552
- Produces: nothing new (this is a one-line library fix; Task 2's loader is already fully tested and proven).
2172082 553
2172082 554
- [ ] **Step 1: Add the import line**
2172082 555
2172082 556
In `libs/std/list.plum`, the file currently opens:
2172082 557
2172082 558
```
2172082 559
module std
2172082 560
2172082 561
# A node stores the data in a list and contains pointers to the previous and next sibling nodes
2172082 562
type Node(a) =
2172082 563
```
2172082 564
2172082 565
Change it to:
2172082 566
2172082 567
```
2172082 568
module std
2172082 569
2172082 570
import std/option
2172082 571
2172082 572
# A node stores the data in a list and contains pointers to the previous and next sibling nodes
2172082 573
type Node(a) =
2172082 574
```
2172082 575
2172082 576
- [ ] **Step 2: Confirm no test regresses**
2172082 577
2172082 578
Run: `cargo test --workspace 2>&1 | tail -100`
2172082 579
Expected: all tests PASS (no existing test currently compiles `libs/std/list.plum` at all — this is a one-line fix to a library file that isn't part of the Rust build, so nothing should change). This step exists to catch the unexpected case where something DOES already reference this file's exact text.
2172082 580
2172082 581
- [ ] **Step 3: Commit**
2172082 582
2172082 583
```bash
2172082 584
git add libs/std/list.plum
2172082 585
git commit -m "fix(libs/std): list.plum imports std/option instead of relying on unresolved cross-file references"
2172082 586
```
2172082 587
2172082 588
---
2172082 589
2172082 590
### Task 4: README — close the gap
2172082 591
2172082 592
**Files:**
2172082 593
- Modify: `README.md` (the "Known gaps" section)
2172082 594
2172082 595
**Interfaces:**
2172082 596
- Consumes: nothing.
2172082 597
- Produces: nothing (docs only).
2172082 598
2172082 599
- [ ] **Step 1: Update the Known gaps bullet**
2172082 600
2172082 601
Run: `grep -n "cross-file" README.md` to find the current bullet, which reads along the lines of:
2172082 602
2172082 603
```
2172082 604
- `libs/std`'s actual `List`/`Map` still don't fully compile — there's no cross-file import resolution yet, so a file that references a type/enum declared in a different `libs/std` file won't type-check standalone; separately, `List`'s methods beyond `get`/`length` (`add`, `set`, `removeAt`, `remove`, `clear`, `reverse`) are still `todo` — variadic parameters (`values: ...a`) now work as a language feature, but wiring these methods up is separate, unstarted work
2172082 605
```
2172082 606
2172082 607
Replace it with:
2172082 608
2172082 609
```
2172082 610
- `libs/std`'s actual `List`/`Map` still don't fully compile — cross-file `import` resolution now works (`import <path>` resolves against `--lib-path`, defaulting to `./libs`), and variadic parameters work, but `List`'s methods beyond `get`/`length` (`add`, `set`, `removeAt`, `remove`, `clear`, `reverse`) are still `todo`; separately, `List`'s own `join` method (and `Map`) reference a `Buffer` type and trait-bounded dispatch (`Stringable`) that don't exist yet — `plum-checker` doesn't process trait declarations at all currently
2172082 611
```
2172082 612
2172082 613
- [ ] **Step 2: Commit**
2172082 614
2172082 615
```bash
2172082 616
git add README.md
2172082 617
git commit -m "docs: cross-file import resolution is no longer a known gap"
2172082 618
```