Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions TEST-NEEDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
- Contract/invariant tests: `src/ephapax-cli/tests/contract_tests.rs` (type system invariants, 13 tests)
- Aspect tests: `src/ephapax-cli/tests/aspect_tests.rs` (security, performance, correctness, 13 tests)
- Benchmarks: `src/ephapax-parser/benches/parse_bench.rs`, `src/ephapax-vram-cache/benches/cache_bench.rs`
- Total: **486 tests** (`cargo test --workspace --all-targets`); pass/fail enforced by `rust-ci.yml`
- Documented all-target tests: 486
- Total: **488 tests** (`cargo test --workspace --all-targets`); pass/fail enforced by `rust-ci.yml`
- Documented all-target tests: 488

## CRG Testing Taxonomy — Status

Expand Down
20 changes: 20 additions & 0 deletions docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ path, not wasm.

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

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

A program cannot consume another module's API:

----
Expand Down
72 changes: 72 additions & 0 deletions src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MPL-2.0
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//
// Phase K — G6: qualified module-member access (`M.member`).
//
// Phase I (#43) made an imported module's public items visible
// *unqualified*. G6 adds the qualified *syntax* `M.member`, where `M` is
// the last segment of an imported module's path, desugaring to that same
// unqualified member (a constructor for an upper-case member, a function /
// value otherwise). Before this, any named member access errored at the
// parser with "Named field access not yet supported".

use std::process::Command;

fn ephapax_bin() -> String {
env!("CARGO_BIN_EXE_ephapax").to_string()
}

fn fixture(name: &str) -> String {
format!(
"{}/../../tests/v2-grammar/fixtures/{}",
env!("CARGO_MANIFEST_DIR"),
name
)
}

/// `app.eph` uses `lib.inc` (qualified function), `lib.On` (qualified
/// constructor), and `inc` (unqualified, phase I) — all resolving to the
/// imported module `lib`. End-to-end compile must succeed.
#[test]
fn qualified_module_member_access_compiles_end_to_end() {
let out = tempfile::NamedTempFile::new().expect("temp file");
let output = Command::new(ephapax_bin())
.arg("compile-eph")
.arg(fixture("qualified-import/app.eph"))
.arg("-o")
.arg(out.path())
.output()
.expect("ephapax must run");
assert!(
output.status.success(),
"qualified-access compile failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}

/// A qualifier that names no imported module is a clean desugar error, not
/// a panic or a silent miscompile.
#[test]
fn unknown_qualifier_is_a_clean_error() {
let dir = tempfile::tempdir().expect("temp dir");
let app = dir.path().join("app.eph");
std::fs::write(&app, "module app\nimport lib\nfn t(): I32 = nope.inc(1)\n")
.expect("write app");
std::fs::write(dir.path().join("lib.eph"), "module lib\npub fn inc(n: I32): I32 = n + 1\n")
.expect("write lib");
let out = tempfile::NamedTempFile::new().expect("temp file");
let output = Command::new(ephapax_bin())
.arg("compile-eph")
.arg(&app)
.arg("-o")
.arg(out.path())
.output()
.expect("ephapax must run");
assert!(!output.status.success(), "unknown qualifier must fail to compile");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not an imported module"),
"expected an 'unknown module' desugar error, got:\n{stderr}"
);
}
123 changes: 114 additions & 9 deletions src/ephapax-desugar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
//! Some(x) ⟹ inr(x)
//! ```

use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

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

/// The qualifier name of a `base.member` access, if `base` is a bare
/// module name — a `Var(m)` (lower-case module) or a nullary
/// `Construct { ctor: m }` (upper-case module). Anything else (a value,
/// a call result, …) has no module qualifier.
fn module_qualifier_of(base: &SurfaceExpr) -> Option<SmolStr> {
match &base.kind {
SurfaceExprKind::Var(v) => Some(v.clone()),
SurfaceExprKind::Construct { ctor, args } if args.is_empty() => Some(ctor.clone()),
_ => None,
}
}

/// The last `/`- or `.`-separated segment of an import path
/// (`lib/math` → `math`, `Foo.Bar` → `Bar`, `Coproc` → `Coproc`). This is
/// the name a `M.member` qualifier must use.
fn last_segment(path: &str) -> SmolStr {
path.rsplit(|c| c == '/' || c == '.')
.next()
.unwrap_or(path)
.into()
}

/// Desugaring errors.
#[derive(Debug, Clone, Error)]
pub enum DesugarError {
Expand Down Expand Up @@ -99,6 +121,12 @@ pub enum DesugarError {

#[error("empty match expression")]
EmptyMatch,

#[error("qualified access `{qualifier}.{member}`: `{qualifier}` is not an imported module (record field access is not yet supported)")]
QualifiedAccessUnknownModule { qualifier: String, member: String },

#[error("member access on a non-module expression is not supported (only `Module.member` qualified access)")]
FieldAccessOnNonModule,
}

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

impl Desugarer {
/// Create a new desugarer with an empty data registry.
pub fn new() -> Self {
Self {
registry: DataRegistry::new(),
module_qualifiers: HashSet::new(),
}
}

/// Create a new desugarer with a pre-populated registry.
pub fn with_registry(registry: DataRegistry) -> Self {
Self { registry }
Self {
registry,
module_qualifiers: HashSet::new(),
}
}

/// Desugar a complete surface module to a core module.
///
/// First pass: collect all data declarations into the registry.
/// Second pass: desugar all declarations.
pub fn desugar_module(&mut self, module: &SurfaceModule) -> Result<Module, DesugarError> {
// Record this module's import qualifiers (the last path segment of
// each `import`), so qualified `M.member` access resolves against
// them. Reset per module — qualifiers are not inherited.
self.module_qualifiers = module
.imports
.iter()
.map(|i| last_segment(i.module.as_str()))
.collect();

// First pass: register all data types and extern types
for decl in &module.decls {
match decl {
Expand Down Expand Up @@ -523,11 +569,50 @@ impl Desugarer {
SurfaceExprKind::Match { scrutinee, arms } => {
return self.desugar_match(scrutinee, arms, span);
}

SurfaceExprKind::FieldAccess { base, field } => {
return self.desugar_field_access(base, field, span);
}
};

Ok(Expr::new(kind, span))
}

/// Desugar qualified module-member access `M.member`.
///
/// `M` must be an imported module (matched by the last segment of its
/// import path); phase-I import resolution has already made `M`'s
/// public items visible in the importer's shared scope, so the member
/// resolves *unqualified*: an upper-case `member` is a constructor
/// (`Construct`), otherwise a function / value reference (`Var`).
/// Record field access on a value is not yet supported.
fn desugar_field_access(
&self,
base: &SurfaceExpr,
field: &SmolStr,
span: Span,
) -> Result<Expr, DesugarError> {
let qualifier = module_qualifier_of(base).ok_or(DesugarError::FieldAccessOnNonModule)?;
if !self.module_qualifiers.contains(&qualifier) {
return Err(DesugarError::QualifiedAccessUnknownModule {
qualifier: qualifier.to_string(),
member: field.to_string(),
});
}
let resolved = if field.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
SurfaceExpr::new(
SurfaceExprKind::Construct {
ctor: field.clone(),
args: Vec::new(),
},
span,
)
} else {
SurfaceExpr::new(SurfaceExprKind::Var(field.clone()), span)
};
self.desugar_expr(&resolved)
}

// =====================================================================
// Type desugaring
// =====================================================================
Expand Down Expand Up @@ -770,7 +855,9 @@ impl Desugarer {
let (_, ctor_defs) = self
.registry
.get_type_ctors(info.data_name.as_str())
.ok_or_else(|| DesugarError::UnknownType { name: info.data_name.to_string() })?;
.ok_or_else(|| DesugarError::UnknownType {
name: info.data_name.to_string(),
})?;
let ctors = ctor_defs.clone();

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

let (_, ctor_defs) = self.registry.get_type_ctors(data_name.as_str())
.ok_or_else(|| DesugarError::UnknownType { name: data_name.to_string() })?;
let (_, ctor_defs) = self
.registry
.get_type_ctors(data_name.as_str())
.ok_or_else(|| DesugarError::UnknownType {
name: data_name.to_string(),
})?;
let ctors = ctor_defs.clone();

// Build an ordered map: constructor index → (pattern bindings, body)
Expand Down Expand Up @@ -1856,15 +1947,24 @@ mod tests {
};
// The two fn items end up at indexes 2 and 3 (after the two
// type items at 0 and 1).
let ExternItem::Fn { ret_ty: window_ret, .. } = &items[2] else {
let ExternItem::Fn {
ret_ty: window_ret, ..
} = &items[2]
else {
panic!("expected ExternItem::Fn at index 2");
};
let ExternItem::Fn { ret_ty: ipc_ret, .. } = &items[3] else {
let ExternItem::Fn {
ret_ty: ipc_ret, ..
} = &items[3]
else {
panic!("expected ExternItem::Fn at index 3");
};
assert_eq!(*window_ret, Ty::Var("Window".into()));
assert_eq!(*ipc_ret, Ty::Var("IpcChannel".into()));
assert_ne!(window_ret, ipc_ret, "distinct nominal types must not be equal");
assert_ne!(
window_ret, ipc_ret,
"distinct nominal types must not be equal"
);
}

/// `SurfaceDecl::Extern` lowers to `Decl::Extern` with item types
Expand Down Expand Up @@ -1903,7 +2003,12 @@ mod tests {
&items[0],
ExternItem::Type { name } if name.as_str() == "Window"
));
if let ExternItem::Fn { name, params, ret_ty } = &items[1] {
if let ExternItem::Fn {
name,
params,
ret_ty,
} = &items[1]
{
assert_eq!(name.as_str(), "window_open");
assert_eq!(params.len(), 1);
assert_eq!(params[0].0.as_str(), "title");
Expand Down
2 changes: 1 addition & 1 deletion src/ephapax-parser/src/ephapax.pest
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ postfix_op = {
call_op = { "(" ~ call_args? ~ ")" }
call_args = { expression ~ ("," ~ expression)* }
index_op = { "[" ~ expression ~ "]" }
member_op = { "." ~ (integer | identifier) }
member_op = { "." ~ (integer | identifier | constructor_name) }

// ============================================================================
// Atomic Expressions
Expand Down
Loading
Loading