use crate::semantic::type_expr::TypeExpr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Compatible,
Incompatible,
Unknown,
}
impl Verdict {
pub fn is_incompatible(self) -> bool {
matches!(self, Self::Incompatible)
}
}
const PRIMITIVES: &[&str] = &[
"any", "array", "bool", "bytes", "datetime", "decimal", "duration", "file", "float",
"function", "geometry", "int", "none", "null", "number", "object", "point", "range", "record",
"regex", "set", "string", "uuid", "value",
];
fn is_primitive(name: &str) -> bool {
PRIMITIVES
.iter()
.any(|known| known.eq_ignore_ascii_case(name))
}
fn is_top(name: &str) -> bool {
name.eq_ignore_ascii_case("any") || name.eq_ignore_ascii_case("value")
}
fn is_nullish(name: &str) -> bool {
name.eq_ignore_ascii_case("none") || name.eq_ignore_ascii_case("null")
}
fn numeric_rank(name: &str) -> Option<u8> {
match name.to_ascii_lowercase().as_str() {
"int" => Some(0),
"float" => Some(1),
"decimal" => Some(2),
"number" => Some(3),
_ => None,
}
}
fn literal_family(raw: &str) -> Option<&'static str> {
let text = raw.trim();
let first = text.chars().next()?;
if matches!(first, '\'' | '"') {
return Some("string");
}
if text.eq_ignore_ascii_case("true") || text.eq_ignore_ascii_case("false") {
return Some("bool");
}
if first.is_ascii_digit() || matches!(first, '-' | '+') {
if text.ends_with(|ch: char| ch.is_ascii_alphabetic())
&& !text.ends_with("f")
&& !text.ends_with("dec")
{
return Some("duration");
}
if text.ends_with("dec") {
return Some("decimal");
}
if text.ends_with('f') || text.contains('.') {
return Some("float");
}
return Some("int");
}
None
}
fn widen(ty: &TypeExpr) -> Option<TypeExpr> {
match ty {
TypeExpr::Literal(raw) => {
literal_family(raw).map(|name| TypeExpr::Scalar(name.to_string()))
}
_ => None,
}
}
pub fn assignable(actual: &TypeExpr, expected: &TypeExpr) -> Verdict {
use TypeExpr::*;
if matches!(actual, Unknown | Other(_)) || matches!(expected, Unknown | Other(_)) {
return Verdict::Unknown;
}
if actual == expected {
return Verdict::Compatible;
}
if let Scalar(name) = actual
&& is_top(name)
{
return Verdict::Compatible;
}
if let Scalar(name) = expected
&& is_top(name)
{
return Verdict::Compatible;
}
if let Scalar(name) = actual
&& !is_primitive(name)
{
return Verdict::Unknown;
}
if let Scalar(name) = expected
&& !is_primitive(name)
{
return Verdict::Unknown;
}
if let Option(inner) = expected {
if let Scalar(name) = actual
&& is_nullish(name)
{
return Verdict::Compatible;
}
return assignable(actual, inner);
}
if matches!(actual, Option(_)) {
return Verdict::Unknown;
}
if let Scalar(name) = actual
&& is_nullish(name)
{
return Verdict::Unknown;
}
if let Union(members) = expected {
let verdicts: Vec<_> = members
.iter()
.map(|member| assignable(actual, member))
.collect();
if verdicts.contains(&Verdict::Compatible) {
return Verdict::Compatible;
}
return if verdicts.iter().all(|v| *v == Verdict::Incompatible) {
Verdict::Incompatible
} else {
Verdict::Unknown
};
}
if let Union(members) = actual {
let verdicts: Vec<_> = members
.iter()
.map(|member| assignable(member, expected))
.collect();
return if verdicts.iter().all(|v| *v == Verdict::Compatible) {
Verdict::Compatible
} else {
Verdict::Unknown
};
}
if let Some(widened) = widen(actual) {
return match assignable(&widened, expected) {
Verdict::Incompatible if matches!(expected, Literal(_)) => Verdict::Unknown,
verdict => verdict,
};
}
if matches!(expected, Literal(_)) {
return Verdict::Unknown;
}
match (actual, expected) {
(Record(from), Record(to)) => {
let untabled = from.is_empty() || to.is_empty();
if untabled || from.iter().any(|table| to.contains(table)) {
Verdict::Compatible
} else {
Verdict::Incompatible
}
}
(Array(_) | Tuple(_), Scalar(name)) if name.eq_ignore_ascii_case("array") => {
Verdict::Compatible
}
(Set(_), Scalar(name)) if name.eq_ignore_ascii_case("set") => Verdict::Compatible,
(Object(_), Scalar(name)) if name.eq_ignore_ascii_case("object") => Verdict::Compatible,
(Record(_), Scalar(name)) if name.eq_ignore_ascii_case("record") => Verdict::Compatible,
(Scalar(name), Array(_) | Tuple(_)) if name.eq_ignore_ascii_case("array") => {
Verdict::Unknown
}
(Scalar(name), Object(_)) if name.eq_ignore_ascii_case("object") => Verdict::Unknown,
(Scalar(name), Record(_)) if name.eq_ignore_ascii_case("record") => Verdict::Unknown,
(Array(from), Array(to)) | (Set(from), Set(to)) => match assignable(from, to) {
Verdict::Incompatible => Verdict::Incompatible,
_ => Verdict::Compatible,
},
(Tuple(from), Array(to)) => {
if from
.iter()
.any(|item| assignable(item, to).is_incompatible())
{
Verdict::Incompatible
} else {
Verdict::Compatible
}
}
(Array(_), Tuple(_)) => Verdict::Unknown,
(Tuple(from), Tuple(to)) => {
if from.len() != to.len() {
return Verdict::Incompatible;
}
if from
.iter()
.zip(to)
.any(|(a, b)| assignable(a, b).is_incompatible())
{
Verdict::Incompatible
} else {
Verdict::Compatible
}
}
(Object(from), Object(to)) => object_verdict(from, to),
(Scalar(from), Scalar(to)) => {
if from.eq_ignore_ascii_case(to) {
return Verdict::Compatible;
}
match (numeric_rank(from), numeric_rank(to)) {
(Some(a), Some(b)) => {
if a <= b {
Verdict::Compatible
} else {
Verdict::Unknown
}
}
_ if from.eq_ignore_ascii_case("string")
&& matches!(
to.to_ascii_lowercase().as_str(),
"datetime" | "duration" | "uuid" | "bytes" | "regex" | "file"
) =>
{
Verdict::Unknown
}
_ => Verdict::Incompatible,
}
}
(Record(_), Array(_) | Tuple(_) | Set(_) | Object(_))
| (Array(_) | Tuple(_) | Set(_) | Object(_), Record(_))
| (Object(_), Array(_) | Tuple(_) | Set(_))
| (Array(_) | Tuple(_) | Set(_), Object(_))
| (Set(_), Array(_) | Tuple(_))
| (Array(_) | Tuple(_), Set(_)) => Verdict::Incompatible,
(Scalar(_), Set(_) | Array(_) | Tuple(_)) => Verdict::Unknown,
(Set(_), Scalar(_)) | (Array(_) | Tuple(_), Scalar(_)) => Verdict::Incompatible,
(Scalar(_), Object(_)) | (Object(_), Scalar(_)) => Verdict::Incompatible,
(Scalar(_), Record(_)) | (Record(_), Scalar(_)) => Verdict::Incompatible,
_ => Verdict::Unknown,
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ObjectFault {
Property {
key: String,
expected: TypeExpr,
actual: TypeExpr,
},
Missing { key: String },
}
pub fn object_faults(
actual: &[(String, TypeExpr)],
expected: &[(String, TypeExpr)],
) -> Vec<ObjectFault> {
let mut faults = Vec::new();
for (key, want) in expected {
match actual.iter().find(|(name, _)| name == key) {
Some((_, have)) => {
if assignable(have, want).is_incompatible() {
faults.push(ObjectFault::Property {
key: key.clone(),
expected: want.clone(),
actual: have.clone(),
});
}
}
None if !matches!(want, TypeExpr::Option(_)) => {
faults.push(ObjectFault::Missing { key: key.clone() })
}
None => {}
}
}
faults
}
fn object_verdict(actual: &[(String, TypeExpr)], expected: &[(String, TypeExpr)]) -> Verdict {
if !object_faults(actual, expected).is_empty() {
return Verdict::Incompatible;
}
let uncertain = expected.iter().any(|(key, want)| {
actual
.iter()
.find(|(name, _)| name == key)
.is_some_and(|(_, have)| assignable(have, want) == Verdict::Unknown)
});
if uncertain {
Verdict::Unknown
} else {
Verdict::Compatible
}
}
#[cfg(test)]
mod tests {
use super::{ObjectFault, Verdict, assignable, object_faults};
use crate::semantic::type_expr::TypeExpr;
fn s(name: &str) -> TypeExpr {
TypeExpr::Scalar(name.to_string())
}
fn rec(tables: &[&str]) -> TypeExpr {
TypeExpr::Record(tables.iter().map(|t| t.to_string()).collect())
}
fn arr(inner: TypeExpr) -> TypeExpr {
TypeExpr::Array(Box::new(inner))
}
fn opt(inner: TypeExpr) -> TypeExpr {
TypeExpr::Option(Box::new(inner))
}
fn lit(raw: &str) -> TypeExpr {
TypeExpr::Literal(raw.to_string())
}
#[test]
fn reports_the_users_actual_mistake() {
assert_eq!(
assignable(&s("string"), &rec(&["user"])),
Verdict::Incompatible
);
assert_eq!(
assignable(&s("int"), &rec(&["user"])),
Verdict::Incompatible
);
}
#[test]
fn plain_primitive_mismatches_are_reported() {
for (from, to) in [
("string", "int"),
("int", "string"),
("bool", "string"),
("string", "bool"),
("object", "string"),
] {
assert_eq!(
assignable(&s(from), &s(to)),
Verdict::Incompatible,
"{from} -> {to}"
);
}
}
#[test]
fn identical_and_widening_numerics_are_compatible() {
assert_eq!(assignable(&s("string"), &s("string")), Verdict::Compatible);
assert_eq!(assignable(&s("int"), &s("number")), Verdict::Compatible);
assert_eq!(assignable(&s("int"), &s("decimal")), Verdict::Compatible);
assert_eq!(assignable(&s("float"), &s("decimal")), Verdict::Compatible);
assert_eq!(assignable(&s("number"), &s("int")), Verdict::Unknown);
assert_eq!(assignable(&s("decimal"), &s("float")), Verdict::Unknown);
}
#[test]
fn unknown_and_other_are_always_silent() {
for other in [TypeExpr::Unknown, TypeExpr::Other("weird<x>".into())] {
assert_eq!(assignable(&other, &s("int")), Verdict::Unknown);
assert_eq!(assignable(&s("int"), &other), Verdict::Unknown);
}
}
#[test]
fn any_and_value_accept_everything() {
for top in ["any", "value"] {
assert_eq!(assignable(&s("string"), &s(top)), Verdict::Compatible);
assert_eq!(assignable(&s(top), &rec(&["user"])), Verdict::Compatible);
}
}
#[test]
fn unrecognised_type_names_never_produce_a_mismatch() {
assert_eq!(assignable(&s("frobnicate"), &s("int")), Verdict::Unknown);
assert_eq!(assignable(&s("int"), &s("frobnicate")), Verdict::Unknown);
}
#[test]
fn option_handling_is_asymmetric_and_quiet() {
assert_eq!(
assignable(&s("string"), &opt(s("string"))),
Verdict::Compatible
);
assert_eq!(
assignable(&s("none"), &opt(s("string"))),
Verdict::Compatible
);
assert_eq!(
assignable(&s("int"), &opt(s("string"))),
Verdict::Incompatible
);
assert_eq!(
assignable(&opt(s("string")), &s("string")),
Verdict::Unknown
);
assert_eq!(assignable(&opt(s("string")), &s("int")), Verdict::Unknown);
assert_eq!(assignable(&s("none"), &s("string")), Verdict::Unknown);
}
#[test]
fn record_tables_must_be_disjoint_to_be_a_mismatch() {
assert_eq!(
assignable(&rec(&["user"]), &rec(&["user"])),
Verdict::Compatible
);
assert_eq!(
assignable(&rec(&["project"]), &rec(&["orderData", "project"])),
Verdict::Compatible
);
assert_eq!(assignable(&rec(&[]), &rec(&["user"])), Verdict::Compatible);
assert_eq!(assignable(&rec(&["user"]), &rec(&[])), Verdict::Compatible);
assert_eq!(
assignable(&rec(&["user"]), &rec(&["company"])),
Verdict::Incompatible
);
}
#[test]
fn literals_widen_to_their_family() {
assert_eq!(
assignable(&lit("'open'"), &s("string")),
Verdict::Compatible
);
assert_eq!(assignable(&lit("42"), &s("int")), Verdict::Compatible);
assert_eq!(assignable(&lit("42"), &s("number")), Verdict::Compatible);
assert_eq!(assignable(&lit("1h"), &s("duration")), Verdict::Compatible);
assert_eq!(assignable(&lit("'open'"), &s("int")), Verdict::Incompatible);
}
#[test]
fn widening_into_a_literal_union_stays_silent() {
let union = TypeExpr::Union(vec![lit("'Started'"), lit("'Cancelled'")]);
assert_eq!(assignable(&s("string"), &union), Verdict::Unknown);
assert_eq!(assignable(&lit("'Started'"), &union), Verdict::Compatible);
}
#[test]
fn unions_widen_the_expected_side_and_narrow_the_actual() {
let expected = TypeExpr::Union(vec![s("string"), s("int")]);
assert_eq!(assignable(&s("string"), &expected), Verdict::Compatible);
assert_eq!(assignable(&s("bool"), &expected), Verdict::Incompatible);
let actual = TypeExpr::Union(vec![s("string"), s("int")]);
assert_eq!(assignable(&actual, &s("string")), Verdict::Unknown);
assert_eq!(
assignable(&actual, &TypeExpr::Union(vec![s("string"), s("int")])),
Verdict::Compatible
);
}
#[test]
fn collections_compare_structurally() {
assert_eq!(
assignable(&arr(s("string")), &s("array")),
Verdict::Compatible
);
assert_eq!(
assignable(&arr(s("string")), &arr(s("string"))),
Verdict::Compatible
);
assert_eq!(
assignable(&arr(s("string")), &arr(s("int"))),
Verdict::Incompatible
);
assert_eq!(
assignable(&arr(s("any")), &arr(rec(&["user"]))),
Verdict::Compatible
);
assert_eq!(
assignable(&s("string"), &arr(s("string"))),
Verdict::Unknown
);
assert_eq!(
assignable(&arr(s("string")), &s("string")),
Verdict::Incompatible
);
}
#[test]
fn tuples_check_arity_and_elements() {
let pair = TypeExpr::Tuple(vec![s("string"), s("string")]);
assert_eq!(assignable(&pair, &pair.clone()), Verdict::Compatible);
assert_eq!(assignable(&pair, &arr(s("string"))), Verdict::Compatible);
assert_eq!(assignable(&pair, &arr(s("int"))), Verdict::Incompatible);
assert_eq!(
assignable(&pair, &TypeExpr::Tuple(vec![s("string")])),
Verdict::Incompatible
);
}
#[test]
fn object_shared_keys_are_compared() {
let actual = TypeExpr::Object(vec![("line".into(), s("int")), ("asset".into(), s("int"))]);
let expected = TypeExpr::Object(vec![
("line".into(), rec(&["orderLine"])),
("asset".into(), rec(&["asset"])),
]);
assert_eq!(assignable(&actual, &expected), Verdict::Incompatible);
assert_eq!(
object_faults(
&[("line".into(), s("int")), ("asset".into(), s("int"))],
&[
("line".into(), rec(&["orderLine"])),
("asset".into(), rec(&["asset"]))
]
),
vec![
ObjectFault::Property {
key: "line".into(),
expected: rec(&["orderLine"]),
actual: s("int"),
},
ObjectFault::Property {
key: "asset".into(),
expected: rec(&["asset"]),
actual: s("int"),
},
]
);
}
#[test]
fn object_missing_required_property_is_a_fault() {
let faults = object_faults(
&[("line".into(), rec(&["orderLine"]))],
&[
("line".into(), rec(&["orderLine"])),
("asset".into(), rec(&["asset"])),
],
);
assert_eq!(
faults,
vec![ObjectFault::Missing {
key: "asset".into()
}]
);
}
#[test]
fn object_optional_property_may_be_omitted() {
let faults = object_faults(
&[("line".into(), rec(&["orderLine"]))],
&[
("line".into(), rec(&["orderLine"])),
("note".into(), opt(s("string"))),
],
);
assert!(faults.is_empty(), "an option<T> property is not required");
}
#[test]
fn object_extra_properties_are_ignored() {
let faults = object_faults(
&[
("line".into(), rec(&["orderLine"])),
("extra".into(), s("string")),
],
&[("line".into(), rec(&["orderLine"]))],
);
assert!(faults.is_empty());
}
#[test]
fn object_with_undecidable_property_stays_silent() {
let actual = TypeExpr::Object(vec![("line".into(), TypeExpr::Unknown)]);
let expected = TypeExpr::Object(vec![("line".into(), rec(&["orderLine"]))]);
assert_eq!(assignable(&actual, &expected), Verdict::Unknown);
}
#[test]
fn object_still_fits_the_bare_object_primitive() {
let a = TypeExpr::Object(vec![("line".into(), rec(&["orderLine"]))]);
assert_eq!(assignable(&a, &s("object")), Verdict::Compatible);
}
#[test]
fn string_to_stringly_types_stays_silent() {
for to in ["datetime", "duration", "uuid", "bytes", "regex", "file"] {
assert_eq!(
assignable(&s("string"), &s(to)),
Verdict::Unknown,
"string -> {to} must not be reported"
);
}
}
}