1205 lines
48 KiB
Rust
1205 lines
48 KiB
Rust
//! Workspace loader: loads an entry module and recursively follows its
|
|
//! `imports`.
|
|
//!
|
|
//! Convention: an `import { module: "foo" }` is resolved relative to
|
|
//! the entry file's directory as `<root_dir>/foo.ail.json`. Module
|
|
//! names must match the file stem (the loader rejects mismatches).
|
|
//!
|
|
//! The single entry point is [`load_workspace`]; it returns a fully
|
|
//! populated [`Workspace`] or a structured [`WorkspaceLoadError`].
|
|
//! [`module_hash`] is the module-granularity counterpart to
|
|
//! [`crate::def_hash`] and is used both internally (to detect a module
|
|
//! re-loaded from disk with different content) and by the CLI.
|
|
//!
|
|
//! This module is responsible only for **finding** and consistently
|
|
//! **loading** all reachable modules. Cross-module typechecking lives
|
|
//! in `ailang-check`; codegen in `ailang-codegen`. Neither is run from
|
|
//! here.
|
|
//!
|
|
//! # Examples
|
|
//!
|
|
//! ```ignore
|
|
//! use ailang_core::load_workspace;
|
|
//! use std::path::Path;
|
|
//!
|
|
//! let ws = load_workspace(Path::new("examples/ws_main.ail.json"))?;
|
|
//! assert_eq!(ws.entry, "ws_main");
|
|
//! // All transitively imported modules are now in `ws.modules`.
|
|
//! for (name, m) in &ws.modules {
|
|
//! println!("{name}: {} defs", m.defs.len());
|
|
//! }
|
|
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
|
|
//! ```
|
|
|
|
use crate::ast::{ClassDef, Def, InstanceDef, Module, Type};
|
|
use crate::canonical;
|
|
use crate::{load_module, Error as CoreError};
|
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Fully loaded workspace.
|
|
///
|
|
/// `entry` names the entry module (module name, **not** a path). All
|
|
/// 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`]).
|
|
pub entry: String,
|
|
/// Every module reachable from `entry`, indexed by module name.
|
|
/// `BTreeMap` is used so iteration order is deterministic, which
|
|
/// matters for downstream codegen and reporting.
|
|
pub modules: BTreeMap<String, Module>,
|
|
/// 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.
|
|
///
|
|
/// `Cycle.path` is the chain of module names in which the cycle was
|
|
/// closed — the last element is the name already present in `visiting`.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum WorkspaceLoadError {
|
|
/// File I/O failed while reading a module file (typically: file
|
|
/// missing, permission denied).
|
|
#[error("io error for {path}: {source}")]
|
|
Io {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: std::io::Error,
|
|
},
|
|
|
|
/// File contents failed to parse as a [`Module`] or had the wrong
|
|
/// schema tag. Wraps a [`CoreError`] (the single-module loader's
|
|
/// error type).
|
|
#[error("schema/parse error in {path}: {source}")]
|
|
Schema {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: CoreError,
|
|
},
|
|
|
|
/// An `import { module: "foo" }` did not resolve to an existing
|
|
/// file at the expected path.
|
|
#[error("module `{name}` not found (expected at {expected_path})")]
|
|
ModuleNotFound { name: String, expected_path: PathBuf },
|
|
|
|
/// The `name` field in a loaded module file disagrees with the
|
|
/// file-stem-derived name the loader expected (i.e. the
|
|
/// `<name>.ail.json` convention is broken).
|
|
#[error(
|
|
"module name in file ({name_in_file:?}) does not match expected name from path ({name_from_path:?})"
|
|
)]
|
|
ModuleNameMismatch {
|
|
name_in_file: String,
|
|
name_from_path: String,
|
|
},
|
|
|
|
/// An import cycle was detected. `path` is the chain of module
|
|
/// names in which the cycle was closed — the last element is the
|
|
/// name that was already on the visit stack.
|
|
#[error("import cycle detected: {}", path.join(" -> "))]
|
|
Cycle { path: Vec<String> },
|
|
|
|
/// A module was reachable through two import paths, and its
|
|
/// on-disk content (compared via [`module_hash`]) differs between
|
|
/// the two reads. This typically means the file changed mid-load.
|
|
#[error(
|
|
"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,
|
|
},
|
|
|
|
/// Iter 22b.2: class-schema validation. The class parameter
|
|
/// appears in applied position (e.g. as the head of a
|
|
/// `Type::Con { name == param, args.len() > 0 }`) inside a method
|
|
/// signature. Decision 11 axis 5 forbids HKTs — class params are
|
|
/// kind `*` only.
|
|
#[error(
|
|
"kind mismatch in class `{class}`: parameter `{param}` is used in applied position \
|
|
inside method `{method}` (Decision 11 axis 5: class params are kind `*` only)"
|
|
)]
|
|
KindMismatch {
|
|
class: String,
|
|
param: String,
|
|
method: String,
|
|
defining_module: String,
|
|
},
|
|
|
|
/// Iter 22b.2: class-schema validation. A class's `superclass.type`
|
|
/// does not equal its own `param`. Decision 11 single-superclass
|
|
/// model requires the superclass to be applied to the same param
|
|
/// (e.g. `class Ord a extends Eq a`, not `extends Eq b`).
|
|
#[error(
|
|
"class `{class}` declares superclass `{superclass} {got_type}`, but its own parameter is `{expected_param}` \
|
|
— superclass `type` must equal class `param`"
|
|
)]
|
|
InvalidSuperclassParam {
|
|
class: String,
|
|
superclass: String,
|
|
expected_param: String,
|
|
got_type: String,
|
|
},
|
|
|
|
/// Iter 22b.2: class-schema validation. A class method's
|
|
/// signature contains a constraint referencing a type variable
|
|
/// that is neither bound by the method's `Forall.vars` nor equal
|
|
/// to the class's `param`.
|
|
#[error(
|
|
"in class `{class}` method `{method}`: constraint `{constraint_class} {var}` references unbound type variable `{var}`"
|
|
)]
|
|
UnboundConstraintTypeVar {
|
|
class: String,
|
|
method: String,
|
|
constraint_class: String,
|
|
var: String,
|
|
},
|
|
|
|
/// Iter 22b.2: an instance specifies a body for a method name
|
|
/// that the corresponding class does not declare. Symmetric to
|
|
/// `MissingMethod` but in the opposite direction.
|
|
#[error(
|
|
"instance `{class} {type_repr}` provides body for method `{method}`, but class `{class}` does not declare it"
|
|
)]
|
|
OverridingNonExistentMethod {
|
|
class: String,
|
|
type_repr: String,
|
|
method: String,
|
|
},
|
|
|
|
/// Iter 22b.2: a class-method name collides with another
|
|
/// class-method or with a top-level fn. `kind` is
|
|
/// `"class-class"` or `"class-fn"`.
|
|
#[error(
|
|
"method name `{method}` collides ({kind}): defined in `{first_origin}` and `{second_origin}`"
|
|
)]
|
|
MethodNameCollision {
|
|
method: String,
|
|
kind: &'static str,
|
|
first_origin: String,
|
|
second_origin: String,
|
|
},
|
|
|
|
/// Iter 22b.2: an instance `C T` was declared, but `C`'s
|
|
/// superclass `S` does not have an instance for the same type
|
|
/// `T`. Decision 11 single-superclass model requires `instance S
|
|
/// T` to exist whenever `instance C T` exists.
|
|
#[error(
|
|
"instance `{class} {type_repr}` requires superclass instance `{superclass} {type_repr}`, but none was found"
|
|
)]
|
|
MissingSuperclassInstance {
|
|
class: String,
|
|
superclass: String,
|
|
type_repr: String,
|
|
},
|
|
}
|
|
|
|
/// Hash over the canonical bytes of a complete module.
|
|
///
|
|
/// Parallel to `def_hash`, but at module level. The workspace loader
|
|
/// uses this to verify double-loads; the CLI uses it to emit a stable
|
|
/// per-module identifier.
|
|
pub fn module_hash(m: &Module) -> String {
|
|
let bytes = canonical::to_bytes(m);
|
|
let h = blake3::hash(&bytes);
|
|
h.to_hex().as_str()[..16].to_string()
|
|
}
|
|
|
|
/// Load entry module plus all transitively reachable modules.
|
|
///
|
|
/// Algorithm: DFS over `imports`, with two sets:
|
|
/// - `loaded` (= `modules` map): modules whose subtree is already fully
|
|
/// processed. On a re-hit only hash consistency is checked.
|
|
/// - `visiting`: stack of modules whose DFS descent is still running.
|
|
/// A hit here = cycle.
|
|
pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError> {
|
|
let entry_path = entry_path.to_path_buf();
|
|
let root_dir = entry_path
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
|
|
// Load entry module + check name<->filename convention.
|
|
let entry_module = load_one(&entry_path)?;
|
|
let expected_entry_name = module_name_from_path(&entry_path);
|
|
if entry_module.name != expected_entry_name {
|
|
return Err(WorkspaceLoadError::ModuleNameMismatch {
|
|
name_in_file: entry_module.name.clone(),
|
|
name_from_path: expected_entry_name,
|
|
});
|
|
}
|
|
|
|
let entry_name = entry_module.name.clone();
|
|
|
|
let mut modules: BTreeMap<String, Module> = BTreeMap::new();
|
|
let mut visiting: Vec<String> = Vec::new();
|
|
let mut visiting_set: HashSet<String> = HashSet::new();
|
|
|
|
visit(
|
|
entry_module,
|
|
&root_dir,
|
|
&mut modules,
|
|
&mut visiting,
|
|
&mut visiting_set,
|
|
)?;
|
|
|
|
// Iter 22b.2: class-schema validation runs before registry
|
|
// construction so that ill-kinded class definitions are rejected
|
|
// before any instance against them is registered.
|
|
validate_classdefs(&modules)?;
|
|
|
|
// 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());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Iter 22b.2: method-name-collision pre-pass. Bare-name resolution
|
|
// (`foo x` rather than `A.foo x`) requires that a method name
|
|
// appears in at most one origin across the whole workspace. We
|
|
// walk every `Def::Class` and `Def::Fn` once, building a
|
|
// `method_origins` map; the first repeat fires the diagnostic.
|
|
// The `kind` field distinguishes class-method ↔ class-method from
|
|
// class-method ↔ top-level-fn; fn-fn collisions are a separate
|
|
// concern surfaced by `CheckError::DuplicateDef` in `ailang-check`,
|
|
// not here.
|
|
//
|
|
// Origins are kept structural (the `Origin` enum below) so the
|
|
// `kind` discriminator is a `match` on variants rather than a
|
|
// string-prefix check on a display form.
|
|
enum Origin {
|
|
Class { class_name: String, module: String },
|
|
Fn { name: String, module: String },
|
|
}
|
|
impl Origin {
|
|
fn format(&self) -> String {
|
|
match self {
|
|
Origin::Class { class_name, module } => {
|
|
format!("class {class_name} (in {module})")
|
|
}
|
|
Origin::Fn { name, module } => format!("fn {name} (in {module})"),
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut method_origins: BTreeMap<String, Origin> = BTreeMap::new();
|
|
for (mod_name, m) in modules {
|
|
for def in &m.defs {
|
|
match def {
|
|
Def::Class(c) => {
|
|
for method in &c.methods {
|
|
let origin = Origin::Class {
|
|
class_name: c.name.clone(),
|
|
module: mod_name.clone(),
|
|
};
|
|
if let Some(prior) = method_origins.get(&method.name) {
|
|
let kind = match prior {
|
|
Origin::Class { .. } => "class-class",
|
|
Origin::Fn { .. } => "class-fn",
|
|
};
|
|
return Err(WorkspaceLoadError::MethodNameCollision {
|
|
method: method.name.clone(),
|
|
kind,
|
|
first_origin: prior.format(),
|
|
second_origin: origin.format(),
|
|
});
|
|
}
|
|
method_origins.insert(method.name.clone(), origin);
|
|
}
|
|
}
|
|
Def::Fn(f) => {
|
|
let origin = Origin::Fn {
|
|
name: f.name.clone(),
|
|
module: mod_name.clone(),
|
|
};
|
|
if let Some(prior) = method_origins.get(&f.name) {
|
|
// Only fire on class-fn collisions here. fn-fn
|
|
// collisions (two `Def::Fn` with the same name)
|
|
// are `CheckError::DuplicateDef`'s job in
|
|
// `ailang-check`; firing here with `kind:
|
|
// "class-fn"` would misreport.
|
|
if matches!(prior, Origin::Class { .. }) {
|
|
return Err(WorkspaceLoadError::MethodNameCollision {
|
|
method: f.name.clone(),
|
|
kind: "class-fn",
|
|
first_origin: prior.format(),
|
|
second_origin: origin.format(),
|
|
});
|
|
}
|
|
// prior is a fn: skip; do not overwrite.
|
|
} else {
|
|
method_origins.insert(f.name.clone(), origin);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Symmetric to MissingMethod: an instance must
|
|
// not specify a body for a method name the class
|
|
// never declared. Decision 11 forbids ad-hoc
|
|
// additions to a class's method set at the
|
|
// instance site.
|
|
let declared: BTreeSet<&str> =
|
|
class_def.methods.iter().map(|m| m.name.as_str()).collect();
|
|
for inst_method in &inst.methods {
|
|
if !declared.contains(inst_method.name.as_str()) {
|
|
return Err(WorkspaceLoadError::OverridingNonExistentMethod {
|
|
class: inst.class.clone(),
|
|
type_repr: type_repr.clone(),
|
|
method: inst_method.name.clone(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
entries.insert(
|
|
key,
|
|
RegistryEntry {
|
|
instance: inst.clone(),
|
|
defining_module: mod_name.clone(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Iter 22b.2: superclass-instance completeness. For every entry,
|
|
// walk the class's superclass chain and require an entry for each
|
|
// step at the same type-hash.
|
|
for (key, entry) in entries.iter() {
|
|
let (class_name, type_hash) = key;
|
|
let type_repr = type_head_name(&entry.instance.type_);
|
|
// Iter 22b.2 leaves superclass-cycle detection to a future arm;
|
|
// here we just terminate the walk.
|
|
let mut visited: BTreeSet<&str> = BTreeSet::new();
|
|
let mut current = class_by_name.get(class_name.as_str()).copied();
|
|
while let Some(c) = current {
|
|
if !visited.insert(c.name.as_str()) {
|
|
break;
|
|
}
|
|
if let Some(sc) = &c.superclass {
|
|
let sc_key = (sc.class.clone(), type_hash.clone());
|
|
if !entries.contains_key(&sc_key) {
|
|
return Err(WorkspaceLoadError::MissingSuperclassInstance {
|
|
class: class_name.clone(),
|
|
superclass: sc.class.clone(),
|
|
type_repr: type_repr.clone(),
|
|
});
|
|
}
|
|
current = class_by_name.get(sc.class.as_str()).copied();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Registry { entries })
|
|
}
|
|
|
|
/// Iter 22b.2: class-schema validation. Runs before `build_registry`.
|
|
/// Three diagnostics fire from here: `kind-mismatch`,
|
|
/// `invalid-superclass-param`, `constraint-references-unbound-type-var`.
|
|
fn validate_classdefs(
|
|
modules: &BTreeMap<String, Module>,
|
|
) -> Result<(), WorkspaceLoadError> {
|
|
for (mod_name, m) in modules {
|
|
for def in &m.defs {
|
|
if let Def::Class(c) = def {
|
|
for method in &c.methods {
|
|
walk_kind_mismatch(&method.ty, &c.param)
|
|
.map_err(|()| WorkspaceLoadError::KindMismatch {
|
|
class: c.name.clone(),
|
|
param: c.param.clone(),
|
|
method: method.name.clone(),
|
|
defining_module: mod_name.clone(),
|
|
})?;
|
|
}
|
|
if let Some(sc) = &c.superclass {
|
|
if sc.type_ != c.param {
|
|
return Err(WorkspaceLoadError::InvalidSuperclassParam {
|
|
class: c.name.clone(),
|
|
superclass: sc.class.clone(),
|
|
expected_param: c.param.clone(),
|
|
got_type: sc.type_.clone(),
|
|
});
|
|
}
|
|
}
|
|
for method in &c.methods {
|
|
if let Type::Forall { vars, constraints, .. } = &method.ty {
|
|
let mut bound: BTreeSet<&str> =
|
|
vars.iter().map(String::as_str).collect();
|
|
bound.insert(c.param.as_str());
|
|
for constr in constraints {
|
|
if let Type::Var { name } = &constr.type_ {
|
|
if !bound.contains(name.as_str()) {
|
|
return Err(WorkspaceLoadError::UnboundConstraintTypeVar {
|
|
class: c.name.clone(),
|
|
method: method.name.clone(),
|
|
constraint_class: constr.class.clone(),
|
|
var: name.clone(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Walks a `Type` looking for any `Type::Con { name == param, args
|
|
/// non-empty }`. The class param is kind `*`; appearing as a
|
|
/// constructor with arguments is a kind-mismatch.
|
|
fn walk_kind_mismatch(t: &Type, param: &str) -> Result<(), ()> {
|
|
match t {
|
|
Type::Con { name, args } => {
|
|
if name == param && !args.is_empty() {
|
|
return Err(());
|
|
}
|
|
for a in args {
|
|
walk_kind_mismatch(a, param)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
Type::Fn { params, ret, .. } => {
|
|
for p in params {
|
|
walk_kind_mismatch(p, param)?;
|
|
}
|
|
walk_kind_mismatch(ret, param)
|
|
}
|
|
Type::Forall { body, .. } => walk_kind_mismatch(body, param),
|
|
Type::Var { .. } => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
modules: &mut BTreeMap<String, Module>,
|
|
visiting: &mut Vec<String>,
|
|
visiting_set: &mut HashSet<String>,
|
|
) -> Result<(), WorkspaceLoadError> {
|
|
let name = module.name.clone();
|
|
|
|
// Already fully loaded? Then do nothing. Hash consistency is checked
|
|
// on re-encounter via imports (see below in the loop).
|
|
if modules.contains_key(&name) {
|
|
return Ok(());
|
|
}
|
|
|
|
// Cycle: same name currently on the DFS stack.
|
|
if visiting_set.contains(&name) {
|
|
let mut path = visiting.clone();
|
|
path.push(name);
|
|
return Err(WorkspaceLoadError::Cycle { path });
|
|
}
|
|
|
|
visiting.push(name.clone());
|
|
visiting_set.insert(name.clone());
|
|
|
|
// Process imports recursively.
|
|
let imports = module.imports.clone();
|
|
for imp in &imports {
|
|
let imp_path = root_dir.join(format!("{}.ail.json", imp.module));
|
|
|
|
if let Some(existing) = modules.get(&imp.module) {
|
|
// Already fully loaded — check hash consistency, in case the
|
|
// file on disk has changed.
|
|
let on_disk = match load_one(&imp_path) {
|
|
Ok(m) => m,
|
|
Err(WorkspaceLoadError::Io { .. }) => continue,
|
|
Err(e) => return Err(e),
|
|
};
|
|
if module_hash(existing) != module_hash(&on_disk) {
|
|
return Err(WorkspaceLoadError::ModuleHashMismatch {
|
|
name: imp.module.clone(),
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if visiting_set.contains(&imp.module) {
|
|
let mut path = visiting.clone();
|
|
path.push(imp.module.clone());
|
|
return Err(WorkspaceLoadError::Cycle { path });
|
|
}
|
|
|
|
if !imp_path.exists() {
|
|
return Err(WorkspaceLoadError::ModuleNotFound {
|
|
name: imp.module.clone(),
|
|
expected_path: imp_path,
|
|
});
|
|
}
|
|
|
|
let imported = load_one(&imp_path)?;
|
|
if imported.name != imp.module {
|
|
return Err(WorkspaceLoadError::ModuleNameMismatch {
|
|
name_in_file: imported.name,
|
|
name_from_path: imp.module.clone(),
|
|
});
|
|
}
|
|
visit(imported, root_dir, modules, visiting, visiting_set)?;
|
|
}
|
|
|
|
visiting.pop();
|
|
visiting_set.remove(&name);
|
|
modules.insert(name, module);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn load_one(path: &Path) -> Result<Module, WorkspaceLoadError> {
|
|
match load_module(path) {
|
|
Ok(m) => Ok(m),
|
|
Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io {
|
|
path: path.to_path_buf(),
|
|
source: e,
|
|
}),
|
|
Err(e) => Err(WorkspaceLoadError::Schema {
|
|
path: path.to_path_buf(),
|
|
source: e,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn module_name_from_path(p: &Path) -> String {
|
|
let file = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
|
// Convention: `<name>.ail.json`.
|
|
if let Some(stripped) = file.strip_suffix(".ail.json") {
|
|
return stripped.to_string();
|
|
}
|
|
// Fallback: strip only the last extension.
|
|
Path::new(file)
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or(file)
|
|
.to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn write_module(dir: &Path, name: &str, imports: &[&str]) -> PathBuf {
|
|
let imports_json: Vec<serde_json::Value> = imports
|
|
.iter()
|
|
.map(|m| serde_json::json!({ "module": m }))
|
|
.collect();
|
|
let module = serde_json::json!({
|
|
"schema": crate::SCHEMA,
|
|
"name": name,
|
|
"imports": imports_json,
|
|
"defs": [],
|
|
});
|
|
let path = dir.join(format!("{name}.ail.json"));
|
|
fs::write(&path, serde_json::to_vec_pretty(&module).unwrap()).unwrap();
|
|
path
|
|
}
|
|
|
|
fn tmp_dir(tag: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!(
|
|
"ailang_workspace_test_{tag}_{}",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_dir_all(&d);
|
|
fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn loads_example_workspace_happy_path() {
|
|
// Uses the canonical example files under `examples/`. This
|
|
// test documents that the loader relies on the committed
|
|
// workspace example.
|
|
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("ws_main.ail.json");
|
|
|
|
let ws = load_workspace(&entry).expect("load workspace");
|
|
assert_eq!(ws.entry, "ws_main");
|
|
assert!(ws.modules.contains_key("ws_main"));
|
|
assert!(ws.modules.contains_key("ws_lib"));
|
|
assert_eq!(ws.modules.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn detects_import_cycle() {
|
|
let dir = tmp_dir("cycle");
|
|
write_module(&dir, "a", &["b"]);
|
|
write_module(&dir, "b", &["a"]);
|
|
let entry = dir.join("a.ail.json");
|
|
|
|
let err = load_workspace(&entry).expect_err("must error on cycle");
|
|
match err {
|
|
WorkspaceLoadError::Cycle { path } => {
|
|
assert!(path.contains(&"a".to_string()));
|
|
assert!(path.contains(&"b".to_string()));
|
|
}
|
|
other => panic!("expected Cycle, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn module_not_found_yields_structured_error() {
|
|
let dir = tmp_dir("notfound");
|
|
write_module(&dir, "main", &["does_not_exist"]);
|
|
let entry = dir.join("main.ail.json");
|
|
|
|
let err = load_workspace(&entry).expect_err("must error on missing module");
|
|
match err {
|
|
WorkspaceLoadError::ModuleNotFound { name, expected_path } => {
|
|
assert_eq!(name, "does_not_exist");
|
|
assert!(expected_path.ends_with("does_not_exist.ail.json"));
|
|
}
|
|
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"
|
|
);
|
|
}
|
|
|
|
fn examples_dir() -> PathBuf {
|
|
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
|
Path::new(manifest_dir)
|
|
.parent()
|
|
.unwrap()
|
|
.parent()
|
|
.unwrap()
|
|
.join("examples")
|
|
}
|
|
|
|
/// Iter 22b.1: a coherent instance (in the class's module)
|
|
/// loads cleanly and produces one registry entry.
|
|
#[test]
|
|
fn iter22b1_instance_in_class_module_loads_clean() {
|
|
let entry = examples_dir().join("test_22b1_orphan_class.ail.json");
|
|
let ws = load_workspace(&entry).expect("coherent instance loads");
|
|
assert_eq!(ws.registry.entries.len(), 1);
|
|
let (key, entry) = ws.registry.entries.iter().next().unwrap();
|
|
assert_eq!(&key.0, "Show");
|
|
assert_eq!(entry.defining_module, "test_22b1_orphan_class");
|
|
assert_eq!(entry.instance.class, "Show");
|
|
}
|
|
|
|
/// Iter 22b.1: an instance declared in a module that is neither
|
|
/// the class's module nor the type's module fires `OrphanInstance`.
|
|
/// The fixture imports `test_22b1_orphan_third_classmod` (which
|
|
/// owns `class Show`) and the entry module declares `instance
|
|
/// Show Int` itself — but the entry is not the class's module
|
|
/// and `Int` is primitive, so neither leg of coherence is
|
|
/// satisfied.
|
|
#[test]
|
|
fn iter22b1_orphan_instance_fires_diagnostic() {
|
|
let entry = examples_dir().join("test_22b1_orphan_third.ail.json");
|
|
let err = load_workspace(&entry).expect_err("must fire orphan");
|
|
match err {
|
|
WorkspaceLoadError::OrphanInstance {
|
|
class,
|
|
type_repr,
|
|
defining_module,
|
|
..
|
|
} => {
|
|
assert_eq!(class, "Show");
|
|
assert_eq!(type_repr, "Int");
|
|
assert_eq!(defining_module, "test_22b1_orphan_third");
|
|
}
|
|
other => panic!("expected OrphanInstance, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.1: two instances of the same `(class, type)` pair
|
|
/// declared from coherent positions (one in the class's module,
|
|
/// one in the type's module) collide on the registry's
|
|
/// uniqueness check. Setup per the JOURNAL hint:
|
|
/// - module A defines `class Show` and declares
|
|
/// `instance Show MyInt` (legal, A is class's module).
|
|
/// - module B defines `type MyInt` and declares
|
|
/// `instance Show MyInt` (legal, B is type's module).
|
|
/// - entry module imports both A and B.
|
|
/// The build_registry pass sees both instances under the same
|
|
/// `(Show, hash-of-MyInt)` key and fires `DuplicateInstance`.
|
|
#[test]
|
|
fn iter22b1_duplicate_instance_fires_diagnostic() {
|
|
let entry = examples_dir().join("test_22b1_dup_entry.ail.json");
|
|
let err = load_workspace(&entry).expect_err("must fire duplicate");
|
|
match err {
|
|
WorkspaceLoadError::DuplicateInstance {
|
|
class,
|
|
type_repr,
|
|
first_module,
|
|
second_module,
|
|
} => {
|
|
assert_eq!(class, "Show");
|
|
assert_eq!(type_repr, "MyInt");
|
|
assert_ne!(first_module, second_module);
|
|
let modules: BTreeSet<&str> =
|
|
[first_module.as_str(), second_module.as_str()].iter().copied().collect();
|
|
assert!(modules.contains("test_22b1_dup_a"));
|
|
assert!(modules.contains("test_22b1_dup_b"));
|
|
}
|
|
other => panic!("expected DuplicateInstance, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.1: an instance that omits a required (non-default)
|
|
/// method of its class fires `MissingMethod`. The fixture's
|
|
/// `class Eq` declares `eq` and `ne` as both non-default; the
|
|
/// instance only specifies `ne`, leaving `eq` missing.
|
|
#[test]
|
|
fn iter22b1_missing_method_fires_diagnostic() {
|
|
let entry = examples_dir().join("test_22b1_missing_method.ail.json");
|
|
let err = load_workspace(&entry).expect_err("must fire missing-method");
|
|
match err {
|
|
WorkspaceLoadError::MissingMethod {
|
|
class,
|
|
type_repr,
|
|
method,
|
|
} => {
|
|
assert_eq!(class, "Eq");
|
|
assert_eq!(type_repr, "Int");
|
|
assert_eq!(method, "eq");
|
|
}
|
|
other => panic!("expected MissingMethod, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.2: a class whose parameter `f` appears in applied
|
|
/// position (`Type::Con { name == "f", args.len() > 0 }`) inside
|
|
/// a method signature must fire `KindMismatch`. Decision 11 axis
|
|
/// 5 forbids HKTs — class params are kind `*` only, never
|
|
/// constructors.
|
|
#[test]
|
|
fn class_param_in_applied_position_fires_kind_mismatch() {
|
|
let entry = std::path::PathBuf::from(
|
|
"../../examples/test_22b2_kind_mismatch.ail.json",
|
|
);
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire kind-mismatch");
|
|
match err {
|
|
WorkspaceLoadError::KindMismatch { class, param, method, .. } => {
|
|
assert_eq!(class, "Functor");
|
|
assert_eq!(param, "f");
|
|
assert_eq!(method, "fmap");
|
|
}
|
|
other => panic!("expected KindMismatch, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.2: a class whose `superclass.type` differs from its
|
|
/// own `param` (e.g. `class Ord a extends Eq b`) must fire
|
|
/// `InvalidSuperclassParam`. Decision 11 axis 1 ("single
|
|
/// superclass, applied to the same param") makes the only legal
|
|
/// shape `extends Eq a` when the parent class has `param: "a"`.
|
|
#[test]
|
|
fn superclass_with_wrong_param_fires_invalid_superclass_param() {
|
|
let entry = std::path::PathBuf::from(
|
|
"../../examples/test_22b2_invalid_superclass_param.ail.json",
|
|
);
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire invalid-superclass-param");
|
|
match err {
|
|
WorkspaceLoadError::InvalidSuperclassParam {
|
|
class, superclass, expected_param, got_type,
|
|
} => {
|
|
assert_eq!(class, "Ord");
|
|
assert_eq!(superclass, "Eq");
|
|
assert_eq!(expected_param, "a");
|
|
assert_eq!(got_type, "b");
|
|
}
|
|
other => panic!("expected InvalidSuperclassParam, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.2: an instance that specifies a body for a method
|
|
/// name the class never declared must fire
|
|
/// `OverridingNonExistentMethod`. Symmetric counterpart to
|
|
/// `MissingMethod`: the latter fires when the class declares a
|
|
/// non-default method that the instance omits; this one fires
|
|
/// when the instance provides a body the class did not ask for.
|
|
/// Decision 11 forbids ad-hoc additions to a class's method set
|
|
/// at the instance site.
|
|
#[test]
|
|
fn instance_overriding_nonexistent_method_fires() {
|
|
let entry = std::path::PathBuf::from(
|
|
"../../examples/test_22b2_overriding_nonexistent.ail.json",
|
|
);
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire overriding-non-existent-method");
|
|
match err {
|
|
WorkspaceLoadError::OverridingNonExistentMethod {
|
|
class, type_repr, method,
|
|
} => {
|
|
assert_eq!(class, "Eq");
|
|
assert_eq!(type_repr, "Int");
|
|
assert_eq!(method, "ne");
|
|
}
|
|
other => panic!("expected OverridingNonExistentMethod, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.2: a class method's `Type::Forall` whose `constraints`
|
|
/// reference a type variable that is neither bound by `Forall.vars`
|
|
/// nor equal to the class's `param` must fire
|
|
/// `UnboundConstraintTypeVar`. The fixture's `class Foo a` declares
|
|
/// method `foo` with `forall a. (Bar z) => a -> Unit`; `z` is
|
|
/// bound nowhere, so the constraint is unsatisfiable by
|
|
/// construction.
|
|
#[test]
|
|
fn constraint_with_unbound_var_fires_unbound_constraint_type_var() {
|
|
let entry = std::path::PathBuf::from(
|
|
"../../examples/test_22b2_unbound_constraint_var.ail.json",
|
|
);
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire constraint-references-unbound-type-var");
|
|
match err {
|
|
WorkspaceLoadError::UnboundConstraintTypeVar {
|
|
class, method, var, ..
|
|
} => {
|
|
assert_eq!(class, "Foo");
|
|
assert_eq!(method, "foo");
|
|
assert_eq!(var, "z");
|
|
}
|
|
other => panic!("expected UnboundConstraintTypeVar, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.2: two classes that declare a method with the same
|
|
/// name must fire `MethodNameCollision` with `kind == "class-class"`.
|
|
/// Decision 11 keeps method names workspace-unique so that bare
|
|
/// method-name resolution (`foo x` rather than `A.foo x`) is
|
|
/// unambiguous; if two classes both export `foo`, the registry has
|
|
/// no way to choose between them at a use site.
|
|
#[test]
|
|
fn class_class_method_name_collision_fires() {
|
|
let entry = examples_dir()
|
|
.join("test_22b2_method_name_collision_class_class.ail.json");
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire method-name-collision");
|
|
match err {
|
|
WorkspaceLoadError::MethodNameCollision {
|
|
method,
|
|
kind,
|
|
first_origin,
|
|
second_origin,
|
|
} => {
|
|
assert_eq!(method, "foo");
|
|
assert_eq!(kind, "class-class");
|
|
assert!(
|
|
first_origin.starts_with("class A"),
|
|
"first_origin = {first_origin:?}",
|
|
);
|
|
assert!(
|
|
second_origin.starts_with("class B"),
|
|
"second_origin = {second_origin:?}",
|
|
);
|
|
}
|
|
other => panic!("expected MethodNameCollision, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.2: a class-method name that collides with a top-level
|
|
/// `fn` of the same name must fire `MethodNameCollision` with
|
|
/// `kind == "class-fn"`. Same rationale as the class-class case:
|
|
/// bare-name resolution must be unambiguous, and a class method
|
|
/// shadowing (or being shadowed by) a free function silently is
|
|
/// the worst possible failure mode.
|
|
#[test]
|
|
fn class_fn_method_name_collision_fires() {
|
|
let entry = examples_dir()
|
|
.join("test_22b2_method_name_collision_class_fn.ail.json");
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire method-name-collision");
|
|
match err {
|
|
WorkspaceLoadError::MethodNameCollision {
|
|
method,
|
|
kind,
|
|
first_origin,
|
|
second_origin,
|
|
} => {
|
|
assert_eq!(method, "greet");
|
|
assert_eq!(kind, "class-fn");
|
|
assert!(
|
|
first_origin.starts_with("class Greet"),
|
|
"first_origin = {first_origin:?}",
|
|
);
|
|
assert!(
|
|
second_origin.starts_with("fn greet"),
|
|
"second_origin = {second_origin:?}",
|
|
);
|
|
}
|
|
other => panic!("expected MethodNameCollision, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Iter 22b.2: an instance `C T` whose class `C` declares a
|
|
/// superclass `S` requires that `instance S T` also exist in the
|
|
/// workspace. The fixture declares `class Eq a`, `class Ord a
|
|
/// extends Eq a`, and `instance Ord Int` — but no `instance Eq
|
|
/// Int` — so registry build must fire
|
|
/// `MissingSuperclassInstance`. Decision 11 single-superclass
|
|
/// model requires `instance S T` whenever `instance C T` exists.
|
|
#[test]
|
|
fn instance_without_superclass_instance_fires() {
|
|
let entry = examples_dir().join("test_22b2_missing_superclass_instance.ail.json");
|
|
let err = load_workspace(&entry)
|
|
.expect_err("must fire missing-superclass-instance");
|
|
match err {
|
|
WorkspaceLoadError::MissingSuperclassInstance {
|
|
class, superclass, type_repr,
|
|
} => {
|
|
assert_eq!(class, "Ord");
|
|
assert_eq!(superclass, "Eq");
|
|
assert_eq!(type_repr, "Int");
|
|
}
|
|
other => panic!("expected MissingSuperclassInstance, got {other:?}"),
|
|
}
|
|
}
|
|
}
|