iter 22b.1.2: workspace registry skeleton + coherence checks
Adds the workspace-global typeclass instance registry per Decision 11
"Resolution and monomorphisation". Built at the end of load_workspace
after the DFS over imports completes; keyed by (class-name, type-hash)
where type-hash uses the new canonical::type_hash (16-hex prefix,
parallel to def_hash and module_hash).
Three coherence checks fire from build_registry, each surfacing as a
distinct WorkspaceLoadError variant:
- OrphanInstance: `instance C T` not in C's or T's defining module.
- DuplicateInstance: two entries share the same (class, type-hash) key.
- MissingMethod: instance omits a required (non-default) method.
The CLI's workspace_error_to_diagnostic is extended with the three new
codes (orphan-instance / duplicate-instance / missing-method).
All existing Workspace { ... } construction sites get the new
`registry` field, defaulting to Registry::default() at synthetic /
test-only paths and threading through ws.registry.clone() at codepath
sites that already hold a real workspace.
Includes hash-stability regression tests (iter22b1_schema_extension_*
and iter22b1_classdef_empty_optionals_hash_stable) and the empty-
registry positive test against examples/sum.ail.json. Test count:
288 → 291 (3 new tests, all pass).
This commit is contained in:
@@ -968,6 +968,67 @@ fn workspace_error_to_diagnostic(
|
||||
"module": name,
|
||||
})),
|
||||
),
|
||||
// Iter 22b.1: typeclass-coherence diagnostics emitted from
|
||||
// `workspace::build_registry`. The codes follow the JOURNAL
|
||||
// queue's wording and Decision 11 §"Diagnostic categories".
|
||||
W::OrphanInstance {
|
||||
class,
|
||||
type_repr,
|
||||
defining_module,
|
||||
class_module,
|
||||
type_module,
|
||||
} => Some(
|
||||
ailang_check::Diagnostic::error(
|
||||
"orphan-instance",
|
||||
format!(
|
||||
"orphan instance: `instance {class} {type_repr}` declared in `{defining_module}`, \
|
||||
but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there"
|
||||
),
|
||||
)
|
||||
.with_ctx(serde_json::json!({
|
||||
"class": class,
|
||||
"type": type_repr,
|
||||
"defining_module": defining_module,
|
||||
"class_module": class_module,
|
||||
"type_module": type_module,
|
||||
})),
|
||||
),
|
||||
W::DuplicateInstance {
|
||||
class,
|
||||
type_repr,
|
||||
first_module,
|
||||
second_module,
|
||||
} => Some(
|
||||
ailang_check::Diagnostic::error(
|
||||
"duplicate-instance",
|
||||
format!(
|
||||
"duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`"
|
||||
),
|
||||
)
|
||||
.with_ctx(serde_json::json!({
|
||||
"class": class,
|
||||
"type": type_repr,
|
||||
"first_module": first_module,
|
||||
"second_module": second_module,
|
||||
})),
|
||||
),
|
||||
W::MissingMethod {
|
||||
class,
|
||||
type_repr,
|
||||
method,
|
||||
} => Some(
|
||||
ailang_check::Diagnostic::error(
|
||||
"missing-method",
|
||||
format!(
|
||||
"instance `{class} {type_repr}` is missing a body for method `{method}` (no default)"
|
||||
),
|
||||
)
|
||||
.with_ctx(serde_json::json!({
|
||||
"class": class,
|
||||
"type": type_repr,
|
||||
"method": method,
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1854,6 +1915,11 @@ fn build_to(
|
||||
entry: ws.entry.clone(),
|
||||
modules: lifted_modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
// Iter 22b.1: pass the registry through the lift pass. Lift
|
||||
// only rewrites `Term::LetRec` into top-level fns; it does
|
||||
// not touch class/instance defs, so the registry built at
|
||||
// load-time remains valid.
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(&ws, alloc)?;
|
||||
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
|
||||
|
||||
@@ -1388,6 +1388,7 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
|
||||
entry: ws.entry.clone(),
|
||||
modules: lifted_modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||
&lifted_ws,
|
||||
@@ -1586,6 +1587,7 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
|
||||
entry: ws.entry.clone(),
|
||||
modules: lifted_modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||
&lifted_ws,
|
||||
@@ -1661,6 +1663,7 @@ fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
|
||||
entry: ws.entry.clone(),
|
||||
modules: lifted_modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||
&lifted_ws,
|
||||
@@ -1750,6 +1753,7 @@ fn alloc_rc_own_param_dec_at_fn_return() {
|
||||
entry: ws.entry.clone(),
|
||||
modules: lifted_modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||
&lifted_ws,
|
||||
@@ -1875,6 +1879,7 @@ fn iter18e_drop_iterative_emits_worklist_body_no_self_recursion() {
|
||||
entry: ws.entry.clone(),
|
||||
modules: lifted_modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||
&lifted_ws,
|
||||
@@ -1958,6 +1963,7 @@ fn iter18e_no_annotation_keeps_recursive_drop_body() {
|
||||
entry: ws.entry.clone(),
|
||||
modules,
|
||||
root_dir: ws.root_dir.clone(),
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||
&lifted_ws,
|
||||
|
||||
@@ -598,6 +598,13 @@ pub fn check_module(m: &Module) -> Vec<Diagnostic> {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
// Iter 22b.1: single-module check_module entry point does not
|
||||
// build a registry. The registry is workspace-load-time
|
||||
// metadata; check_module is invoked from in-memory paths
|
||||
// (tests, single-file CLI) where no workspace DFS happened.
|
||||
// Once 22b.2 typecheck arms read the registry, those paths
|
||||
// will need to populate it from the in-memory module too.
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
check_workspace(&ws)
|
||||
}
|
||||
@@ -628,6 +635,11 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
|
||||
.map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m)))
|
||||
.collect(),
|
||||
root_dir: ws.root_dir.clone(),
|
||||
// Iter 22b.1: pass the registry through unchanged. The desugar
|
||||
// pass does not touch class/instance defs (see desugar.rs:
|
||||
// 22b.1 passthrough), so the registry built at load time
|
||||
// remains valid against the desugared modules.
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ws = &ws_owned;
|
||||
// Pass 1: build per-module top-level symbol table — without checking
|
||||
@@ -730,6 +742,11 @@ pub fn check(m: &Module) -> Result<CheckedModule> {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
// Iter 22b.1: the legacy single-module `check` entry point
|
||||
// builds an empty registry. See `check_module` for the same
|
||||
// pattern; once 22b.2 typecheck arms read the registry, both
|
||||
// paths must populate it from the in-memory module.
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let module_globals = build_module_globals(&ws)?;
|
||||
// `check` keeps single-error semantics for callers (snapshot tests,
|
||||
@@ -2881,6 +2898,7 @@ mod tests {
|
||||
entry: consumer.name,
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3056,6 +3074,7 @@ mod tests {
|
||||
entry: "use_both".into(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
assert!(
|
||||
@@ -3162,6 +3181,7 @@ mod tests {
|
||||
entry: "uses_lst".into(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||||
|
||||
@@ -112,6 +112,7 @@ fn invalid_def_name_with_dot_is_reported() {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
assert_eq!(diags.len(), 1, "got: {:?}", diags);
|
||||
@@ -184,6 +185,7 @@ fn body_errors_accumulate_across_defs() {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
|
||||
@@ -288,6 +290,7 @@ fn use_after_consume_on_own_param_is_reported() {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
|
||||
@@ -389,6 +392,7 @@ fn consume_while_borrowed_in_sibling_arg_is_reported() {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
|
||||
@@ -527,6 +531,7 @@ fn reuse_as_happy_path_in_map_inc_is_linearity_clean() {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
assert!(
|
||||
@@ -638,6 +643,7 @@ fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let diags = check_workspace(&ws);
|
||||
let shape_diags: Vec<&ailang_check::Diagnostic> = diags
|
||||
|
||||
@@ -195,6 +195,12 @@ pub fn emit_ir(m: &Module) -> Result<String> {
|
||||
entry: m.name.clone(),
|
||||
modules,
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
// Iter 22b.1: emit_ir is the single-module shortcut. The
|
||||
// empty registry is fine for 22b.1 because codegen does not
|
||||
// yet read it; once 22b.3 monomorphisation runs, the queue
|
||||
// is built from class-method-call sites in already-typechecked
|
||||
// module bodies, not from the registry.
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
lower_workspace(&ws)
|
||||
}
|
||||
@@ -251,6 +257,11 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
|
||||
.map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m)))
|
||||
.collect(),
|
||||
root_dir: ws.root_dir.clone(),
|
||||
// Iter 22b.1: pass the registry through unchanged. The desugar
|
||||
// pass does not touch class/instance defs (see desugar.rs:
|
||||
// 22b.1 passthrough), so the registry built at load time
|
||||
// remains valid against the desugared modules.
|
||||
registry: ws.registry.clone(),
|
||||
};
|
||||
let ws = &ws_owned;
|
||||
let mut header = String::new();
|
||||
@@ -2837,6 +2848,7 @@ mod tests {
|
||||
x
|
||||
},
|
||||
root_dir: std::path::PathBuf::from("."),
|
||||
registry: ailang_core::workspace::Registry::default(),
|
||||
};
|
||||
let ir_rc = lower_workspace_with_alloc(&ws, AllocStrategy::Rc).unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -46,6 +46,23 @@ pub fn to_bytes<T: serde::Serialize>(value: &T) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Iter 22b.1: 16-hex-char hash of a [`crate::ast::Type`] in isolation.
|
||||
///
|
||||
/// Used by [`crate::workspace::Registry`] to key `InstanceDef`s by
|
||||
/// their target type. Parallel in shape to [`crate::def_hash`] and
|
||||
/// [`crate::workspace::module_hash`]: BLAKE3 over canonical-JSON
|
||||
/// bytes, truncated to 16 hex chars.
|
||||
///
|
||||
/// The key property is determinism — two `Type` values that are
|
||||
/// canonically equal MUST hash identically — so the workspace
|
||||
/// registry's "is `instance C T` already declared?" check is
|
||||
/// representation-independent.
|
||||
pub fn type_hash(t: &crate::ast::Type) -> String {
|
||||
let bytes = to_bytes(t);
|
||||
let h = blake3::hash(&bytes);
|
||||
h.to_hex().as_str()[..16].to_string()
|
||||
}
|
||||
|
||||
fn write_value(v: &serde_json::Value, out: &mut Vec<u8>) -> std::io::Result<()> {
|
||||
use serde_json::Value;
|
||||
match v {
|
||||
|
||||
@@ -181,4 +181,60 @@ mod tests {
|
||||
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
|
||||
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
|
||||
}
|
||||
|
||||
/// Iter 22b.1 regression: adding `Def::Class` and `Def::Instance`
|
||||
/// must NOT change canonical-JSON hashes of any pre-22b def. The
|
||||
/// new variants are alternatives, not field additions — pre-22b
|
||||
/// fixtures simply do not produce a `Class` / `Instance` `Def`,
|
||||
/// and the existing `Fn` / `Const` / `Type` arms are unchanged.
|
||||
/// This re-asserts the same on-disk hashes the 13a / 19b
|
||||
/// regressions pinned, after the 22b.1 schema bump.
|
||||
#[test]
|
||||
fn iter22b1_schema_extension_preserves_pre_22b_hashes() {
|
||||
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let examples = manifest_dir.join("../../examples");
|
||||
|
||||
let sum_src = std::fs::read(examples.join("sum.ail.json"))
|
||||
.expect("examples/sum.ail.json present");
|
||||
let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
|
||||
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
|
||||
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
|
||||
|
||||
let list_src = std::fs::read(examples.join("list.ail.json"))
|
||||
.expect("examples/list.ail.json present");
|
||||
let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap();
|
||||
let int_list_def = list_mod
|
||||
.defs
|
||||
.iter()
|
||||
.find(|d| d.name() == "IntList")
|
||||
.unwrap();
|
||||
assert_eq!(def_hash(int_list_def), "b082192bd0c99202");
|
||||
}
|
||||
|
||||
/// Iter 22b.1: a [`crate::ast::ClassDef`] with no doc, no
|
||||
/// superclass, and an empty methods list serialises to a stable
|
||||
/// canonical form. Two construction paths (default-elided
|
||||
/// optionals vs. JSON without those keys at all) hash
|
||||
/// bit-identically. If FAIL: a `skip_serializing_if` is missing
|
||||
/// on `superclass` or `doc`, or the parser produces different
|
||||
/// canonical bytes from the same logical value.
|
||||
#[test]
|
||||
fn iter22b1_classdef_empty_optionals_hash_stable() {
|
||||
let bare = Def::Class(crate::ast::ClassDef {
|
||||
name: "Empty".into(),
|
||||
param: "a".into(),
|
||||
superclass: None,
|
||||
methods: vec![],
|
||||
doc: None,
|
||||
});
|
||||
|
||||
let json = r#"{"kind":"class","name":"Empty","param":"a","methods":[]}"#;
|
||||
let parsed: Def = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
def_hash(&bare),
|
||||
def_hash(&parsed),
|
||||
"ClassDef with elided optionals must hash identically to construction with explicit None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
|
||||
//! ```
|
||||
|
||||
use crate::ast::Module;
|
||||
use crate::ast::{ClassDef, Def, InstanceDef, Module, Type};
|
||||
use crate::canonical;
|
||||
use crate::{load_module, Error as CoreError};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Fully loaded workspace.
|
||||
@@ -43,6 +43,11 @@ use std::path::{Path, PathBuf};
|
||||
/// transitively reachable modules are contained in `modules` and indexed
|
||||
/// by `Module.name`. `root_dir` is the directory the entry file lives
|
||||
/// in; all imports are resolved relative to it.
|
||||
///
|
||||
/// Iter 22b.1 (Decision 11): `registry` is the workspace-global
|
||||
/// instance registry, built at the end of [`load_workspace`] after the
|
||||
/// DFS over imports completes. It is empty for any workspace whose
|
||||
/// modules contain no [`crate::ast::Def::Instance`] defs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Workspace {
|
||||
/// Name of the entry module (the one passed to [`load_workspace`]).
|
||||
@@ -54,6 +59,42 @@ pub struct Workspace {
|
||||
/// Directory the entry file lives in; all imports are resolved
|
||||
/// relative to it.
|
||||
pub root_dir: PathBuf,
|
||||
/// Iter 22b.1: workspace-global typeclass instance registry.
|
||||
pub registry: Registry,
|
||||
}
|
||||
|
||||
/// Iter 22b.1: workspace-global instance registry (Decision 11).
|
||||
///
|
||||
/// Built at the end of [`load_workspace`] after all modules are
|
||||
/// loaded. Keyed by `(class-name, canonical-type-hash)`; values are
|
||||
/// the matching [`crate::ast::InstanceDef`] plus the name of the
|
||||
/// module it was declared in. The hash key uses
|
||||
/// [`canonical::type_hash`], so the key is stable against unrelated
|
||||
/// whitespace / field-order differences in the source JSON.
|
||||
///
|
||||
/// 22b.1 enforces three coherence checks during build:
|
||||
///
|
||||
/// 1. **Coherence (orphan-freedom).** Every `instance C T` lives in
|
||||
/// the module of `C` or in the module of `T` (per Decision 11
|
||||
/// axis 3). Otherwise → [`WorkspaceLoadError::OrphanInstance`].
|
||||
/// 2. **Uniqueness.** No two entries share a key. Otherwise →
|
||||
/// [`WorkspaceLoadError::DuplicateInstance`].
|
||||
/// 3. **Method completeness.** Each instance specifies a body for
|
||||
/// every required (non-default) method of its class. Otherwise →
|
||||
/// [`WorkspaceLoadError::MissingMethod`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Registry {
|
||||
/// Map from `(class-name, type-hash)` to the registry entry.
|
||||
pub entries: BTreeMap<(String, String), RegistryEntry>,
|
||||
}
|
||||
|
||||
/// One entry in the [`Registry`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryEntry {
|
||||
/// The instance declaration itself.
|
||||
pub instance: InstanceDef,
|
||||
/// Name of the module the instance was declared in.
|
||||
pub defining_module: String,
|
||||
}
|
||||
|
||||
/// Structured errors of the workspace loader.
|
||||
@@ -110,6 +151,54 @@ pub enum WorkspaceLoadError {
|
||||
"module `{name}` was loaded twice with differing content (hashes differ)"
|
||||
)]
|
||||
ModuleHashMismatch { name: String },
|
||||
|
||||
/// Iter 22b.1: an [`crate::ast::Def::Instance`] was declared in a
|
||||
/// module that is neither the class's defining module nor the
|
||||
/// instance type's defining module. Coherence violation per
|
||||
/// Decision 11 axis 3 ("orphan-freedom"). The lookup is hard:
|
||||
/// AILang does not provide a `--allow-orphans` flag.
|
||||
#[error(
|
||||
"orphan instance: `instance {class} {type_repr}` declared in module `{defining_module}`, \
|
||||
but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there"
|
||||
)]
|
||||
OrphanInstance {
|
||||
class: String,
|
||||
type_repr: String,
|
||||
defining_module: String,
|
||||
class_module: String,
|
||||
type_module: String,
|
||||
},
|
||||
|
||||
/// Iter 22b.1: two [`crate::ast::Def::Instance`]s share the same
|
||||
/// `(class, canonical-type-hash)` key. Coherence requires
|
||||
/// uniqueness; the registry has no way to disambiguate at
|
||||
/// resolution time. Per Decision 11 there is no
|
||||
/// `AmbiguousInstance` diagnostic — coherence makes the lookup
|
||||
/// unambiguous by construction, and a duplicate is a workspace
|
||||
/// configuration error, not a per-call-site one.
|
||||
#[error(
|
||||
"duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`"
|
||||
)]
|
||||
DuplicateInstance {
|
||||
class: String,
|
||||
type_repr: String,
|
||||
first_module: String,
|
||||
second_module: String,
|
||||
},
|
||||
|
||||
/// Iter 22b.1: an [`crate::ast::Def::Instance`] does not specify
|
||||
/// a body for a required (non-default) method of its class.
|
||||
/// Default-bearing methods may be inherited; non-default
|
||||
/// (abstract-required) methods must be specified by every
|
||||
/// instance. Per Decision 11 §"Defaults and superclasses".
|
||||
#[error(
|
||||
"instance `{class} {type_repr}` is missing a body for method `{method}` (no default)"
|
||||
)]
|
||||
MissingMethod {
|
||||
class: String,
|
||||
type_repr: String,
|
||||
method: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Hash over the canonical bytes of a complete module.
|
||||
@@ -161,13 +250,163 @@ pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError
|
||||
&mut visiting_set,
|
||||
)?;
|
||||
|
||||
// Iter 22b.1: build the workspace-global instance registry. Three
|
||||
// coherence checks fire here (Orphan / Duplicate / Missing-method);
|
||||
// any violation is surfaced as a `WorkspaceLoadError`, not as a
|
||||
// per-call-site diagnostic.
|
||||
let registry = build_registry(&modules)?;
|
||||
|
||||
Ok(Workspace {
|
||||
entry: entry_name,
|
||||
modules,
|
||||
root_dir,
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
/// Iter 22b.1: build the workspace-global typeclass instance registry.
|
||||
///
|
||||
/// Two passes:
|
||||
///
|
||||
/// 1. Scan every loaded module to build a "where is X defined" lookup
|
||||
/// for class names and type names — needed for the coherence
|
||||
/// (orphan) check below.
|
||||
/// 2. For each [`crate::ast::Def::Instance`] in declaration order,
|
||||
/// apply the three checks (coherence / uniqueness / method
|
||||
/// completeness) and insert the entry on success.
|
||||
///
|
||||
/// Iteration is over the modules `BTreeMap` (alphabetical by name) so
|
||||
/// the order in which collisions are detected is deterministic across
|
||||
/// runs. Within a module, defs are scanned in source order.
|
||||
fn build_registry(
|
||||
modules: &BTreeMap<String, Module>,
|
||||
) -> Result<Registry, WorkspaceLoadError> {
|
||||
// Pass 1: collect "where is X defined" maps, plus a class lookup
|
||||
// by name (needed for the method-completeness check).
|
||||
let mut class_def_module: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut type_def_module: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut class_by_name: BTreeMap<String, &ClassDef> = BTreeMap::new();
|
||||
for (mod_name, m) in modules {
|
||||
for def in &m.defs {
|
||||
match def {
|
||||
Def::Class(c) => {
|
||||
class_def_module.insert(c.name.clone(), mod_name.clone());
|
||||
class_by_name.insert(c.name.clone(), c);
|
||||
}
|
||||
Def::Type(t) => {
|
||||
type_def_module.insert(t.name.clone(), mod_name.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: register each instance, with coherence / uniqueness /
|
||||
// method-completeness checks.
|
||||
let mut entries: BTreeMap<(String, String), RegistryEntry> = BTreeMap::new();
|
||||
for (mod_name, m) in modules {
|
||||
for def in &m.defs {
|
||||
if let Def::Instance(inst) = def {
|
||||
let type_repr = type_head_name(&inst.type_);
|
||||
|
||||
// Coherence (orphan-freedom): instance's module must
|
||||
// equal the class's module or the type's module. A
|
||||
// class declared inside the same module as the
|
||||
// instance always satisfies the first leg; a
|
||||
// user-defined type declared in the instance's module
|
||||
// satisfies the second. Primitives have no
|
||||
// user-defined module — instances on primitives
|
||||
// therefore must live in the class's module.
|
||||
let class_mod = class_def_module
|
||||
.get(&inst.class)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "<unknown-class>".into());
|
||||
let type_mod = type_def_module
|
||||
.get(&type_repr)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "<primitive-or-unknown>".into());
|
||||
let coherent = mod_name == &class_mod || mod_name == &type_mod;
|
||||
if !coherent {
|
||||
return Err(WorkspaceLoadError::OrphanInstance {
|
||||
class: inst.class.clone(),
|
||||
type_repr,
|
||||
defining_module: mod_name.clone(),
|
||||
class_module: class_mod,
|
||||
type_module: type_mod,
|
||||
});
|
||||
}
|
||||
|
||||
// Uniqueness: a `(class, type-hash)` key must appear
|
||||
// at most once across the whole workspace.
|
||||
let type_hash = canonical::type_hash(&inst.type_);
|
||||
let key = (inst.class.clone(), type_hash);
|
||||
if let Some(prior) = entries.get(&key) {
|
||||
return Err(WorkspaceLoadError::DuplicateInstance {
|
||||
class: inst.class.clone(),
|
||||
type_repr,
|
||||
first_module: prior.defining_module.clone(),
|
||||
second_module: mod_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Method completeness: every non-default method of
|
||||
// the class must have a body in this instance.
|
||||
// Defaults may be inherited (no body required).
|
||||
// Missing class declaration is deferred to 22b.2's
|
||||
// typecheck arms — for 22b.1 we skip the
|
||||
// completeness check rather than firing a separate
|
||||
// diagnostic.
|
||||
if let Some(class_def) = class_by_name.get(&inst.class) {
|
||||
let provided: BTreeSet<&str> =
|
||||
inst.methods.iter().map(|m| m.name.as_str()).collect();
|
||||
for class_method in &class_def.methods {
|
||||
if class_method.default.is_none()
|
||||
&& !provided.contains(class_method.name.as_str())
|
||||
{
|
||||
return Err(WorkspaceLoadError::MissingMethod {
|
||||
class: inst.class.clone(),
|
||||
type_repr,
|
||||
method: class_method.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.insert(
|
||||
key,
|
||||
RegistryEntry {
|
||||
instance: inst.clone(),
|
||||
defining_module: mod_name.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Registry { entries })
|
||||
}
|
||||
|
||||
/// Iter 22b.1: extract the head-constructor name of a type, for the
|
||||
/// "where is this type defined" lookup and for diagnostic-message
|
||||
/// rendering.
|
||||
///
|
||||
/// For `Type::Con { name, .. }` (the only legal head shape for a
|
||||
/// non-orphan instance) returns `name`. Other variants (`Var`,
|
||||
/// `Forall`, `Fn`) are not legal as instance heads — Decision 11
|
||||
/// requires a concrete type expression at the instance head — so we
|
||||
/// emit a stable fallback string. The fallback prevents diagnostic
|
||||
/// rendering from panicking on a malformed fixture; the "real"
|
||||
/// rejection of non-concrete instance heads will arrive as a
|
||||
/// schema-validation diagnostic in 22b.2.
|
||||
fn type_head_name(t: &Type) -> String {
|
||||
match t {
|
||||
Type::Con { name, .. } => name.clone(),
|
||||
Type::Var { name } => format!("<var:{name}>"),
|
||||
Type::Forall { .. } => "<forall>".into(),
|
||||
Type::Fn { .. } => "<fn>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit(
|
||||
module: Module,
|
||||
root_dir: &Path,
|
||||
@@ -352,4 +591,22 @@ mod tests {
|
||||
other => panic!("expected ModuleNotFound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iter 22b.1: a workspace whose modules contain no
|
||||
/// `Def::Instance` defs has an empty registry. This is the
|
||||
/// happy-path baseline for the registry-build pass — every
|
||||
/// pre-22b workspace falls into this case.
|
||||
#[test]
|
||||
fn iter22b1_workspace_with_no_classes_has_empty_registry() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace_root =
|
||||
Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let entry = workspace_root.join("examples").join("sum.ail.json");
|
||||
|
||||
let ws = load_workspace(&entry).expect("sum.ail.json loads");
|
||||
assert!(
|
||||
ws.registry.entries.is_empty(),
|
||||
"pre-22b fixture has no class/instance defs, registry must be empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user