Skip to content

Commit b714cdd

Browse files
committed
types: a module's interface carries the records and opaque types it references
ModuleExports held only what a module declares, so anything its exports referred to fell off at the next boundary: an extern type could not be named across a module at all (#72, there was no slot for it), and a record reached only through another module's exports had invisible fields (#75, the field registry crossed one hop while the type crossed two). Both get the same treatment the scheme side got in #26: the interface now closes over what it references. extern type declarations export as name + arity and register in a consumer like any imported type, clash check included. Records and opaque types mentioned by exported schemes, constructors, or record fields are pulled from the imports' interfaces, tagged with their declaring module, and re-exported; the walk follows newly pulled records and terminates on cycles. A carried type registers under its bare identity name only, so it can be named, unified, and field-accessed, while construction and patterns still require importing the declaring module. A taken bare name skips the carried entry silently (local and direct declarations win; the same record along two paths is admitted once), and a shadowed record's fields feed the unknown-field diagnostic, which now names the record and the module that declares it. Record updates in a consumer that never imports the declaring module reconstruct via that module's class, with its import hoisted. Closes #75 Closes #72
1 parent 27edeea commit b714cdd

6 files changed

Lines changed: 853 additions & 99 deletions

File tree

DESIGN.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -893,7 +893,8 @@ checker already uses for built-in/in-file modules, so the existing `Field`-node
893893
cross-module reference with no new lookup logic. A module's interface is its top-level **`let`
894894
values** plus its **sum types** (since the cross-module-ADT follow-on; `ModuleExports` carries each public
895895
sum type's name, arity, and constructors) **and its records** (since the cross-module-record follow-on;
896-
`ModuleExports` also carries each public record's name + fields). A consumer can construct (`Geometry.Circle
896+
`ModuleExports` also carries each public record's name + fields) **and its opaque handle types** (name +
897+
arity, so `extern type Rng` can be named across the boundary). A consumer can construct (`Geometry.Circle
897898
2.0`) and pattern-match (`| Geometry.Circle r ->`, a qualified constructor pattern) the imported type's
898899
values, with **exhaustiveness checked across the boundary** (a missing arm reports the qualified witness
899900
`Geometry.Rect _ _`). **Records cross too** (`DESIGN.md` §8.3): construct `Geometry.Point { x = 1, y = 2 }`,
@@ -904,10 +905,21 @@ an ADT variant payload or an `extern` signature. Both spellings are accepted, ba
904905
`Shapes.Placed`, and they denote the same type: an imported type registers under its bare identity name
905906
(unique across everything visible, as the import clash check enforces) plus a qualified key, and a written
906907
qualifier is validated and then folded back to that identity, so the two unify freely. Prefer the qualified
907-
spelling where the reader benefits from knowing which module a type came from. The one thing this does not
908-
extend to is *transitive* naming: a third module importing `Holder` without importing `Shapes` can hold and
909-
pass its `item` around, but cannot access that value's fields or construct one, since only `Shapes` brings
910-
`Placed`'s field registry into scope. **Externs and
908+
spelling where the reader benefits from knowing which module a type came from. **Opaque handle types
909+
(`extern type Rng`, §6) cross like any other type name:** the interface carries the name + arity, so a
910+
consumer can write `Gen.Rng` (or bare `Rng`) in a record field, an ADT payload, or an `extern` signature;
911+
values of the type always crossed through the exported schemes. **Interfaces close transitively over what
912+
they reference:** a record or opaque type that an exported scheme, constructor, or record field mentions is
913+
carried in the exporter's interface, tagged with the module that declares it, and the pull repeats through
914+
each hop (a worklist walk with a seen set, so mutually referencing records terminate). A third module
915+
importing `Holder` without importing `Shapes` can therefore hold its `item`, read that value's fields, and
916+
name its type bare (`Placed`). A carried type crosses as an *identity*, not as a member: constructing it,
917+
pattern-matching it, or writing the qualified spelling still requires importing the declaring module
918+
directly. A carried name already taken here (by a local type or a direct import) is skipped silently rather
919+
than reported, since the consumer never wrote that name: local and direct declarations win the bare name,
920+
the same record arriving along two import paths is recognized by its declaring module and admitted once,
921+
and a genuinely different type shadows the carried record, whose fields then feed the "unknown record
922+
field" diagnostic (the message names the hidden record and the module that declares it). **Externs and
911923
measures cross too:** an imported `extern` (`Mathx.cbrt`) is exported like a value (its scheme joins the
912924
interface) and — in the project lowering path — also **bound at top level in its own module** (`cbrt =
913925
math.cbrt`, `import math` hoisted) so a dependent module references it as `mathx.cbrt`; single-file lowering

INTERNALS.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,8 +430,9 @@ returned topological order (dependencies first, entry last). `project::build_fro
430430
in the entry's directory). Cross-module *checking* and *emit* consume this `Project`.
431431

432432
**Cross-module checking** (`types::check_module` + `project::check`). *Implementation:* the single-file `run` was generalized to take the imports map and return the module's
433-
exported value schemes (which now include `extern` names), its exported sum types, **its exported records**,
434-
**and its measures + measure-aliases**; `check_module(module,
433+
`ModuleExports` interface: its exported value schemes (which include `extern` names), its exported sum
434+
types, **its exported records**, **its opaque handle types** (`collect_exported_opaques` /
435+
`ExportedOpaque`: name + arity, issue #72), **and its measures + measure-aliases**; `check_module(module,
435436
imports)` seeds imported values under qualified keys and imported sum types into the decls under **qualified
436437
constructor keys**. The type merge happens in **two halves**, because a local `type` declaration may name an
437438
imported type (`type Holder = { item: Shapes.Placed }`, DESIGN §6.1): `merge_imported_type_names` runs
@@ -456,6 +457,22 @@ and leaked local variables into the "free in the env" set — blocking generaliz
456457
bindings, which then exported un-quantified and cascaded onward (#26). Every imported scheme is therefore
457458
alpha-renamed into the consumer's id space as it is seeded (`Infer::refresh_scheme`, values and constructors
458459
alike, in sorted order so the ids do not depend on `HashMap` iteration).
460+
*Transitive interface closure* (issues #72/#75): before `run` hands back its `ModuleExports`,
461+
`close_over_references` walks every exported scheme, constructor scheme, and record field for `Ty::Con`
462+
names (`collect_con_names`) and pulls any name satisfied by an import's record/opaque tables into this
463+
module's own exports, tagged `origin: Some(declaring module)` (own declarations carry `origin: None`);
464+
newly pulled records are walked in turn, and a seen set terminates the walk on mutually referencing
465+
records. Each import's interface is itself closed, so one pull per hop reaches any depth. On the consumer
466+
side `merge_imported_type_names` registers carried entries **last** and under the **bare identity name
467+
only** (no qualified key, no `record_aliases` entry, so constructing or pattern-matching a carried record
468+
still requires the direct import); a taken bare name skips the carried entry silently (`record_home`
469+
recognizes the same record arriving along two paths), and a genuinely shadowed record's fields land in
470+
`Decls::field_hints`, which `record_of_field` / `record_of_field_on` render as "the record `Config` in
471+
module `Inner` declares this field" instead of a bare unknown. The lowering side:
472+
`ModuleExports::carried_records` feeds `project::compile`'s per-module `ImportContext::record_class_modules`
473+
plus field data keyed by the *declaring* module's tag, so a record **update** in a consumer that never
474+
imports the declaring module still reconstructs via the right class (`inner.Config(...)`,
475+
`Lowerer::record_class_name` accepts those modules and hoists their imports).
459476
`project::check` threads the `ModuleExports` map through the topological order, seeding each module from
460477
only the modules it actually imports (so an unimported module's members/constructors stay invisible), and
461478
returns errors grouped by module. *Lowering* routes a qualified constructor — in expression or pattern

src/lowering/mod.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ pub struct ImportContext {
122122
/// (`x` → `Geometry.Point`), so a cross-module update `{ p with x = 3 }`
123123
/// (which carries no tag) routes to the imported class.
124124
pub record_field_owners: HashMap<String, String>,
125+
/// Modules that declare a **transitively** visible record (`DESIGN.md` §6.1)
126+
/// without being imported by this module directly. A record update on such a
127+
/// record reconstructs via the declaring module's class (`inner.Config(...)`),
128+
/// so its module must be recognized as a class source (and its Python import
129+
/// hoisted) even though no `import` names it here.
130+
pub record_class_modules: HashSet<String>,
125131
}
126132

127133
/// A module lowered as part of a multi-file project.
@@ -149,6 +155,7 @@ pub fn lower_in_project(
149155
let mut lowerer = Lowerer::new(module);
150156
lowerer.float_literals = float_literals.clone();
151157
lowerer.imported_modules = ctx.modules.clone();
158+
lowerer.record_class_modules = ctx.record_class_modules.clone();
152159
lowerer.imported_nullary_ctors = ctx.nullary_ctors.clone();
153160
lowerer
154161
.newtype_ctors
@@ -318,6 +325,11 @@ struct Lowerer {
318325
/// A `Geometry.member` reference routes to Python `geometry.member` (vs the
319326
/// `Geometry_member` mangling used for in-file `module` declarations).
320327
imported_modules: HashSet<String>,
328+
/// Modules that declare a transitively visible record without being imported
329+
/// here directly ([`ImportContext::record_class_modules`]). Consulted only by
330+
/// [`Lowerer::record_class_name`], so nothing but a record reconstruction can
331+
/// route through such a module.
332+
record_class_modules: HashSet<String>,
321333
/// Qualified names of imported nullary constructors (`Palette.Red`), referenced
322334
/// as values, which must lower to a call (`palette.Red()`) not the bare class.
323335
imported_nullary_ctors: HashSet<String>,
@@ -610,6 +622,7 @@ impl Lowerer {
610622
float_literals: HashSet::new(),
611623
cur_module: None,
612624
imported_modules: HashSet::new(),
625+
record_class_modules: HashSet::new(),
613626
imported_nullary_ctors: HashSet::new(),
614627
use_runtime: false,
615628
project_mode: false,
@@ -2850,10 +2863,13 @@ impl Lowerer {
28502863
/// file module (`Geometry.Point`) becomes dotted attribute access on that module
28512864
/// (`geometry.Point`, with `import geometry` hoisted) so it references the *same*
28522865
/// class the module defines (the consumer never redefines it); a bare tag is the
2853-
/// record class name (mangled for the reserved `Exception`).
2866+
/// record class name (mangled for the reserved `Exception`). A tag rooted in a
2867+
/// module visible only through a transitively carried record
2868+
/// (`record_class_modules`) routes the same way — an update on such a record
2869+
/// reconstructs via the declaring module's class.
28542870
fn record_class_name(&mut self, tag: &str) -> String {
28552871
if let Some((base, rec)) = tag.split_once('.')
2856-
&& self.imported_modules.contains(base)
2872+
&& (self.imported_modules.contains(base) || self.record_class_modules.contains(base))
28572873
{
28582874
let module = self.py_module_ref(base);
28592875
format!("{module}.{}", py_record_class(rec))

src/project/mod.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,11 @@ pub fn compile_targeting(
242242
// Per-module spans of integer literals that inference resolved to `float`, so
243243
// lowering emits them as `7.0`. Computed in topological order (like `check`) so
244244
// each module's imports are seeded before it — a literal's float-ness can depend
245-
// on a cross-module call. Exports are threaded forward in the same pass.
245+
// on a cross-module call. Exports are threaded forward in the same pass and kept:
246+
// an import's interface also carries the records it references transitively
247+
// (`DESIGN.md` §6.1), which the lowering context below needs.
248+
let mut exports: HashMap<String, crate::types::ModuleExports> = HashMap::new();
246249
let float_spans: HashMap<String, std::collections::HashSet<crate::lexer::Span>> = {
247-
let mut exports: HashMap<String, crate::types::ModuleExports> = HashMap::new();
248250
let mut spans = HashMap::new();
249251
for module in &project.modules {
250252
let imports: HashMap<String, crate::types::ModuleExports> = module
@@ -294,6 +296,26 @@ pub fn compile_targeting(
294296
}
295297
}
296298
}
299+
// Records an import's interface carries transitively (declared in a
300+
// module this one does not import directly): keyed by the *declaring*
301+
// module's tag, so an update on such a record reconstructs via the right
302+
// class (`inner.Config(...)`, with `import inner` hoisted). Registered
303+
// after the direct entries, which keep precedence for shared field names.
304+
for import in &module.imports {
305+
let Some(exp) = exports.get(import) else {
306+
continue;
307+
};
308+
for (origin, rec, fields) in exp.carried_records() {
309+
let tag = format!("{origin}.{rec}");
310+
for field in &fields {
311+
ctx.record_field_owners
312+
.entry(field.clone())
313+
.or_insert_with(|| tag.clone());
314+
}
315+
ctx.record_fields.entry(tag).or_insert(fields);
316+
ctx.record_class_modules.insert(origin);
317+
}
318+
}
297319
let floats = float_spans.get(&module.name).unwrap_or(&no_floats);
298320
let lowered = lowering::lower_in_project(&module.ast, &ctx, floats)?;
299321
needs_runtime |= lowered.uses_runtime;

0 commit comments

Comments
 (0)