From 1e3a7d5dbfbb47f2047c5561898dab2436e92795 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:05:41 +0000 Subject: [PATCH] feat(v2): qualified module-member access M.member (G6 keystone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the parse half of the G6 keystone gap (docs/v2-grammar-and-codegen- gaps-2026-06-16.adoc). Phase I (#43) already made an imported module's public items visible UNQUALIFIED; this adds the qualified `M.member` SYNTAX, which previously errored at the parser with "Named field access not yet supported". - grammar: `member_op` accepts a `constructor_name` after `.` (so both `M.fn` and `M.Ctor` lex). - surface AST: new `SurfaceExprKind::FieldAccess { base, field }`. - parser: a named member access builds `FieldAccess` instead of erroring. - desugar: `FieldAccess` resolves against the importer's imports — `M` is the last path segment of an `import` — to the unqualified member: an upper-case `member` desugars to a constructor (`Construct`), otherwise a function/value (`Var`), reusing phase I's shared scope. An unknown qualifier is a clean `QualifiedAccessUnknownModule` error; member access on a non-module base is rejected (record field access stays a follow-up). Verified: `lib.inc(...)` (function) + `lib.On` (constructor) compile end-to-end to wasm (new fixture tests/v2-grammar/fixtures/qualified-import/ + test src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs, 2 tests). Full parser/desugar/surface/cli suites pass — no regressions. Follow-ups: `M.Ctor(args)` (lowers to App, not Construct-with-args) and record field access on values. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AGFqWzByua4dKbA7W7oVsL --- TEST-NEEDS.md | 4 +- ...2-grammar-and-codegen-gaps-2026-06-16.adoc | 20 +++ .../tests/v2_grammar_phase_k_qualified.rs | 72 ++++++++++ src/ephapax-desugar/src/lib.rs | 123 ++++++++++++++++-- src/ephapax-parser/src/ephapax.pest | 2 +- src/ephapax-parser/src/surface.rs | 51 ++++++-- src/ephapax-surface/src/lib.rs | 22 +++- .../fixtures/qualified-import/app.eph | 13 ++ .../fixtures/qualified-import/lib.eph | 12 ++ 9 files changed, 293 insertions(+), 26 deletions(-) create mode 100644 src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs create mode 100644 tests/v2-grammar/fixtures/qualified-import/app.eph create mode 100644 tests/v2-grammar/fixtures/qualified-import/lib.eph diff --git a/TEST-NEEDS.md b/TEST-NEEDS.md index 63af0f0d..e3a6965e 100644 --- a/TEST-NEEDS.md +++ b/TEST-NEEDS.md @@ -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 diff --git a/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc b/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc index ea617ba2..5c76f0da 100644 --- a/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc +++ b/docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc @@ -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: ---- diff --git a/src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs b/src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs new file mode 100644 index 00000000..e528f8a9 --- /dev/null +++ b/src/ephapax-cli/tests/v2_grammar_phase_k_qualified.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// +// 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}" + ); +} diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index 261994cb..41117a4d 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -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, @@ -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 { + 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 { @@ -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, } // ========================================================================= @@ -217,6 +245,11 @@ 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, } impl Desugarer { @@ -224,12 +257,16 @@ impl Desugarer { 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. @@ -237,6 +274,15 @@ impl Desugarer { /// First pass: collect all data declarations into the registry. /// Second pass: desugar all declarations. pub fn desugar_module(&mut self, module: &SurfaceModule) -> Result { + // 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 { @@ -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 { + 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 // ===================================================================== @@ -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 @@ -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) @@ -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 @@ -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"); diff --git a/src/ephapax-parser/src/ephapax.pest b/src/ephapax-parser/src/ephapax.pest index 855e75c0..90b784d7 100644 --- a/src/ephapax-parser/src/ephapax.pest +++ b/src/ephapax-parser/src/ephapax.pest @@ -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 diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index 2ece114e..910e8086 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -193,11 +193,10 @@ fn parse_extern_item(pair: pest::iterators::Pair) -> Result) -> Result) -> Result) -> Result { + // Named member access `base.member` — qualified + // module-member access (`M.f`, `M.Ctor`). The + // desugarer resolves it against the imported + // modules. + let field: SmolStr = member.as_str().into(); + result = SurfaceExpr::new( + SurfaceExprKind::FieldAccess { + base: Box::new(result), + field, + }, + span, + ); + } _ => { - // Field access — not yet in surface AST, treat as error return Err(ParseError::Syntax { - message: "Named field access not yet supported".into(), + message: "unexpected member access form".into(), span: span_from_pair(&member), }); } @@ -1507,7 +1525,10 @@ fn parse_atom_expr(pair: pest::iterators::Pair) -> Result) -> Result Ok(SurfaceExpr::new(SurfaceExprKind::Lit(Literal::Unit), span)), 1 => { // invariant: match arm 1 guarantees len() == 1, so next() returns Some - Ok(exprs.into_iter().next().expect("invariant: exprs.len() == 1")) + Ok(exprs + .into_iter() + .next() + .expect("invariant: exprs.len() == 1")) } 2 => { let mut iter = exprs.into_iter(); @@ -1812,7 +1836,10 @@ fn parse_type_atom(pair: pest::iterators::Pair) -> Result { let elem_ty = parse_type( diff --git a/src/ephapax-surface/src/lib.rs b/src/ephapax-surface/src/lib.rs index 899d2e57..f71e0d18 100644 --- a/src/ephapax-surface/src/lib.rs +++ b/src/ephapax-surface/src/lib.rs @@ -93,7 +93,10 @@ pub enum SurfaceTy { }, /// Second-class borrow `&T` (shared) or `&mut T` (exclusive). - Borrow { inner: Box, mutable: bool }, + Borrow { + inner: Box, + mutable: bool, + }, /// Type variable (bound by data declaration) Var(TyVar), @@ -325,7 +328,10 @@ pub enum SurfaceExprKind { name: RegionName, body: Box, }, - Borrow { inner: Box, mutable: bool }, + Borrow { + inner: Box, + mutable: bool, + }, Deref(Box), Drop(Box), Copy(Box), @@ -379,6 +385,18 @@ pub enum SurfaceExprKind { /// Match arms (pattern → body) arms: Vec, }, + + /// Qualified module-member access: `M.member`, where `M` is an + /// imported module (referred to by the last segment of its import + /// path). Desugars to the unqualified member — a constructor + /// (`Construct`) when `member` is upper-case, otherwise a function / + /// value reference (`Var`) — resolved in the importer's shared scope. + FieldAccess { + /// The qualifier expression (a bare module name). + base: Box, + /// The member being selected. + field: SmolStr, + }, } // ========================================================================= diff --git a/tests/v2-grammar/fixtures/qualified-import/app.eph b/tests/v2-grammar/fixtures/qualified-import/app.eph new file mode 100644 index 00000000..ddebfe34 --- /dev/null +++ b/tests/v2-grammar/fixtures/qualified-import/app.eph @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// G6: qualified module-member access — `lib.inc` (function) and `lib.On` +// (constructor) reached via the `M.member` syntax. Composes with phase I: +// the inner `inc` is the same function reached unqualified. + +module app + +import lib + +fn bump(n: I32): I32 = lib.inc(inc(n)) + +fn check(): I32 = lib.is_on(lib.On) diff --git a/tests/v2-grammar/fixtures/qualified-import/lib.eph b/tests/v2-grammar/fixtures/qualified-import/lib.eph new file mode 100644 index 00000000..3c3c49f3 --- /dev/null +++ b/tests/v2-grammar/fixtures/qualified-import/lib.eph @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// Library module for the G6 qualified-access fixture. Published at `lib`; +// exposes a public function, a public data type, and a consumer of it. + +module lib + +pub data Flag = On | Off + +pub fn inc(n: I32): I32 = n + 1 + +pub fn is_on(f: Flag): I32 = match f of | On => 1 | Off => 0 end