Skip to content

Commit 02865d1

Browse files
feat(modules): stdlib import search path — 'import Argv' works from any program (#345)
## Blocker addressed Stdlib consumability: import resolution was strictly sibling-relative, so `import Argv` failed everywhere except next to `stdlib/Argv.eph` — stdlib modules were reachable only via raw `__ffi`. ## Changes - `import_resolver::load_program` gains an optional **stdlib dir as a third resolution tier** (after the literal path and the base-dir module index). Local files shadow stdlib modules of the same name. - `ephapax compile --stdlib <dir>`; effective dir = flag > `$EPHAPAX_STDLIB` > the source tree's `stdlib/` (dev builds). - New fixture `stdlib-import/app.eph` (`import Argv` + qualified extern call `Argv.argv_count()`) with 3 tests: compiles via default path, compiles from an unrelated tempdir via `--stdlib`, and an explicit empty `--stdlib` correctly fails rather than silently falling back. - Emitted wasm validates and carries the `env::argv_*` host imports from Argv's extern block. - Corpus ratchet allowlist +1 (now 14/120) — the enabling PR flips its file, as the gate demands. ## Verification `cargo test -p ephapax-cli` fully green — 19 test binaries, including the corpus gate and the new stdlib-import suite. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 22e278e commit 02865d1

5 files changed

Lines changed: 147 additions & 4 deletions

File tree

src/ephapax-cli/src/import_resolver.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,13 @@ impl std::error::Error for ResolveError {}
6464
/// Walk the import graph rooted at `root_path` and return all loaded
6565
/// modules in topological order — dependencies first, root last. Search
6666
/// for imports under `base_dir` (typically the directory containing the
67-
/// root file).
67+
/// root file), falling back to `stdlib_dir` when given — this is what
68+
/// lets a program anywhere on disk say `import Argv` and reach the
69+
/// shipped stdlib.
6870
pub fn load_program(
6971
root_path: &Path,
7072
base_dir: &Path,
73+
stdlib_dir: Option<&Path>,
7174
) -> Result<Vec<LoadedModule>, ResolveError> {
7275
let mut loaded: HashMap<String, LoadedModule> = HashMap::new();
7376
let mut order: Vec<String> = Vec::new();
@@ -80,7 +83,14 @@ pub fn load_program(
8083
// this map. This lets files live anywhere in the tree as long as
8184
// they declare their module name in a `module a/b/c` header — which
8285
// matches existing corpora like hypatia/src/ui/gossamer/.
83-
let mod_index = scan_module_index(base_dir);
86+
// The stdlib directory (when configured) contributes a THIRD tier:
87+
// local files always shadow stdlib modules of the same name.
88+
let mut mod_index = scan_module_index(base_dir);
89+
if let Some(sd) = stdlib_dir {
90+
for (name, path) in scan_module_index(sd) {
91+
mod_index.entry(name).or_insert(path);
92+
}
93+
}
8494

8595
let root_module_path = root_module_path_from_source(root_path)?;
8696
visit(

src/ephapax-cli/src/main.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use ephapax_typing::{type_check_module, type_check_module_with_registry, ModuleR
2121
// AST dump support (sexpr + json output)
2222
#[allow(unused_imports)]
2323
use std::fs;
24-
use std::path::PathBuf;
24+
use std::path::{Path, PathBuf};
2525
use std::process::ExitCode;
2626

2727
#[derive(Parser)]
@@ -110,6 +110,13 @@ enum Commands {
110110
/// if any are found.
111111
#[arg(long)]
112112
verify_ownership: bool,
113+
114+
/// Directory searched for stdlib modules (`import Argv`, ...)
115+
/// after the program's own directory. Defaults to
116+
/// $EPHAPAX_STDLIB, else the repo stdlib/ when built from
117+
/// source. Local files shadow stdlib modules of the same name.
118+
#[arg(long)]
119+
stdlib: Option<PathBuf>,
113120
},
114121

115122
/// Compile an S-expression IR module to WebAssembly
@@ -193,13 +200,15 @@ fn main() -> ExitCode {
193200
debug,
194201
mode,
195202
verify_ownership,
203+
stdlib,
196204
}) => compile_file(
197205
&file,
198206
output,
199207
opt_level,
200208
debug,
201209
&mode,
202210
verify_ownership,
211+
stdlib,
203212
cli.verbose,
204213
),
205214
Some(Commands::CompileSexpr { file, output }) => {
@@ -417,6 +426,7 @@ fn compile_file(
417426
debug: bool,
418427
_mode_str: &str,
419428
verify_ownership: bool,
429+
stdlib: Option<PathBuf>,
420430
verbose: bool,
421431
) -> Result<(), String> {
422432
let filename = path.to_str().unwrap_or("input").to_string();
@@ -428,11 +438,21 @@ fn compile_file(
428438
.map(|p| p.to_path_buf())
429439
.unwrap_or_else(|| PathBuf::from("."));
430440

441+
// Stdlib search dir: --stdlib flag > $EPHAPAX_STDLIB > the source
442+
// tree's stdlib/ (dev builds). None if none of those exist.
443+
let stdlib_dir = stdlib
444+
.or_else(|| std::env::var_os("EPHAPAX_STDLIB").map(PathBuf::from))
445+
.or_else(|| {
446+
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../stdlib");
447+
dev.canonicalize().ok()
448+
})
449+
.filter(|p| p.is_dir());
450+
431451
// Try the multi-module pipeline first: load + parse the import graph
432452
// (modules returned in topological order, dependencies first, root
433453
// last). If the surface parser rejects the root file, fall back to
434454
// the legacy single-file core-parser path.
435-
let loaded = match import_resolver::load_program(path, &base_dir) {
455+
let loaded = match import_resolver::load_program(path, &base_dir, stdlib_dir.as_deref()) {
436456
Ok(l) => l,
437457
Err(import_resolver::ResolveError::Parse { .. }) => {
438458
// Surface parse failed on the root — legacy core-parser fallback.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Stdlib import resolution — `import Argv` from a program that is NOT a
5+
// sibling of stdlib/Argv.eph.
6+
//
7+
// Resolution order (import_resolver::load_program):
8+
// 1. literal path / module-declaration index under the program's dir,
9+
// 2. module-declaration index under the stdlib dir
10+
// (--stdlib flag > $EPHAPAX_STDLIB > repo stdlib/ for dev builds).
11+
// Local files shadow stdlib modules of the same name (tier order).
12+
13+
use std::path::{Path, PathBuf};
14+
use std::process::Command;
15+
16+
fn ephapax_bin() -> String {
17+
env!("CARGO_BIN_EXE_ephapax").to_string()
18+
}
19+
20+
fn repo_root() -> PathBuf {
21+
Path::new(env!("CARGO_MANIFEST_DIR"))
22+
.join("../..")
23+
.canonicalize()
24+
.expect("repo root")
25+
}
26+
27+
fn fixture() -> PathBuf {
28+
repo_root().join("tests/v2-grammar/fixtures/stdlib-import/app.eph")
29+
}
30+
31+
/// The fixture compiles via the dev-default stdlib path (repo stdlib/).
32+
#[test]
33+
fn stdlib_import_compiles_via_default_path() {
34+
let tmp = tempfile::tempdir().expect("tempdir");
35+
let out = tmp.path().join("app.wasm");
36+
let output = Command::new(ephapax_bin())
37+
.args(["compile"])
38+
.arg(fixture())
39+
.arg("-o")
40+
.arg(&out)
41+
.output()
42+
.expect("spawn ephapax");
43+
assert!(
44+
output.status.success(),
45+
"compile failed:\n{}{}",
46+
String::from_utf8_lossy(&output.stdout),
47+
String::from_utf8_lossy(&output.stderr)
48+
);
49+
let wasm = std::fs::read(&out).expect("read wasm");
50+
wasmparser::validate(&wasm).expect("emitted wasm failed validation");
51+
}
52+
53+
/// The program works from a directory with NO sibling Argv.eph — the
54+
/// point of the search path. Copy it to a tempdir and compile there.
55+
#[test]
56+
fn stdlib_import_compiles_from_unrelated_directory() {
57+
let tmp = tempfile::tempdir().expect("tempdir");
58+
let src = tmp.path().join("app.eph");
59+
std::fs::copy(fixture(), &src).expect("copy fixture");
60+
let out = tmp.path().join("app.wasm");
61+
let output = Command::new(ephapax_bin())
62+
.args(["compile"])
63+
.arg(&src)
64+
.arg("-o")
65+
.arg(&out)
66+
.arg("--stdlib")
67+
.arg(repo_root().join("stdlib"))
68+
.output()
69+
.expect("spawn ephapax");
70+
assert!(
71+
output.status.success(),
72+
"compile failed:\n{}{}",
73+
String::from_utf8_lossy(&output.stdout),
74+
String::from_utf8_lossy(&output.stderr)
75+
);
76+
}
77+
78+
/// An empty --stdlib dir means the import cannot resolve: the search
79+
/// path must not silently fall back past an explicit flag.
80+
#[test]
81+
fn explicit_stdlib_flag_is_authoritative() {
82+
let tmp = tempfile::tempdir().expect("tempdir");
83+
let empty = tmp.path().join("empty-stdlib");
84+
std::fs::create_dir(&empty).expect("mkdir");
85+
let src = tmp.path().join("app.eph");
86+
std::fs::copy(fixture(), &src).expect("copy fixture");
87+
let out = tmp.path().join("app.wasm");
88+
let output = Command::new(ephapax_bin())
89+
.args(["compile"])
90+
.arg(&src)
91+
.arg("-o")
92+
.arg(&out)
93+
.arg("--stdlib")
94+
.arg(&empty)
95+
.output()
96+
.expect("spawn ephapax");
97+
assert!(
98+
!output.status.success(),
99+
"compile unexpectedly succeeded with an empty --stdlib dir"
100+
);
101+
}

tests/eph-corpus-compiles.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ tests/v2-grammar/fixtures/multi-module/app.eph
3030
tests/v2-grammar/fixtures/multi-module/lib/math.eph
3131
tests/v2-grammar/fixtures/qualified-import/app.eph
3232
tests/v2-grammar/fixtures/qualified-import/lib.eph
33+
tests/v2-grammar/fixtures/stdlib-import/app.eph
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
// Stdlib import: `import Argv` resolves via the stdlib search path
4+
// (--stdlib / $EPHAPAX_STDLIB / repo stdlib/), not a sibling file.
5+
// Qualified access to an imported extern (G6 + extern seam).
6+
7+
module app
8+
9+
import Argv
10+
11+
fn main(): I32 = Argv.argv_count()

0 commit comments

Comments
 (0)