Skip to content

Commit 02bf873

Browse files
feat(v2): qualified module-member access M.member (G6 keystone) (#331)
## Summary Closes the parse half of **G6**, "the single highest-leverage v2 toolchain item" (`docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc`). Phase I (#43) already made an imported module's public items visible **unqualified** (the cross-module test passes). This adds the qualified **`M.member` syntax**, which previously errored at the parser with *"Named field access not yet supported"*. ## The change (parser → surface AST → desugar) - **Grammar** (`ephapax.pest`): `member_op` accepts a `constructor_name` after `.`, so both `M.fn` (lower-case) and `M.Ctor` (upper-case) lex. - **Surface AST**: new `SurfaceExprKind::FieldAccess { base, field }`. - **Parser** (`surface.rs`): a named member access builds `FieldAccess` instead of erroring. - **Desugar** (`ephapax-desugar`): `FieldAccess` resolves against the importer's imports — `M` is the **last path segment** of an `import` (`import lib/math` → `math`) — to the unqualified member, reusing phase I's shared scope: - upper-case `member` → constructor (`Construct`), - otherwise → function/value (`Var`). - Unknown qualifier → clean `QualifiedAccessUnknownModule` error; member access on a non-module base → rejected (record field access stays a follow-up). ## Verified - New fixture `tests/v2-grammar/fixtures/qualified-import/` + test `src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs` (2 tests): `lib.inc(...)` (qualified function), `lib.On` (qualified constructor), and unqualified `inc` (phase I) all compile **end-to-end to wasm**; an unknown qualifier is a clean error. - Full **parser / desugar / surface / cli** suites pass — **no regressions** (parser 62, etc.). - `TEST-NEEDS.md` count bumped 486 → 488. ## Mapped to the doc The G6 doc's first snippet (`Coproc.available(Coproc.Tensor)` not parsing) is exactly this: `available`=function→`Var`, `Tensor`=nullary constructor→`Construct`. Resolution note added to the gaps doc. ## Follow-ups (documented, out of scope here) - `M.Ctor(args)` — qualified constructor *with arguments* currently lowers to `App(Ctor, args)`, not `Construct{Ctor,[args]}`. - Record field access on values (`value.field`) — still unsupported. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AGFqWzByua4dKbA7W7oVsL --- _Generated by [Claude Code](https://claude.ai/code/session_01AGFqWzByua4dKbA7W7oVsL)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72a0f36 commit 02bf873

9 files changed

Lines changed: 293 additions & 26 deletions

File tree

TEST-NEEDS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
- Contract/invariant tests: `src/ephapax-cli/tests/contract_tests.rs` (type system invariants, 13 tests)
1616
- Aspect tests: `src/ephapax-cli/tests/aspect_tests.rs` (security, performance, correctness, 13 tests)
1717
- Benchmarks: `src/ephapax-parser/benches/parse_bench.rs`, `src/ephapax-vram-cache/benches/cache_bench.rs`
18-
- Total: **486 tests** (`cargo test --workspace --all-targets`); pass/fail enforced by `rust-ci.yml`
19-
- Documented all-target tests: 486
18+
- Total: **488 tests** (`cargo test --workspace --all-targets`); pass/fail enforced by `rust-ci.yml`
19+
- Documented all-target tests: 488
2020

2121
## CRG Testing Taxonomy — Status
2222

docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,26 @@ path, not wasm.
113113

114114
== G6 — no working module-import + qualified-member access (THE keystone)
115115

116+
[NOTE]
117+
.RESOLVED (2026)
118+
====
119+
Both halves now work. *Bringing members into scope* (the second snippet)
120+
landed with phase I (`#43`): the multi-file resolver walks the `import`
121+
graph and a shared `DataRegistry` + module scope makes an imported
122+
module's public items visible *unqualified*. *Qualified `M.member`
123+
access* (the first snippet) now parses and desugars: `member_op` accepts
124+
a `constructor_name`, a new `SurfaceExprKind::FieldAccess` node carries
125+
`base.member`, and the desugarer resolves it against the importer's
126+
imports (`M` = the last path segment of an `import`) to the unqualified
127+
member — a constructor for an upper-case member, a function/value
128+
otherwise. So `lib.inc(...)` (function) and `lib.On` (constructor) both
129+
compile end-to-end (see `tests/v2-grammar/fixtures/qualified-import/` and
130+
`src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs`). Remaining
131+
follow-ups: qualified constructor application with args
132+
(`M.Ctor(x)` currently lowers to `App(Ctor, x)`, not `Construct{Ctor,[x]}`)
133+
and record field access on values (still unsupported).
134+
====
135+
116136
A program cannot consume another module's API:
117137

118138
----
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Phase K — G6: qualified module-member access (`M.member`).
5+
//
6+
// Phase I (#43) made an imported module's public items visible
7+
// *unqualified*. G6 adds the qualified *syntax* `M.member`, where `M` is
8+
// the last segment of an imported module's path, desugaring to that same
9+
// unqualified member (a constructor for an upper-case member, a function /
10+
// value otherwise). Before this, any named member access errored at the
11+
// parser with "Named field access not yet supported".
12+
13+
use std::process::Command;
14+
15+
fn ephapax_bin() -> String {
16+
env!("CARGO_BIN_EXE_ephapax").to_string()
17+
}
18+
19+
fn fixture(name: &str) -> String {
20+
format!(
21+
"{}/../../tests/v2-grammar/fixtures/{}",
22+
env!("CARGO_MANIFEST_DIR"),
23+
name
24+
)
25+
}
26+
27+
/// `app.eph` uses `lib.inc` (qualified function), `lib.On` (qualified
28+
/// constructor), and `inc` (unqualified, phase I) — all resolving to the
29+
/// imported module `lib`. End-to-end compile must succeed.
30+
#[test]
31+
fn qualified_module_member_access_compiles_end_to_end() {
32+
let out = tempfile::NamedTempFile::new().expect("temp file");
33+
let output = Command::new(ephapax_bin())
34+
.arg("compile-eph")
35+
.arg(fixture("qualified-import/app.eph"))
36+
.arg("-o")
37+
.arg(out.path())
38+
.output()
39+
.expect("ephapax must run");
40+
assert!(
41+
output.status.success(),
42+
"qualified-access compile failed:\nstdout: {}\nstderr: {}",
43+
String::from_utf8_lossy(&output.stdout),
44+
String::from_utf8_lossy(&output.stderr),
45+
);
46+
}
47+
48+
/// A qualifier that names no imported module is a clean desugar error, not
49+
/// a panic or a silent miscompile.
50+
#[test]
51+
fn unknown_qualifier_is_a_clean_error() {
52+
let dir = tempfile::tempdir().expect("temp dir");
53+
let app = dir.path().join("app.eph");
54+
std::fs::write(&app, "module app\nimport lib\nfn t(): I32 = nope.inc(1)\n")
55+
.expect("write app");
56+
std::fs::write(dir.path().join("lib.eph"), "module lib\npub fn inc(n: I32): I32 = n + 1\n")
57+
.expect("write lib");
58+
let out = tempfile::NamedTempFile::new().expect("temp file");
59+
let output = Command::new(ephapax_bin())
60+
.arg("compile-eph")
61+
.arg(&app)
62+
.arg("-o")
63+
.arg(out.path())
64+
.output()
65+
.expect("ephapax must run");
66+
assert!(!output.status.success(), "unknown qualifier must fail to compile");
67+
let stderr = String::from_utf8_lossy(&output.stderr);
68+
assert!(
69+
stderr.contains("not an imported module"),
70+
"expected an 'unknown module' desugar error, got:\n{stderr}"
71+
);
72+
}

src/ephapax-desugar/src/lib.rs

Lines changed: 114 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
//! Some(x) ⟹ inr(x)
5555
//! ```
5656
57-
use std::collections::HashMap;
57+
use std::collections::{HashMap, HashSet};
5858

5959
use ephapax_surface::{
6060
ConstructorDef, DataDecl, MatchArm, Pattern, Span, SurfaceDecl, SurfaceExpr, SurfaceExprKind,
@@ -71,6 +71,28 @@ fn lower_visibility(v: SurfaceVisibility) -> Visibility {
7171
}
7272
}
7373

74+
/// The qualifier name of a `base.member` access, if `base` is a bare
75+
/// module name — a `Var(m)` (lower-case module) or a nullary
76+
/// `Construct { ctor: m }` (upper-case module). Anything else (a value,
77+
/// a call result, …) has no module qualifier.
78+
fn module_qualifier_of(base: &SurfaceExpr) -> Option<SmolStr> {
79+
match &base.kind {
80+
SurfaceExprKind::Var(v) => Some(v.clone()),
81+
SurfaceExprKind::Construct { ctor, args } if args.is_empty() => Some(ctor.clone()),
82+
_ => None,
83+
}
84+
}
85+
86+
/// The last `/`- or `.`-separated segment of an import path
87+
/// (`lib/math` → `math`, `Foo.Bar` → `Bar`, `Coproc` → `Coproc`). This is
88+
/// the name a `M.member` qualifier must use.
89+
fn last_segment(path: &str) -> SmolStr {
90+
path.rsplit(|c| c == '/' || c == '.')
91+
.next()
92+
.unwrap_or(path)
93+
.into()
94+
}
95+
7496
/// Desugaring errors.
7597
#[derive(Debug, Clone, Error)]
7698
pub enum DesugarError {
@@ -99,6 +121,12 @@ pub enum DesugarError {
99121

100122
#[error("empty match expression")]
101123
EmptyMatch,
124+
125+
#[error("qualified access `{qualifier}.{member}`: `{qualifier}` is not an imported module (record field access is not yet supported)")]
126+
QualifiedAccessUnknownModule { qualifier: String, member: String },
127+
128+
#[error("member access on a non-module expression is not supported (only `Module.member` qualified access)")]
129+
FieldAccessOnNonModule,
102130
}
103131

104132
// =========================================================================
@@ -217,26 +245,44 @@ impl DataRegistry {
217245
/// [`DataRegistry`] and uses it to transform `Construct` and `Match` nodes.
218246
pub struct Desugarer {
219247
registry: DataRegistry,
248+
/// Last-segment names of the importer's modules (e.g. `math` for
249+
/// `import lib/math`), used to recognise a `M.member` qualifier as a
250+
/// module reference at desugar time. Reset per-module by
251+
/// [`Desugarer::desugar_module`].
252+
module_qualifiers: HashSet<SmolStr>,
220253
}
221254

222255
impl Desugarer {
223256
/// Create a new desugarer with an empty data registry.
224257
pub fn new() -> Self {
225258
Self {
226259
registry: DataRegistry::new(),
260+
module_qualifiers: HashSet::new(),
227261
}
228262
}
229263

230264
/// Create a new desugarer with a pre-populated registry.
231265
pub fn with_registry(registry: DataRegistry) -> Self {
232-
Self { registry }
266+
Self {
267+
registry,
268+
module_qualifiers: HashSet::new(),
269+
}
233270
}
234271

235272
/// Desugar a complete surface module to a core module.
236273
///
237274
/// First pass: collect all data declarations into the registry.
238275
/// Second pass: desugar all declarations.
239276
pub fn desugar_module(&mut self, module: &SurfaceModule) -> Result<Module, DesugarError> {
277+
// Record this module's import qualifiers (the last path segment of
278+
// each `import`), so qualified `M.member` access resolves against
279+
// them. Reset per module — qualifiers are not inherited.
280+
self.module_qualifiers = module
281+
.imports
282+
.iter()
283+
.map(|i| last_segment(i.module.as_str()))
284+
.collect();
285+
240286
// First pass: register all data types and extern types
241287
for decl in &module.decls {
242288
match decl {
@@ -523,11 +569,50 @@ impl Desugarer {
523569
SurfaceExprKind::Match { scrutinee, arms } => {
524570
return self.desugar_match(scrutinee, arms, span);
525571
}
572+
573+
SurfaceExprKind::FieldAccess { base, field } => {
574+
return self.desugar_field_access(base, field, span);
575+
}
526576
};
527577

528578
Ok(Expr::new(kind, span))
529579
}
530580

581+
/// Desugar qualified module-member access `M.member`.
582+
///
583+
/// `M` must be an imported module (matched by the last segment of its
584+
/// import path); phase-I import resolution has already made `M`'s
585+
/// public items visible in the importer's shared scope, so the member
586+
/// resolves *unqualified*: an upper-case `member` is a constructor
587+
/// (`Construct`), otherwise a function / value reference (`Var`).
588+
/// Record field access on a value is not yet supported.
589+
fn desugar_field_access(
590+
&self,
591+
base: &SurfaceExpr,
592+
field: &SmolStr,
593+
span: Span,
594+
) -> Result<Expr, DesugarError> {
595+
let qualifier = module_qualifier_of(base).ok_or(DesugarError::FieldAccessOnNonModule)?;
596+
if !self.module_qualifiers.contains(&qualifier) {
597+
return Err(DesugarError::QualifiedAccessUnknownModule {
598+
qualifier: qualifier.to_string(),
599+
member: field.to_string(),
600+
});
601+
}
602+
let resolved = if field.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
603+
SurfaceExpr::new(
604+
SurfaceExprKind::Construct {
605+
ctor: field.clone(),
606+
args: Vec::new(),
607+
},
608+
span,
609+
)
610+
} else {
611+
SurfaceExpr::new(SurfaceExprKind::Var(field.clone()), span)
612+
};
613+
self.desugar_expr(&resolved)
614+
}
615+
531616
// =====================================================================
532617
// Type desugaring
533618
// =====================================================================
@@ -770,7 +855,9 @@ impl Desugarer {
770855
let (_, ctor_defs) = self
771856
.registry
772857
.get_type_ctors(info.data_name.as_str())
773-
.ok_or_else(|| DesugarError::UnknownType { name: info.data_name.to_string() })?;
858+
.ok_or_else(|| DesugarError::UnknownType {
859+
name: info.data_name.to_string(),
860+
})?;
774861
let ctors = ctor_defs.clone();
775862

776863
// Wrap in inl/inr chain based on index
@@ -935,8 +1022,12 @@ impl Desugarer {
9351022
// Find the data type from constructor patterns
9361023
let data_name = self.find_data_type_from_arms(arms)?;
9371024

938-
let (_, ctor_defs) = self.registry.get_type_ctors(data_name.as_str())
939-
.ok_or_else(|| DesugarError::UnknownType { name: data_name.to_string() })?;
1025+
let (_, ctor_defs) = self
1026+
.registry
1027+
.get_type_ctors(data_name.as_str())
1028+
.ok_or_else(|| DesugarError::UnknownType {
1029+
name: data_name.to_string(),
1030+
})?;
9401031
let ctors = ctor_defs.clone();
9411032

9421033
// Build an ordered map: constructor index → (pattern bindings, body)
@@ -1856,15 +1947,24 @@ mod tests {
18561947
};
18571948
// The two fn items end up at indexes 2 and 3 (after the two
18581949
// type items at 0 and 1).
1859-
let ExternItem::Fn { ret_ty: window_ret, .. } = &items[2] else {
1950+
let ExternItem::Fn {
1951+
ret_ty: window_ret, ..
1952+
} = &items[2]
1953+
else {
18601954
panic!("expected ExternItem::Fn at index 2");
18611955
};
1862-
let ExternItem::Fn { ret_ty: ipc_ret, .. } = &items[3] else {
1956+
let ExternItem::Fn {
1957+
ret_ty: ipc_ret, ..
1958+
} = &items[3]
1959+
else {
18631960
panic!("expected ExternItem::Fn at index 3");
18641961
};
18651962
assert_eq!(*window_ret, Ty::Var("Window".into()));
18661963
assert_eq!(*ipc_ret, Ty::Var("IpcChannel".into()));
1867-
assert_ne!(window_ret, ipc_ret, "distinct nominal types must not be equal");
1964+
assert_ne!(
1965+
window_ret, ipc_ret,
1966+
"distinct nominal types must not be equal"
1967+
);
18681968
}
18691969

18701970
/// `SurfaceDecl::Extern` lowers to `Decl::Extern` with item types
@@ -1903,7 +2003,12 @@ mod tests {
19032003
&items[0],
19042004
ExternItem::Type { name } if name.as_str() == "Window"
19052005
));
1906-
if let ExternItem::Fn { name, params, ret_ty } = &items[1] {
2006+
if let ExternItem::Fn {
2007+
name,
2008+
params,
2009+
ret_ty,
2010+
} = &items[1]
2011+
{
19072012
assert_eq!(name.as_str(), "window_open");
19082013
assert_eq!(params.len(), 1);
19092014
assert_eq!(params[0].0.as_str(), "title");

src/ephapax-parser/src/ephapax.pest

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ postfix_op = {
345345
call_op = { "(" ~ call_args? ~ ")" }
346346
call_args = { expression ~ ("," ~ expression)* }
347347
index_op = { "[" ~ expression ~ "]" }
348-
member_op = { "." ~ (integer | identifier) }
348+
member_op = { "." ~ (integer | identifier | constructor_name) }
349349

350350
// ============================================================================
351351
// Atomic Expressions

0 commit comments

Comments
 (0)