///|
/// that PKL-118 still tracks separately.
fn push_inheritance_hardening_diagnostics(
declarations : Array[Declaration],
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Unit {
let by_name : Map[String, ClassDecl] = Map([], capacity=8)
for declaration in declarations {
match declaration {
ClassDeclaration(class_decl) => by_name[class_decl.name] = class_decl
_ => ()
}
}
for declaration in declarations {
match declaration {
ClassDeclaration(class_decl) => {
if !class_decl.is_abstract {
enforce_abstract_method_coverage(class_decl, by_name, diagnostics)
}
enforce_method_override_directions(
class_decl, by_name, type_env, diagnostics,
)
}
_ => ()
}
}
}
///|
/// Walk the parent chain collecting abstract methods that the
/// concrete `class_decl` (or some ancestor below the declaring
/// class) must override. Methods present in any descendant between
/// the abstract declaration and `class_decl` count as overrides.
fn enforce_abstract_method_coverage(
class_decl : ClassDecl,
by_name : Map[String, ClassDecl],
diagnostics : Array[Diagnostic],
) -> Unit {
let descendant_methods : Array[String] = []
for class_method in class_decl.methods {
descendant_methods.push(class_method.name)
}
let mut current : ClassDecl? = match class_decl.parent_name {
Some(name) => by_name.get(name)
None => None
}
while current is Some(parent_decl) {
for parent_method in parent_decl.methods {
if parent_method.is_abstract &&
!array_contains_string(descendant_methods, parent_method.name) {
diagnostics.push(
diag(
"Class `\{class_decl.name}` does not implement abstract method `\{parent_method.name}` inherited from `\{parent_decl.name}`.",
),
)
}
}
for parent_method in parent_decl.methods {
if !array_contains_string(descendant_methods, parent_method.name) {
descendant_methods.push(parent_method.name)
}
}
current = match parent_decl.parent_name {
Some(name) => by_name.get(name)
None => None
}
}
}
///|
/// For each method in `class_decl`, look up a parent method with the
/// same name and verify return-type covariance and parameter-type
/// contravariance. Unresolved type annotations (`Cannot find type`)
/// don't push a duplicate diagnostic — they were already surfaced by
/// the class-member typecheck and skipping here keeps the noise
/// floor low.
fn enforce_method_override_directions(
class_decl : ClassDecl,
by_name : Map[String, ClassDecl],
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Unit {
for class_method in class_decl.methods {
let parent_method = find_inherited_method(
class_decl,
class_method.name,
by_name,
)
match parent_method {
Some((parent_decl, ancestor_method)) => {
// Return type: covariant. Child must be a subtype of parent.
match
(ancestor_method.return_type_name, class_method.return_type_name) {
(Some(parent_ret), Some(child_ret)) =>
match
(
type_from_annotation(parent_ret, type_env),
type_from_annotation(child_ret, type_env),
) {
(Some(parent_type), Some(child_type)) =>
if !type_accepts(parent_type, child_type) {
diagnostics.push(
diag(
"Method `\{class_decl.name}.\{class_method.name}` return type `\{child_ret}` is not a subtype of `\{parent_decl.name}.\{ancestor_method.name}` return type `\{parent_ret}`.",
),
)
}
_ => ()
}
_ => ()
}
let parent_params = ancestor_method.parameters
let child_params = class_method.parameters
if parent_params.length() == child_params.length() {
for i = 0; i < parent_params.length(); i = i + 1 {
match (parent_params[i].type_name, child_params[i].type_name) {
(Some(parent_param), Some(child_param)) =>
match
(
type_from_annotation(parent_param, type_env),
type_from_annotation(child_param, type_env),
) {
(Some(parent_type), Some(child_type)) =>
if !type_accepts(child_type, parent_type) {
diagnostics.push(
diag(
"Method `\{class_decl.name}.\{class_method.name}` parameter `\{child_params[i].name}` type `\{child_param}` is not a supertype of `\{parent_decl.name}.\{ancestor_method.name}` parameter type `\{parent_param}`.",
),
)
}
_ => ()
}
_ => ()
}
}
}
}
None => ()
}
}
}
///|
/// Look up the nearest ancestor that declares a method named
/// `method_name`. Returns the declaring class along with the method
/// itself so the diagnostic can name both. `None` when no ancestor
/// declares the method (this method is a new addition, not an
/// override).
fn find_inherited_method(
class_decl : ClassDecl,
method_name : String,
by_name : Map[String, ClassDecl],
) -> (ClassDecl, FunctionDecl)? {
let mut current : ClassDecl? = match class_decl.parent_name {
Some(name) => by_name.get(name)
None => None
}
while current is Some(parent_decl) {
for parent_method in parent_decl.methods {
if parent_method.name == method_name {
return Some((parent_decl, parent_method))
}
}
current = match parent_decl.parent_name {
Some(name) => by_name.get(name)
None => None
}
}
None
}
///|
fn array_contains_string(items : Array[String], needle : String) -> Bool {
for item in items {
if item == needle {
return true
}
}
false
}
///|
fn type_from_alias_target_annotation(
name : String,
type_env : Array[TypeBinding],
) -> Type? {
match type_from_annotation(name, type_env) {
Some(typ) =>
if pkl_constrained_type_annotation_has_supported_constraint(name) {
Some(ConstrainedType(name, typ))
} else {
Some(typ)
}
None => None
}
}
///|
fn parameter_type_from_annotation(
name : String,
type_env : Array[TypeBinding],
) -> Type? {
match type_from_annotation(name, type_env) {
Some(typ) =>
if pkl_constrained_type_annotation_has_supported_constraint(name) ||
typ is ConstrainedType(_, _) {
Some(ConstrainedType(name, member_contract_type(typ)))
} else {
Some(typ)
}
None => None
}
}
///|
fn type_accepts(expected : Type, inferred : Type) -> Bool {
if expected == inferred {
return true
}
match (expected, inferred) {
(UnknownType, _) => true
// Unknown means inference could not refine the expression, not that the
// expression is known to violate the annotation. The originating lookup
// already emits a diagnostic when it is genuinely unresolved.
(_, UnknownType) => true
// PKL-110: a free type parameter accepts any incoming type at
// unification time. The substitution pass at the call site fills in
// the concrete type before the return value flows on; if no call site
// exists (e.g. the body refers to T directly) it stays free.
(TypeVariable(_), _) => true
(_, TypeVariable(_)) => true
// PKL-133: `Any` is Pkl's top type — every value flows through it.
// The relation is symmetric so the same value can flow into and out
// of `Any` (e.g. `x: Any = 5` and `n: Int = x as Int` once `as` lands).
(AnyType, _) => true
(_, AnyType) => true
(ConstrainedType(_, expected_inner), actual) =>
type_accepts(expected_inner, actual)
(expected, ConstrainedType(_, actual_inner)) =>
type_accepts(expected, actual_inner)
(ObjectType(_), ObjectType(_)) => true
(ObjectType(_), ClassType(_, _)) => true
(ClassType(expected_name, _), ClassType(actual_name, _)) =>
expected_name == actual_name
(UnionType(expected_options), UnionType(actual_options)) => {
let mut ok = true
for actual in actual_options {
if !type_accepts(UnionType(expected_options), actual) {
ok = false
}
}
ok
}
(UnionType(expected_options), actual) => {
let mut ok = false
for expected_option in expected_options {
if type_accepts(expected_option, actual) {
ok = true
}
}
ok
}
(expected, UnionType(actual_options)) => {
let mut ok = true
for actual in actual_options {
if !type_accepts(expected, actual) {
ok = false
}
}
ok
}
(DefaultedType(expected_inner), actual) =>
type_accepts(expected_inner, member_contract_type(actual))
(expected, DefaultedType(actual_inner)) =>
type_accepts(expected, actual_inner)
(NullableType(_), NullType) => true
(NullableType(expected_inner), NullableType(actual_inner)) =>
type_accepts(expected_inner, actual_inner)
(NullableType(expected_inner), actual) =>
type_accepts(expected_inner, actual)
(ClassType(_, expected_members), ObjectType(actual_members)) => {
let mut ok = true
for expected_member in expected_members {
match lookup_member_type(actual_members, expected_member.name) {
Some(actual) =>
if !type_accepts(member_contract_type(expected_member.typ), actual) {
ok = false
}
None =>
if !is_defaulted_member_type(expected_member.typ) {
ok = false
}
}
}
ok
}
(ListingType(expected_items), ListingType(actual_items)) => {
if expected_items.length() == 0 {
return true
}
let expected_item = common_type(expected_items)
let mut ok = true
for actual_item in actual_items {
if !type_accepts(expected_item, actual_item) {
ok = false
}
}
ok
}
// PKL-119c: Set is invariant on element shape but accepts the
// same element-wise widening as Listing — an empty expected
// parameter list means "any element type".
(SetType(expected_items), SetType(actual_items)) => {
if expected_items.length() == 0 {
return true
}
let expected_item = common_type(expected_items)
let mut ok = true
for actual_item in actual_items {
if !type_accepts(expected_item, actual_item) {
ok = false
}
}
ok
}
// PKL-119d: Map follows the same widening rule as MappingType —
// empty expected entries accept any key/value. Otherwise the
// expected key / value common-types must accept each actual
// entry's slots.
(MapType(expected_entries), MapType(actual_entries)) => {
if expected_entries.length() == 0 {
return true
}
let expected_key = common_type(
expected_entries.map(fn(entry) { entry.key }),
)
let expected_value = common_type(
expected_entries.map(fn(entry) { entry.value }),
)
let mut ok = true
for actual_entry in actual_entries {
if !type_accepts(expected_key, actual_entry.key) ||
!type_accepts(expected_value, actual_entry.value) {
ok = false
}
}
ok
}
// PKL-137: an empty `new {}` literal infers to `ObjectType([])` at
// parse time. PKL-138's eval-side coercion projects the runtime
// value to `ListingValue([])` / `MappingValue([])` based on the
// binding annotation; mirror the same coercion here so the
// typechecker accepts `tests: Listing = new {}` and
// `m: Mapping = new {}` cleanly.
(ListingType(expected_items), ObjectType(members)) => {
let expected_item = if expected_items.length() == 0 {
UnknownType
} else {
common_type(expected_items)
}
let mut accepted = true
for field in members {
if field.name == "@spread" {
if !type_accepts(ListingType(expected_items), field.typ) {
accepted = false
}
} else if field.name.has_prefix("@element$") {
if !type_accepts(expected_item, field.typ) {
accepted = false
}
} else if field.name != "@when" && field.name != "@for" {
accepted = false
}
}
accepted
}
(MappingType(_), ObjectType(members)) => members.length() == 0
(MappingType(expected_entries), MappingType(actual_entries)) => {
if expected_entries.length() == 0 {
return true
}
let expected_key = common_type(
expected_entries.map(fn(entry) { entry.key }),
)
let expected_value = common_type(
expected_entries.map(fn(entry) { entry.value }),
)
let mut ok = true
for actual_entry in actual_entries {
if !type_accepts(expected_key, actual_entry.key) ||
!type_accepts(expected_value, actual_entry.value) {
ok = false
}
}
ok
}
(
FunctionType(expected_parameters, expected_return),
FunctionType(actual_parameters, actual_return),
) => {
if expected_parameters.length() != actual_parameters.length() {
return false
}
let mut ok = type_accepts(expected_return, actual_return)
for i = 0; i < expected_parameters.length(); i = i + 1 {
if !type_accepts(expected_parameters[i], actual_parameters[i]) {
ok = false
}
}
ok
}
_ => false
}
}
///|
// PKL-113: Equality (`==` / `!=`) admits a wider compatibility relation
// than `type_accepts`. Two operands typecheck under equality when they
// share a base shape: same primitive, numeric mix (Int / Float),
// nullable / non-null with matching base, or any structural pair where
// runtime equality is meaningful (object / class, listing / listing).
// The relation stays symmetric — `a == b` and `b == a` must agree —
// so the helper normalizes wrappers (`ConstrainedType`, `DefaultedType`)
// before pattern-matching.
fn equality_compatible(left : Type, right : Type) -> Bool {
let l = equality_unwrap_type(left)
let r = equality_unwrap_type(right)
if l == r {
return true
}
match (l, r) {
(UnknownType, _) | (_, UnknownType) => true
(TypeVariable(_), _) | (_, TypeVariable(_)) => true
// PKL-133: `Any` participates in equality on either side.
(AnyType, _) | (_, AnyType) => true
(NullType, NullableType(_)) | (NullableType(_), NullType) => true
(NullableType(inner), other) | (other, NullableType(inner)) =>
equality_compatible(inner, other)
(IntType, FloatType) | (FloatType, IntType) => true
(ClassType(name_l, _), ClassType(name_r, _)) => name_l == name_r
(ClassType(_, _), ObjectType(_)) | (ObjectType(_), ClassType(_, _)) => true
(ObjectType(_), ObjectType(_)) => true
(ListingType(_), ListingType(_)) => true
(MappingType(_), MappingType(_)) => true
(UnionType(options), other) | (other, UnionType(options)) => {
let mut ok = false
for option in options {
if equality_compatible(option, other) {
ok = true
}
}
ok
}
_ => false
}
}
///|
fn equality_unwrap_type(typ : Type) -> Type {
match typ {
ConstrainedType(_, inner) => equality_unwrap_type(inner)
DefaultedType(inner) => equality_unwrap_type(inner)
_ => typ
}
}
///|
// PKL-110: Substitution table for generic type parameters. A pair
// `(name, typ)` means "every TypeVariable(name) site rewrites to typ".
// Built incrementally by `unify_for_substitution` and consumed by
// `substitute_type`.
priv struct TypeSubstitution {
name : String
typ : Type
}
///|
fn substitution_lookup(subs : Array[TypeSubstitution], name : String) -> Type? {
let mut found : Type? = None
for entry in subs {
if entry.name == name {
found = Some(entry.typ)
}
}
found
}
///|
fn substitute_type_member(
field : TypeMember,
subs : Array[TypeSubstitution],
) -> TypeMember {
{ name: field.name, typ: substitute_type(field.typ, subs) }
}
///|
fn substitute_type_entry(
entry : TypeEntry,
subs : Array[TypeSubstitution],
) -> TypeEntry {
{
key: substitute_type(entry.key, subs),
value: substitute_type(entry.value, subs),
}
}
///|
fn substitute_type(typ : Type, subs : Array[TypeSubstitution]) -> Type {
if subs.length() == 0 {
return typ
}
match typ {
TypeVariable(name) =>
match substitution_lookup(subs, name) {
Some(resolved) => resolved
None => typ
}
ObjectType(members) =>
ObjectType(members.map(fn(m) { substitute_type_member(m, subs) }))
ClassType(name, members) =>
ClassType(name, members.map(fn(m) { substitute_type_member(m, subs) }))
ListingType(items) =>
ListingType(items.map(fn(t) { substitute_type(t, subs) }))
MappingType(entries) =>
MappingType(entries.map(fn(e) { substitute_type_entry(e, subs) }))
FunctionType(parameters, ret) =>
FunctionType(
parameters.map(fn(t) { substitute_type(t, subs) }),
substitute_type(ret, subs),
)
ConstrainedType(name, inner) =>
ConstrainedType(name, substitute_type(inner, subs))
UnionType(options) =>
UnionType(options.map(fn(t) { substitute_type(t, subs) }))
NullableType(inner) => NullableType(substitute_type(inner, subs))
DefaultedType(inner) => DefaultedType(substitute_type(inner, subs))
_ => typ
}
}
///|
fn record_substitution(
subs : Array[TypeSubstitution],
name : String,
typ : Type,
) -> Unit {
// First binding wins. Later mismatches stay silent here — the surrounding
// type_accepts check is the diagnostic surface, this pass only collects.
match substitution_lookup(subs, name) {
Some(_) => ()
None => subs.push({ name, typ })
}
}
///|
fn unify_for_substitution(
expected : Type,
actual : Type,
subs : Array[TypeSubstitution],
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Unit {
match (expected, actual) {
(TypeVariable(name), _) => {
// PKL-116: when the parameter carries a declared bound, check
// that the concrete argument flows through `type_accepts(bound,
// actual)`. First-binding-wins still applies to the substitution
// table itself; bound rejection is independent of binding
// recording so the diagnostic surface stays clear.
for binding in type_env {
if binding.name == name {
match binding.bound {
Some(bound_type) =>
if !type_accepts(bound_type, actual) {
diagnostics.push(
diag(
"type parameter \{name} bound \{render_type(bound_type)} rejects \{render_type(actual)}",
),
)
}
None => ()
}
}
}
record_substitution(subs, name, actual)
}
(ConstrainedType(_, expected_inner), _) =>
unify_for_substitution(
expected_inner, actual, subs, type_env, diagnostics,
)
(_, ConstrainedType(_, actual_inner)) =>
unify_for_substitution(
expected, actual_inner, subs, type_env, diagnostics,
)
(DefaultedType(expected_inner), _) =>
unify_for_substitution(
expected_inner, actual, subs, type_env, diagnostics,
)
(_, DefaultedType(actual_inner)) =>
unify_for_substitution(
expected, actual_inner, subs, type_env, diagnostics,
)
(NullableType(expected_inner), NullableType(actual_inner)) =>
unify_for_substitution(
expected_inner, actual_inner, subs, type_env, diagnostics,
)
(NullableType(expected_inner), _) =>
unify_for_substitution(
expected_inner, actual, subs, type_env, diagnostics,
)
(ListingType(expected_items), ListingType(actual_items)) =>
if expected_items.length() > 0 && actual_items.length() > 0 {
let expected_item = expected_items[0]
for item in actual_items {
unify_for_substitution(
expected_item, item, subs, type_env, diagnostics,
)
}
}
(MappingType(expected_entries), MappingType(actual_entries)) =>
if expected_entries.length() > 0 && actual_entries.length() > 0 {
let expected_entry = expected_entries[0]
for entry in actual_entries {
unify_for_substitution(
expected_entry.key,
entry.key,
subs,
type_env,
diagnostics,
)
unify_for_substitution(
expected_entry.value,
entry.value,
subs,
type_env,
diagnostics,
)
}
}
(
FunctionType(expected_parameters, expected_return),
FunctionType(actual_parameters, actual_return),
) => {
let limit = if expected_parameters.length() < actual_parameters.length() {
expected_parameters.length()
} else {
actual_parameters.length()
}
for i = 0; i < limit; i = i + 1 {
unify_for_substitution(
expected_parameters[i],
actual_parameters[i],
subs,
type_env,
diagnostics,
)
}
unify_for_substitution(
expected_return, actual_return, subs, type_env, diagnostics,
)
}
(ClassType(_, expected_members), ObjectType(actual_members))
| (ClassType(_, expected_members), ClassType(_, actual_members)) =>
for expected_member in expected_members {
match lookup_member_type(actual_members, expected_member.name) {
Some(actual) =>
unify_for_substitution(
member_contract_type(expected_member.typ),
actual,
subs,
type_env,
diagnostics,
)
None => ()
}
}
(ObjectType(expected_members), ObjectType(actual_members))
| (ObjectType(expected_members), ClassType(_, actual_members)) =>
for expected_member in expected_members {
match lookup_member_type(actual_members, expected_member.name) {
Some(actual) =>
unify_for_substitution(
member_contract_type(expected_member.typ),
actual,
subs,
type_env,
diagnostics,
)
None => ()
}
}
_ => ()
}
}
///|
fn class_type_mismatch_message(
annotation_name : String,
expected : Type,
inferred : Type,
) -> String? {
match (expected, inferred) {
(ClassType(_, expected_members), ObjectType(actual_members)) => {
let mut message : String? = None
for expected_member in expected_members {
if message is None {
match lookup_member_type(actual_members, expected_member.name) {
Some(actual) =>
if !type_accepts(
member_contract_type(expected_member.typ),
actual,
) {
message = Some(
"type annotation \{annotation_name} member \{expected_member.name} expects \{render_type(expected_member.typ)}, got \{render_type(actual)}",
)
}
None =>
if !is_defaulted_member_type(expected_member.typ) {
message = Some(
"type annotation \{annotation_name} missing member \{expected_member.name}",
)
}
}
}
}
message
}
_ => None
}
}
///|
fn apply_type_annotation(
type_name : String?,
inferred : Type,
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Type {
match type_name {
None => inferred
Some(name) =>
match type_from_annotation(name, type_env) {
Some(expected) =>
if type_accepts(expected, inferred) {
// PKL-110: for class literals like `new Box { value = 5 }`,
// the expected ClassType still carries TypeVariable("T") in
// its members. Unify against the inferred ObjectType members
// and rewrite the returned ClassType so downstream field
// access (`b.value`) resolves to the concrete argument type.
let expected = substitute_class_type_variables(
expected, inferred, type_env, diagnostics,
)
match (expected, inferred) {
(NullableType(ObjectType([])), ObjectType(_)) =>
NullableType(inferred)
(ConstrainedType(_, inner), _) => inner
_ => expected
}
} else {
let message = match
class_type_mismatch_message(name, expected, inferred) {
Some(detail) => detail
None =>
"type annotation \{name} does not accept \{render_type(inferred)}"
}
diagnostics.push(diag(message))
UnknownType
}
None => {
diagnostics.push(diag("Cannot find type `\{name}`."))
UnknownType
}
}
}
}
///|
fn substitute_class_type_variables(
expected : Type,
inferred : Type,
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Type {
match expected {
ClassType(_, _) => {
let subs : Array[TypeSubstitution] = []
unify_for_substitution(expected, inferred, subs, type_env, diagnostics)
if subs.length() == 0 {
expected
} else {
substitute_type(expected, subs)
}
}
_ => expected
}
}
///|
fn common_type(types : Array[Type]) -> Type {
if types.length() == 0 {
return UnknownType
}
let first = types[0]
for typ in types {
if typ != first {
return make_union_type(types)
}
}
first
}
///|
fn lookup_mapping_value_type(entries : Array[TypeEntry], key : Type) -> Type? {
let mut found : Type? = None
for entry in entries {
if entry.key == key {
found = Some(entry.value)
}
}
found
}
///|
fn function_parameter_types(
parameters : Array[FunctionParameter],
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Array[Type] {
let types : Array[Type] = []
for parameter in parameters {
match parameter.type_name {
Some(type_name) =>
match parameter_type_from_annotation(type_name, type_env) {
Some(typ) => types.push(typ)
None => {
diagnostics.push(diag("Cannot find type `\{type_name}`."))
types.push(UnknownType)
}
}
None => types.push(UnknownType)
}
}
types
}
///|
fn function_return_type(
return_type_name : String?,
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Type {
match return_type_name {
Some(type_name) =>
match type_from_annotation(type_name, type_env) {
Some(typ) => typ
None => {
diagnostics.push(diag("Cannot find type `\{type_name}`."))
UnknownType
}
}
None => UnknownType
}
}
///|
fn function_signature_type(
function_decl : FunctionDecl,
type_env : Array[TypeBinding],
diagnostics : Array[Diagnostic],
) -> Type {
FunctionType(
function_parameter_types(function_decl.parameters, type_env, diagnostics),
function_return_type(function_decl.return_type_name, type_env, diagnostics),
)
}
///|
fn copy_type_bindings(bindings : Array[TypeBinding]) -> Array[TypeBinding] {
let copied : Array[TypeBinding] = []
for binding in bindings {
copied.push(binding)
}
copied
}
///|
fn qualify_imported_type(
import_name : String,
type_export : TypeExport,
) -> Type {
match type_export.typ {
ClassType(_, members) =>
ClassType("\{import_name}.\{type_export.name}", members)
_ => type_export.typ
}
}
///|
fn imported_type_bindings(
imports : Array[ImportDecl],
resolve_import_types : (String) -> Array[TypeExport]?,
) -> Array[TypeBinding] {
let bindings : Array[TypeBinding] = []
for decl in imports {
if !decl.is_glob {
match resolve_import_types(decl.uri) {
Some(exports) =>
for type_export in exports {
bindings.push({
name: "\{decl.import_name}.\{type_export.name}",
typ: qualify_imported_type(decl.import_name, type_export),
alias_decl: None,
bound: None,
})
}
None => ()
}
}
}
bindings
}