Skip to content

Commit 7b1cd76

Browse files
feat(v2-grammar): re-port phases E/F-H/I/J onto upstream extern API (refs #80)
Re-ports the net-new v2-grammar phases E/F-H/I/J onto the upstream Extern { abi, items, span } + cross-module-registry API. Full suite green. Refs #80 (NOT Closes — #80 is major + requirements-target; closes only on explicit joint-close agreement). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 835d517 commit 7b1cd76

22 files changed

Lines changed: 1691 additions & 123 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
//
3+
//! Multi-file module loader for `compile-eph`.
4+
//!
5+
//! Resolves `import a/b/c` declarations by reading the matching file
6+
//! `<base_dir>/a/b/c.eph` relative to the root module. Walks the import
7+
//! graph transitively, detects cycles, and returns the modules in
8+
//! topological order (dependencies before dependents) so the compiler
9+
//! pipeline (desugar → typecheck → codegen) sees each module after its
10+
//! dependencies have already populated the registries.
11+
//!
12+
//! Scope today is deliberately small: file-system resolution against a
13+
//! single base directory, no package-manifest support yet, no version
14+
//! ranges. Both the dot-form (`Foo.Bar.Baz`) and slash-form
15+
//! (`hyperpolymath/ephapax/test`) are recognised — they map to the same
16+
//! file path with the separator normalised to `/`.
17+
18+
use std::collections::{HashMap, HashSet};
19+
use std::path::{Path, PathBuf};
20+
21+
use ephapax_parser::parse_surface_module;
22+
use ephapax_surface::SurfaceModule;
23+
24+
#[derive(Debug)]
25+
pub struct LoadedModule {
26+
/// Module path as written in the source (e.g. `hypatia/ui/gui`).
27+
#[allow(dead_code)]
28+
pub logical_path: String,
29+
/// Resolved file path on disk.
30+
pub file_path: PathBuf,
31+
/// Source contents (kept for error reporting).
32+
#[allow(dead_code)]
33+
pub source: String,
34+
/// Parsed surface module.
35+
pub surface: SurfaceModule,
36+
}
37+
38+
#[derive(Debug)]
39+
pub enum ResolveError {
40+
Io { path: PathBuf, message: String },
41+
Parse { path: PathBuf, message: String },
42+
Cycle { path: Vec<String> },
43+
}
44+
45+
impl std::fmt::Display for ResolveError {
46+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47+
match self {
48+
ResolveError::Io { path, message } => {
49+
write!(f, "cannot read {}: {}", path.display(), message)
50+
}
51+
ResolveError::Parse { path, message } => {
52+
write!(f, "{}: parse error: {}", path.display(), message)
53+
}
54+
ResolveError::Cycle { path } => {
55+
write!(f, "import cycle: {}", path.join(" -> "))
56+
}
57+
}
58+
}
59+
}
60+
61+
impl std::error::Error for ResolveError {}
62+
63+
/// Walk the import graph rooted at `root_path` and return all loaded
64+
/// modules in topological order — dependencies first, root last. Search
65+
/// for imports under `base_dir` (typically the directory containing the
66+
/// root file).
67+
pub fn load_program(
68+
root_path: &Path,
69+
base_dir: &Path,
70+
) -> Result<Vec<LoadedModule>, ResolveError> {
71+
let mut loaded: HashMap<String, LoadedModule> = HashMap::new();
72+
let mut order: Vec<String> = Vec::new();
73+
let mut visiting: HashSet<String> = HashSet::new();
74+
let mut stack: Vec<String> = Vec::new();
75+
76+
// Build a `declared module name → file path` index by scanning the
77+
// base directory for .eph files. Imports try the literal path first
78+
// (`a/b/c.eph` under base_dir); if that misses, they fall back to
79+
// this map. This lets files live anywhere in the tree as long as
80+
// they declare their module name in a `module a/b/c` header — which
81+
// matches existing corpora like hypatia/src/ui/gossamer/.
82+
let mod_index = scan_module_index(base_dir);
83+
84+
let root_module_path = root_module_path_from_source(root_path)?;
85+
visit(
86+
&root_module_path,
87+
Some(root_path),
88+
base_dir,
89+
&mod_index,
90+
&mut loaded,
91+
&mut order,
92+
&mut visiting,
93+
&mut stack,
94+
)?;
95+
// Re-arrange into the visit (post-order) order: dependencies first.
96+
let mut result = Vec::with_capacity(order.len());
97+
for name in order {
98+
if let Some(m) = loaded.remove(&name) {
99+
result.push(m);
100+
}
101+
}
102+
Ok(result)
103+
}
104+
105+
/// Walk `base_dir` recursively, reading the first `module a/b/c` line of
106+
/// every `.eph` file we find, and return a map from declared module name
107+
/// to file path. Files without a `module` header are skipped.
108+
fn scan_module_index(base_dir: &Path) -> HashMap<String, PathBuf> {
109+
let mut idx = HashMap::new();
110+
let mut stack = vec![base_dir.to_path_buf()];
111+
while let Some(dir) = stack.pop() {
112+
let entries = match std::fs::read_dir(&dir) {
113+
Ok(e) => e,
114+
Err(_) => continue,
115+
};
116+
for entry in entries.flatten() {
117+
let path = entry.path();
118+
if path.is_dir() {
119+
stack.push(path);
120+
continue;
121+
}
122+
if path.extension().and_then(|s| s.to_str()) != Some("eph") {
123+
continue;
124+
}
125+
let Ok(source) = std::fs::read_to_string(&path) else {
126+
continue;
127+
};
128+
if let Some(name) = first_module_declaration(&source) {
129+
idx.entry(name).or_insert(path);
130+
}
131+
}
132+
}
133+
idx
134+
}
135+
136+
fn first_module_declaration(source: &str) -> Option<String> {
137+
for line in source.lines() {
138+
let line = line.trim_start();
139+
if let Some(rest) = line.strip_prefix("module") {
140+
let rest = rest.trim();
141+
// Take everything up to a whitespace, comma, or comment marker.
142+
let end = rest
143+
.find(|c: char| {
144+
!(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '/')
145+
})
146+
.unwrap_or(rest.len());
147+
let name = &rest[..end];
148+
if !name.is_empty() {
149+
return Some(normalise_path(name));
150+
}
151+
}
152+
}
153+
None
154+
}
155+
156+
fn visit(
157+
logical: &str,
158+
explicit_file: Option<&Path>,
159+
base_dir: &Path,
160+
mod_index: &HashMap<String, PathBuf>,
161+
loaded: &mut HashMap<String, LoadedModule>,
162+
order: &mut Vec<String>,
163+
visiting: &mut HashSet<String>,
164+
stack: &mut Vec<String>,
165+
) -> Result<(), ResolveError> {
166+
if loaded.contains_key(logical) {
167+
return Ok(());
168+
}
169+
if visiting.contains(logical) {
170+
let mut cycle = stack.clone();
171+
cycle.push(logical.to_string());
172+
return Err(ResolveError::Cycle { path: cycle });
173+
}
174+
visiting.insert(logical.to_string());
175+
stack.push(logical.to_string());
176+
177+
let file_path = match explicit_file {
178+
Some(p) => p.to_path_buf(),
179+
None => {
180+
// 1) Literal path under base_dir (`a/b/c` → `<base>/a/b/c.eph`).
181+
let direct = logical_to_file_path(logical, base_dir);
182+
if direct.exists() {
183+
direct
184+
} else if let Some(p) = mod_index.get(logical) {
185+
// 2) Module-declaration index built by walking base_dir.
186+
p.clone()
187+
} else {
188+
// Fall back to the literal path so the IO error names a
189+
// useful location.
190+
direct
191+
}
192+
}
193+
};
194+
195+
let source = std::fs::read_to_string(&file_path).map_err(|e| ResolveError::Io {
196+
path: file_path.clone(),
197+
message: e.to_string(),
198+
})?;
199+
200+
let surface =
201+
parse_surface_module(&source, logical).map_err(|errs| ResolveError::Parse {
202+
path: file_path.clone(),
203+
message: errs
204+
.iter()
205+
.map(|e| format!("{}", e))
206+
.collect::<Vec<_>>()
207+
.join("; "),
208+
})?;
209+
210+
// Recurse into imports BEFORE inserting this module so that the
211+
// post-order visit places dependencies before this module.
212+
let deps: Vec<String> = surface
213+
.imports
214+
.iter()
215+
.map(|i| normalise_path(i.module.as_str()))
216+
.collect();
217+
for dep in &deps {
218+
visit(dep, None, base_dir, mod_index, loaded, order, visiting, stack)?;
219+
}
220+
221+
loaded.insert(
222+
logical.to_string(),
223+
LoadedModule {
224+
logical_path: logical.to_string(),
225+
file_path,
226+
source,
227+
surface,
228+
},
229+
);
230+
order.push(logical.to_string());
231+
stack.pop();
232+
visiting.remove(logical);
233+
Ok(())
234+
}
235+
236+
fn root_module_path_from_source(root_path: &Path) -> Result<String, ResolveError> {
237+
let source = std::fs::read_to_string(root_path).map_err(|e| ResolveError::Io {
238+
path: root_path.to_path_buf(),
239+
message: e.to_string(),
240+
})?;
241+
for line in source.lines() {
242+
let line = line.trim_start();
243+
if let Some(rest) = line.strip_prefix("module") {
244+
let rest = rest.trim();
245+
if let Some(end) = rest.find(|c: char| {
246+
!(c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '/')
247+
}) {
248+
return Ok(normalise_path(&rest[..end]));
249+
} else if !rest.is_empty() {
250+
return Ok(normalise_path(rest));
251+
}
252+
}
253+
}
254+
// No explicit `module` header — synthesise a logical path from the
255+
// filename stem.
256+
Ok(root_path
257+
.file_stem()
258+
.and_then(|s| s.to_str())
259+
.unwrap_or("root")
260+
.to_string())
261+
}
262+
263+
fn normalise_path(raw: &str) -> String {
264+
raw.replace('.', "/")
265+
}
266+
267+
fn logical_to_file_path(logical: &str, base_dir: &Path) -> PathBuf {
268+
let mut p = base_dir.to_path_buf();
269+
for seg in logical.split('/') {
270+
p.push(seg);
271+
}
272+
p.set_extension("eph");
273+
p
274+
}

0 commit comments

Comments
 (0)