From a5dda7a4db87cf1817bd706273ada4e4e0b4d047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20Christiansen=20S=C3=B8rby?= Date: Sat, 22 Aug 2026 18:44:19 +0200 Subject: [PATCH] lang+core: dot field access sugar for single-constructor types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a.fi` (and chains, `a.fi.fi2`) now desugars to `match a { {fi} => fi }` for any locally-bound value of a single-constructor type — `struct` or plain `type` alike. `a.fi` was already parsed as a dotted module path (`NameRef::P`): the parser has no scope info to tell a local variable apart from a module name. The fix lives entirely in name resolution, where the binder stack is actually available: - core/src/lower_core.rs: `lower_var`'s `NameRef::P` arm now checks `ctx.find_bound` on the path's first segment. If it's a local binding, synthesize the same bare-form field-pattern `Match` the parser already builds for destructured `def` params — one nested match per remaining segment (new `lower_field_access_chain` helper). Otherwise, falls through to the previous global-atom behavior unchanged. - lang/parser.mo: mirrors the same fix in `variable_try_path`, checking `find_index` against the threaded `ctx` before flattening a dotted path to a global reference. New `field_access_chain` helper reuses the `MatchCase`/`FieldPattern` shape `lam_parsed_params_loop` already builds for destructured params. Because this reuses the existing struct-field-destructuring pipeline (`resolve_field_pattern_case`/`desugar_struct_literals`/`permute_binders` in core_check.rs, and their lang/typecheck mirrors) unchanged, no other file needed to change. Tests: 7 Rust integration tests (core/tests/field_access_integration_test.rs) covering single/chained access, field-order correctness, plain `type`, shadowing, module-path regression, and both error paths; 3 unit tests in lower_core.rs; 3 self-hosted tests in lang/module.mo. Also corrects AGENTS.md's stale "dot syntax doesn't work for field access" note (which also described a since-removed legacy "method call" desugaring) to describe current behavior. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 22 ++- core/src/lower_core.rs | 131 ++++++++++++++- core/tests/field_access_integration_test.rs | 168 ++++++++++++++++++++ lang/module.mo | 72 +++++++++ lang/parser.mo | 98 ++++++++++-- 5 files changed, 460 insertions(+), 31 deletions(-) create mode 100644 core/tests/field_access_integration_test.rs diff --git a/AGENTS.md b/AGENTS.md index cdf3206..61c619e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -779,25 +779,21 @@ map_parse (fn s => s) (tag "x") "xy" // works bind_parse (tag "x") (fn (s : String) => tag "y") "xy" // works ``` -### Struct Field Access via Dot Syntax Is Not Valid Monad +### Struct Field Access via Dot Syntax -**Problem**: Writing `loc.offset` or `span.fragment` to access struct fields appears natural but does NOT work. Dot syntax in Monad is method-call syntax (`x.fun` desugars to `Type.fun x`), NOT field access. Using dot syntax on a struct produces "unexpected token" or "not a function" errors. - -**Root cause**: Monad has no dedicated field access syntax for structs. Dot syntax is exclusively for method calls and module paths. - -**Correct pattern**: Access struct fields via pattern matching on the `mk` constructor: +Writing `loc.offset` (or a chain, `line.span.offset`) on a *locally bound* value works and is sugar for a bare `{ }` match: `loc.offset` desugars to `match loc { {offset} => offset }`, resolved against `loc`'s type at type-check time — so it works for any single-constructor type (not just `struct`-declared ones), not a fixed "method call" scheme. This only applies when the left of the first `.` is a local binding (a `def`/lambda parameter, a `let`); a bare `Module.name`-shaped path (no local binding by that name in scope) still resolves as an ordinary qualified reference, exactly as before: ```monad -// Struct definition: struct Location { offset : I64, line : I64, column : I64 } -// BROKEN — dot syntax: -let off : I64 := loc.offset in // interpreted as method call! +def get_offset (loc : Location) : I64 := loc.offset // works -// CORRECT — pattern matching: -match loc { - mk off line col => ... -} +// Equivalent, if you'd rather write it out: +def get_offset (loc : Location) : I64 := + match loc { + { offset, .. } => offset + } ``` +Dot access on an arbitrary non-identifier expression (e.g. `(mk_point 1 2).x`) isn't supported yet — only a chain of bare identifiers starting from a local binding. ### Debug `println!` in Type Checker Masks Real Errors diff --git a/core/src/lower_core.rs b/core/src/lower_core.rs index a4fa1e8..eabe790 100644 --- a/core/src/lower_core.rs +++ b/core/src/lower_core.rs @@ -27,7 +27,9 @@ use crate::Map; use crate::core_term::{ Atom, AtomTable, CoreConstructor, CoreLit, CoreMatchCase, CoreNative, CoreTerm, DebugName, }; -use crate::term::{Identifier, Literal, ModulePath, NameRef, Operator, Par, Term}; +use crate::term::{ + FieldPattern, Identifier, Literal, ModulePath, NameRef, Operator, Par, Term, id, +}; #[derive(Debug, Clone, PartialEq)] pub enum LowerError { @@ -497,7 +499,30 @@ fn lower_var(ctx: &mut LowerContext, name: &NameRef) -> Result Ok(CoreTerm::Bound(idx)), None => Ok(CoreTerm::Free(resolve_free_name(ctx, id))), }, - NameRef::P(path) => Ok(CoreTerm::Free(ctx.global_atom(path.clone()))), + // A bare-identifier dotted path (`a.fi`, `a.fi.fi2`, ...) is + // ambiguous at parse time between a module-qualified global and local + // struct-field access -- `path_expression` (parser.rs) always builds + // it as a path, since the parser has no scope information to tell + // `a` apart from a module name. Resolve that ambiguity here, where + // `ctx`'s binder stack is available: if the path's first segment is a + // local binding, treat the rest of the path as a chain of field + // accesses (`plans/implementations/struct-field-destructuring.md`'s + // `{ fi }` pattern, one nested `match` per remaining segment) instead + // of a global reference. This reuses `core_check.rs`'s existing + // `resolve_field_pattern_case`/`desugar_struct_literals` pipeline + // unchanged -- it already resolves a bare `{ fi }` pattern against + // any single-constructor type. Otherwise (first segment isn't + // locally bound), keep today's behavior: a global reference. + NameRef::P(path) => { + let segments = path.clone().to_vec(); + match segments.first().and_then(|first| ctx.find_bound(first)) { + Some(idx) => Ok(lower_field_access_chain( + CoreTerm::Bound(idx), + &segments[1..], + )), + None => Ok(CoreTerm::Free(ctx.global_atom(path.clone()))), + } + } NameRef::Index(i) => Err(LowerError::UnexpectedIndex(*i)), NameRef::Op(op) => match ctx.config.infix.get(op).cloned() { Some(path) => Ok(CoreTerm::Free(ctx.global_atom(path))), @@ -510,6 +535,37 @@ fn lower_var(ctx: &mut LowerContext, name: &NameRef) -> Result { {fi} => fi }`, and +/// `fields = [fi, fi2]` yields +/// `match { {fi} => match fi { {fi2} => fi2 } }`, and so on. +/// Mirrors the shape `parser.rs`'s `lambda` already builds for destructured +/// params via `term::case_with_field_pattern` (`rest: true`, so only the +/// field being accessed needs to be present -- not an exhaustive field +/// list), so downstream (`core_check.rs`'s `resolve_field_pattern_case`/ +/// `desugar_struct_literals`, `core_eval.rs`) needs no changes to handle +/// it. +fn lower_field_access_chain(scrutinee: CoreTerm, fields: &[Identifier]) -> CoreTerm { + let (field, rest) = match fields.split_first() { + Some(pair) => pair, + None => return scrutinee, + }; + let value = lower_field_access_chain(CoreTerm::Bound(0), rest); + CoreTerm::Lit(CoreLit::Match { + scrutinee: Box::new(scrutinee), + cases: vec![CoreMatchCase { + name: id(""), + dbgs: vec![DebugName::Named(field.clone())], + field_pattern: Some(FieldPattern { + fields: vec![(field.clone(), field.clone())], + rest: true, + }), + value: Box::new(value), + }], + }) +} + #[cfg(test)] mod test { use super::*; @@ -932,4 +988,75 @@ mod test { other => panic!("expected Lam, got {other:?}"), } } + + // ------------------------------------------------------------------- + // Dot field access (`a.fi`, `a.fi.fi2`, ...) — see `lower_var`'s + // `NameRef::P` arm and `lower_field_access_chain`. + // ------------------------------------------------------------------- + + #[test] + fn test_lower_dotted_path_on_bound_var_becomes_field_access_match() { + use crate::term::pvar; + // fn a => a.fi — `a` is a bound Lam parameter, so `a.fi` must become + // a bare-form field-pattern match on it, not a global reference. + let term = lam(param(id("a"), sort1()), pvar(vec!["a", "fi"])); + match lower(&term) { + CoreTerm::Lam { body, .. } => match *body { + CoreTerm::Lit(CoreLit::Match { scrutinee, cases }) => { + assert_eq!(*scrutinee, CoreTerm::Bound(0)); + assert_eq!(cases.len(), 1); + let case = &cases[0]; + assert_eq!(case.name, id("")); + let fp = case.field_pattern.as_ref().expect("field_pattern"); + assert_eq!(fp.fields, vec![(id("fi"), id("fi"))]); + assert!(fp.rest); + assert_eq!(*case.value, CoreTerm::Bound(0)); + } + other => panic!("expected Lit::Match, got {other:?}"), + }, + other => panic!("expected Lam, got {other:?}"), + } + } + + #[test] + fn test_lower_dotted_path_chain_nests_one_match_per_segment() { + use crate::term::pvar; + // fn a => a.fi.fi2 — one nested match per remaining path segment. + let term = lam(param(id("a"), sort1()), pvar(vec!["a", "fi", "fi2"])); + match lower(&term) { + CoreTerm::Lam { body, .. } => match *body { + CoreTerm::Lit(CoreLit::Match { scrutinee, cases }) => { + assert_eq!(*scrutinee, CoreTerm::Bound(0)); + let outer = &cases[0]; + let fp = outer.field_pattern.as_ref().expect("field_pattern"); + assert_eq!(fp.fields, vec![(id("fi"), id("fi"))]); + match &*outer.value { + CoreTerm::Lit(CoreLit::Match { scrutinee, cases }) => { + assert_eq!(**scrutinee, CoreTerm::Bound(0)); + let inner = &cases[0]; + let fp = inner.field_pattern.as_ref().expect("field_pattern"); + assert_eq!(fp.fields, vec![(id("fi2"), id("fi2"))]); + assert_eq!(*inner.value, CoreTerm::Bound(0)); + } + other => panic!("expected nested Lit::Match, got {other:?}"), + } + } + other => panic!("expected Lit::Match, got {other:?}"), + }, + other => panic!("expected Lam, got {other:?}"), + } + } + + #[test] + fn test_lower_dotted_path_with_unbound_first_segment_stays_a_global_reference() { + use crate::term::pvar; + // `Mod.name` at top level — `Mod` is never locally bound, so this must + // still resolve as an ordinary qualified global reference, exactly as + // before this change. + let term = pvar(vec!["Mod", "name"]); + match lower(&term) { + CoreTerm::Free(_) => {} + other => panic!("expected Free, got {other:?}"), + } + } } diff --git a/core/tests/field_access_integration_test.rs b/core/tests/field_access_integration_test.rs new file mode 100644 index 0000000..a2aca80 --- /dev/null +++ b/core/tests/field_access_integration_test.rs @@ -0,0 +1,168 @@ +//! End-to-end runtime tests for dot field access (`a.fi`, `a.fi.fi2`, ...) +//! against single-constructor types -- sugar for `match a { {fi} => fi }` +//! (chained: one nested `match` per segment). See +//! `lower_core.rs::lower_field_access_chain` for the desugaring and +//! `field_pattern_integration_test.rs` (Phases 1-4 of `plans/ +//! implementations/struct-field-destructuring.md`) for the bare `{ fi }` +//! match-pattern machinery this reuses unchanged. +//! +//! As with that file, tests write struct fields in an order that would +//! produce a visibly wrong (but still well-typed) answer if +//! `desugar_struct_literals`/`permute_binders` weren't retargeting the +//! synthesized pattern's binder onto the constructor's real declared +//! field order. + +use monad_core::eval_core_program; +use monad_core::term::ModulePath; + +fn run(source: &str) -> monad_core::core_value::Value { + let path = ModulePath::top("'field_access_e2e_test"); + eval_core_program(&path, source).unwrap_or_else(|e| panic!("eval_core_program failed: {e}")) +} + +fn run_err(source: &str) -> String { + let path = ModulePath::top("'field_access_e2e_test"); + match eval_core_program(&path, source) { + Ok(v) => panic!("expected an error, got {v:?}"), + Err(e) => e, + } +} + +fn as_i64(v: &monad_core::core_value::Value) -> i64 { + match v { + monad_core::core_value::Value::Lit(monad_core::core_ir::IrLit::Num(n, _)) => *n, + other => panic!("expected an int literal, got {other:?}"), + } +} + +/// `p.y`/`p.x` on a struct whose fields are declared in the OPPOSITE order +/// from how the caller happens to think about them -- if the synthesized +/// `{ fi }` pattern weren't retargeted onto Point's DECLARED order, `x`/`y` +/// would silently read each other's values. +const DOT_ACCESS_ON_PARAM: &str = r#" +struct Point { x : I64, y : I64 } + +def sub (p : Point) : I64 := p.x - p.y + +def main : I64 := sub { x := 10, y := 3 } +"#; + +#[test] +fn dot_access_on_param_reads_the_declared_field() { + assert_eq!(as_i64(&run(DOT_ACCESS_ON_PARAM)), 7); +} + +/// Same reordering-correctness proof through a chain of TWO dots +/// (`l.to.x`) -- `to`'s own field access must be resolved against `Point` +/// (not `Line`), and `Point`'s fields are declared in the order that would +/// give a visibly wrong answer if either level's retargeting were missing. +const CHAINED_DOT_ACCESS: &str = r#" +struct Point { x : I64, y : I64 } +struct Line { from : Point, to : Point } + +def dx (l : Line) : I64 := l.to.x - l.from.x + +def main : I64 := dx { from := { x := 1, y := 2 }, to := { x := 10, y := 20 } } +"#; + +#[test] +fn chained_dot_access_reads_nested_field() { + assert_eq!(as_i64(&run(CHAINED_DOT_ACCESS)), 9); +} + +/// Dot access on a plain single-constructor `type` (not `struct` sugar) -- +/// proves this isn't gated on `struct`-declared types specifically, only +/// on the underlying inductive having exactly one constructor. +const DOT_ACCESS_ON_SINGLE_CONSTRUCTOR_TYPE: &str = r#" +type Box { + mk { value : I64, tag : I64 } +} + +def unwrap (b : Box) : I64 := b.value + +def main : I64 := unwrap (Box.mk 5 1) +"#; + +#[test] +fn dot_access_on_single_constructor_type_works() { + assert_eq!(as_i64(&run(DOT_ACCESS_ON_SINGLE_CONSTRUCTOR_TYPE)), 5); +} + +/// A local binding shadowing an existing type name still resolves as a +/// local: the parameter `Point` (deliberately named the same as the +/// `Point` struct) must be treated as a bound variable, not a module path +/// into `Point`'s own namespace -- and chains through it correctly. +const SHADOWING_LOCAL_BINDING: &str = r#" +struct Point { x : I64, y : I64 } +struct Wrapper { inner : Point } + +def unwrap (Point : Wrapper) : I64 := Point.inner.x + +def main : I64 := unwrap { inner := { x := 7, y := 2 } } +"#; + +#[test] +fn dot_access_prefers_a_local_binding_over_a_same_named_module() { + assert_eq!(as_i64(&run(SHADOWING_LOCAL_BINDING)), 7); +} + +/// A dotted path whose first segment is NOT a local binding still +/// resolves as an ordinary qualified global reference (unaffected by the +/// new local-binding check). +const MODULE_PATH_STILL_RESOLVES_AS_GLOBAL: &str = r#" +type Shape { + circle (radius : I64), + rectangle { width : I64, height : I64 } +} + +def main : I64 := + match Shape.rectangle 3 4 { + rectangle { width, height } => width * height, + circle { radius } => radius + } +"#; + +#[test] +fn dotted_module_path_still_resolves_as_a_global() { + assert_eq!(as_i64(&run(MODULE_PATH_STILL_RESOLVES_AS_GLOBAL)), 12); +} + +/// Dot access against a multi-constructor type has no single constructor +/// to resolve `{ fi }` against -- same `FieldPatternAmbiguousConstructor` +/// error a bare `{ .. }` match pattern would raise. +const DOT_ACCESS_ON_MULTI_CONSTRUCTOR_TYPE: &str = r#" +type Shape { + circle (radius : I64), + rectangle { width : I64, height : I64 } +} + +def width_of (s : Shape) : I64 := s.width + +def main : I64 := width_of (Shape.rectangle 3 4) +"#; + +#[test] +fn dot_access_on_multi_constructor_type_is_ambiguous() { + let err = run_err(DOT_ACCESS_ON_MULTI_CONSTRUCTOR_TYPE); + assert!( + err.contains("requires exactly one constructor"), + "unexpected error: {err}" + ); +} + +/// Dot access naming a field the constructor doesn't have -- same +/// `FieldPatternUnknownField` error a bare `{ nope }` match pattern would +/// raise. +const DOT_ACCESS_ON_UNKNOWN_FIELD: &str = r#" +struct Point { x : I64, y : I64 } + +def z_of (p : Point) : I64 := p.z + +def main : I64 := z_of { x := 1, y := 2 } +"#; + +#[test] +fn dot_access_on_unknown_field_is_reported() { + let err = run_err(DOT_ACCESS_ON_UNKNOWN_FIELD); + assert!(err.contains("no field named"), "unexpected error: {err}"); +} diff --git a/lang/module.mo b/lang/module.mo index 53426c7..e9d4f21 100644 --- a/lang/module.mo +++ b/lang/module.mo @@ -1815,6 +1815,78 @@ def test_check_module_with_scope_accumulates_failures : IO Bool := do { } } +/// End-to-end proof that `lang/parser.mo`'s `variable_try_path`/ +/// `field_access_chain` (the self-hosted mirror of the Rust reference's +/// `lower_core.rs::lower_var`'s `NameRef::P` hook) resolves `p.x`/`p.y` +/// dot field access on a locally-bound struct parameter through the full +/// parse -> resolve -> type-check pipeline, same shape as +/// `test_check_module_with_scope_all_pass` above. +#[test] +def test_check_module_with_scope_dot_field_access_resolves : IO Bool := do { + let path : ModulePath := ModulePath.mp List.empty; + let src : String := "type Color { red, green }\nstruct Point { x : Color, y : Color }\ndef getx (p : Point) : Color := p.x"; + let result : ParseResult (List Decl) := parse_all_decls src; + match result { + ParseResult.success _ decl_list => do { + let sd : ScopeData := build_scope_from_decls path decl_list; + let scope : Scope := { module_id := path, scope := sd, parent := Option.none }; + let locals : LocalScope := { vars := List.empty, parent := Option.none }; + let diags : List String <- check_module_with_scope scope decl_list locals Option.none false; + return (match diags { + List.empty => true, + List.cons _ _ => false + }) + }, + ParseResult.fail _ => do { return false } + } +} + +/// Same as above, chained two levels deep (`l.to.x`) -- proves +/// `field_access_chain`'s recursive nesting resolves correctly through the +/// self-hosted pipeline, not just a single field. +#[test] +def test_check_module_with_scope_chained_dot_field_access_resolves : IO Bool := do { + let path : ModulePath := ModulePath.mp List.empty; + let src : String := "type Color { red, green }\nstruct Point { x : Color, y : Color }\nstruct Line { from : Point, to : Point }\ndef getx (l : Line) : Color := l.to.x"; + let result : ParseResult (List Decl) := parse_all_decls src; + match result { + ParseResult.success _ decl_list => do { + let sd : ScopeData := build_scope_from_decls path decl_list; + let scope : Scope := { module_id := path, scope := sd, parent := Option.none }; + let locals : LocalScope := { vars := List.empty, parent := Option.none }; + let diags : List String <- check_module_with_scope scope decl_list locals Option.none false; + return (match diags { + List.empty => true, + List.cons _ _ => false + }) + }, + ParseResult.fail _ => do { return false } + } +} + +/// A dotted path whose first segment is NOT a local binding still +/// resolves as an ordinary qualified reference (regression guard on +/// `variable_try_path_global`'s fallback branch). +#[test] +def test_check_module_with_scope_dotted_module_path_still_resolves : IO Bool := do { + let path : ModulePath := ModulePath.mp List.empty; + let src : String := "type Color { red, green }\ndef c : Color := Color.red"; + let result : ParseResult (List Decl) := parse_all_decls src; + match result { + ParseResult.success _ decl_list => do { + let sd : ScopeData := build_scope_from_decls path decl_list; + let scope : Scope := { module_id := path, scope := sd, parent := Option.none }; + let locals : LocalScope := { vars := List.empty, parent := Option.none }; + let diags : List String <- check_module_with_scope scope decl_list locals Option.none false; + return (match diags { + List.empty => true, + List.cons _ _ => false + }) + }, + ParseResult.fail _ => do { return false } + } +} + #[test] def test_check_file_reports_missing_file : Bool := let empty_base : PreludeInitBase := { scope_data := scope_data_empty, covered := List.empty } in diff --git a/lang/parser.mo b/lang/parser.mo index 69d4b5a..52bca9c 100644 --- a/lang/parser.mo +++ b/lang/parser.mo @@ -4232,25 +4232,91 @@ def variable (ctx: List Identifier) (input: String) : ParseResult Term := #[partial] def variable_try_path (r: ParseResult TermV0) (ctx: List Identifier) (input: String) : ParseResult Term := match r { - success rem out => - // Dotted path → sentinel index (resolved later by module resolver) - // Extract the last component of the qualified name for constructor detection - match out { - TermV0.var nref => - match name_ref_to_string nref { - Option.some qualified_name => - // Preserve the full qualified name for proper resolution - // is_constructor_var will extract the base name if needed - success rem (Term.var sentinel (DebugName.named (Identifier.id qualified_name))), - Option.none => - success rem (Term.var sentinel DebugName.unnamed), - }, - _ => - success rem (Term.var sentinel DebugName.unnamed), - }, + success rem out => variable_try_path_got out ctx rem, fail _ => variable_got (identifier input) ctx } +#[partial] +def variable_try_path_got (out : TermV0) (ctx : List Identifier) (rem : String) : ParseResult Term := + match out { + TermV0.var nref => variable_try_path_nref nref ctx rem, + _ => success rem (Term.var sentinel DebugName.unnamed), + } + +/// A dotted path (`NameRef.nmp`) is ambiguous at parse time between a +/// module-qualified global and local struct-field access -- `path_variable` +/// always builds it as a path, since the parser has no scope information to +/// tell a bound name apart from a module name. Resolve that ambiguity here, +/// where `ctx` is in scope: if the path's first segment is a local binding +/// (mirrors the Rust reference's own `lower_core.rs::lower_var`'s +/// `NameRef::P` arm), the REST of the path is a chain of field accesses +/// (`plans/implementations/struct-field-destructuring.md`'s `{ fi }` +/// pattern, one nested `match` per remaining segment, +/// `field_access_chain` below) instead of a global reference. Otherwise +/// (first segment isn't a local binding, or the path is somehow empty), +/// fall back to today's behavior: a sentinel-indexed, flattened-string +/// global reference, resolved later by the module resolver. +#[partial] +def variable_try_path_nref (nref : NameRef) (ctx : List Identifier) (rem : String) : ParseResult Term := + match nref { + NameRef.nmp mp => variable_try_path_mp mp ctx rem nref, + _ => variable_try_path_global nref rem, + } + +#[partial] +def variable_try_path_mp (mp : ModulePath) (ctx : List Identifier) (rem : String) (nref : NameRef) : ParseResult Term := + match mp { + ModulePath.mp ids => variable_try_path_ids ids ctx rem nref, + } + +#[partial] +def variable_try_path_ids (ids : List Identifier) (ctx : List Identifier) (rem : String) (nref : NameRef) : ParseResult Term := + match ids { + List.cons first fields => + match find_index first ctx 0 { + Option.some idx => + let scrutinee : Term := Term.var idx (DebugName.named first) in + success rem (field_access_chain scrutinee fields), + Option.none => variable_try_path_global nref rem, + }, + List.empty => variable_try_path_global nref rem, + } + +#[partial] +def variable_try_path_global (nref : NameRef) (rem : String) : ParseResult Term := + // Preserve the full qualified name for proper resolution -- + // is_constructor_var will extract the base name if needed. + match name_ref_to_string nref { + Option.some qualified_name => + success rem (Term.var sentinel (DebugName.named (Identifier.id qualified_name))), + Option.none => + success rem (Term.var sentinel DebugName.unnamed), + } + +/// Builds the nested bare-form field-pattern `Match` chain that desugars a +/// dotted-path field access into ordinary struct-field destructuring -- +/// mirrors the Rust reference's `lower_core.rs::lower_field_access_chain` +/// and reuses the exact same `MatchCase`/`FieldPattern` shape +/// `lam_parsed_params_loop` above already builds for destructured `def` +/// params, so `Phase 7`'s type-checker elaboration +/// (`type_check_field_pattern_case`/`term_permute`) handles it with no new +/// logic needed here. +#[partial] +def field_access_chain (scrutinee : Term) (fields : List Identifier) : Term := + match fields { + List.empty => scrutinee, + List.cons field rest => + let value : Term := field_access_chain (Term.var 0 (DebugName.named field)) rest in + let bare_name : Identifier := Identifier.id "" in + let entry : FieldPatternEntry := FieldPatternEntry.mk field field in + let fp : FieldPattern := FieldPattern.mk (List.cons entry List.empty) true in + let binders : List Identifier := field_pattern_binder_names fp in + let some_fp : Option FieldPattern := Option.some fp in + let case_ : MatchCase := MatchCase.mc bare_name binders value some_fp in + let cases : List MatchCase := List.cons case_ List.empty in + Term.lit (Literal.match_ scrutinee cases), + } + #[partial] def variable_got (r: ParseResult String) (ctx: List Identifier) : ParseResult Term := match r { -- 2.51.2