use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tatara_lisp::binding_shapes::{
is_lambda_list_keyword, DEF_PREFIX, LAMBDA_HEADS, LET_HEADS, QUOTE_HEADS,
};
use tatara_lisp::{Atom, NumericAxis, NumericWidth, Span, Spanned, SpannedForm};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum StaticType {
Any,
Nil,
Bool,
Int,
Float,
Number,
Str,
Symbol,
Keyword,
List(Box<StaticType>),
Map(Box<StaticType>, Box<StaticType>),
Procedure,
Promise,
Error,
Foreign,
Width(NumericWidth),
Union(Vec<StaticType>),
Fn {
params: Vec<StaticType>,
ret: Box<StaticType>,
},
}
impl StaticType {
pub fn render(&self) -> String {
match self {
Self::Any => ":any".into(),
Self::Nil => ":nil".into(),
Self::Bool => ":bool".into(),
Self::Int => ":int".into(),
Self::Float => ":float".into(),
Self::Number => ":number".into(),
Self::Str => ":string".into(),
Self::Symbol => ":symbol".into(),
Self::Keyword => ":keyword".into(),
Self::List(t) => format!("(:list-of {})", t.render()),
Self::Map(k, v) => format!("(:map-of {} {})", k.render(), v.render()),
Self::Procedure => ":procedure".into(),
Self::Promise => ":promise".into(),
Self::Error => ":error".into(),
Self::Foreign => ":foreign".into(),
Self::Width(w) => format!(":{}", w.label()),
Self::Union(branches) => {
let parts: Vec<String> = branches.iter().map(Self::render).collect();
format!("(:union {})", parts.join(" "))
}
Self::Fn { params, ret } => {
let parts: Vec<String> = params.iter().map(Self::render).collect();
format!("(:fn ({}) -> {})", parts.join(" "), ret.render())
}
}
}
pub fn conforms_to(&self, expected: &StaticType) -> bool {
if matches!(self, Self::Any) || matches!(expected, Self::Any) {
return true;
}
if self.width_axis().is_some() || expected.width_axis().is_some() {
return self.erase_width().conforms_to(&expected.erase_width());
}
if matches!(self, Self::Int) && matches!(expected, Self::Float) {
return true;
}
if matches!(expected, Self::Number) && matches!(self, Self::Int | Self::Float) {
return true;
}
if matches!(self, Self::Number) && matches!(expected, Self::Int | Self::Float) {
return true;
}
if let Self::Union(branches) = expected {
return branches.iter().any(|b| self.conforms_to(b));
}
if let Self::Union(branches) = self {
return branches.iter().all(|b| b.conforms_to(expected));
}
if matches!(self, Self::Fn { .. }) && matches!(expected, Self::Procedure)
|| matches!(self, Self::Procedure) && matches!(expected, Self::Fn { .. })
{
return true;
}
match (self, expected) {
(Self::List(a), Self::List(b)) => a.conforms_to(b),
(Self::Map(ak, av), Self::Map(bk, bv)) => ak.conforms_to(bk) && av.conforms_to(bv),
(
Self::Fn {
params: ap,
ret: ar,
},
Self::Fn {
params: bp,
ret: br,
},
) => {
ap.len() == bp.len()
&& ap.iter().zip(bp).all(|(a, b)| a.conforms_to(b))
&& ar.conforms_to(br)
}
_ => self == expected,
}
}
fn width_axis(&self) -> Option<NumericAxis> {
match self {
Self::Width(w) => Some(w.axis()),
_ => None,
}
}
fn erase_width(&self) -> Self {
match self.width_axis() {
Some(axis) => Self::of_axis(axis),
None => self.clone(),
}
}
fn of_axis(axis: NumericAxis) -> Self {
match axis {
NumericAxis::Int => Self::Int,
NumericAxis::Float => Self::Float,
}
}
pub fn from_spanned(form: &Spanned) -> Option<Self> {
match &form.form {
SpannedForm::Atom(Atom::Keyword(k)) => {
if let Some(width) = crate::type_check::numeric_width_of(k.as_str()) {
return Some(if crate::type_check::is_width_alias(k.as_str()) {
Self::of_axis(width.axis())
} else {
Self::Width(width)
});
}
Some(match k.as_str() {
"any" => Self::Any,
"nil" => Self::Nil,
"bool" => Self::Bool,
"number" => Self::Number,
"string" => Self::Str,
"symbol" => Self::Symbol,
"keyword" => Self::Keyword,
"procedure" | "fn" => Self::Procedure,
"promise" => Self::Promise,
"error" => Self::Error,
"foreign" => Self::Foreign,
"list" => Self::List(Box::new(Self::Any)),
"map" => Self::Map(Box::new(Self::Any), Box::new(Self::Any)),
_ => return None,
})
}
SpannedForm::List(items) if !items.is_empty() => {
let head = items[0].as_keyword()?;
match head {
"list-of" if items.len() == 2 => {
Some(Self::List(Box::new(Self::from_spanned(&items[1])?)))
}
"map-of" if items.len() == 3 => Some(Self::Map(
Box::new(Self::from_spanned(&items[1])?),
Box::new(Self::from_spanned(&items[2])?),
)),
"union" => {
let mut branches = Vec::with_capacity(items.len() - 1);
for it in &items[1..] {
branches.push(Self::from_spanned(it)?);
}
Some(Self::Union(branches))
}
"fn" => Some(Self::Procedure),
_ => None,
}
}
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct TypeDiagnostic {
pub span: Span,
pub kind: TypeDiagnosticKind,
}
#[derive(Debug, Clone)]
pub enum TypeDiagnosticKind {
Mismatch {
expected: StaticType,
got: StaticType,
context: String,
},
BadTypeSpec(String),
Arity {
expected: usize,
got: usize,
context: String,
},
}
impl TypeDiagnostic {
pub fn render(&self, src: &str) -> String {
let (line, col) = Span::line_col(src, self.span.start);
let head = format!("type:{}", line);
match &self.kind {
TypeDiagnosticKind::Mismatch {
expected,
got,
context,
} => format!(
"{head}:{col}: type mismatch in {context}: expected {}, got {}",
expected.render(),
got.render()
),
TypeDiagnosticKind::BadTypeSpec(msg) => {
format!("{head}:{col}: bad type spec — {msg}")
}
TypeDiagnosticKind::Arity {
expected,
got,
context,
} => format!("{head}:{col}: arity mismatch in {context}: expected {expected} argument(s), got {got}"),
}
}
}
pub fn check_program(forms: &[Spanned]) -> Vec<TypeDiagnostic> {
let mut env = TypeEnv::default();
for form in forms {
if let SpannedForm::List(items) = &form.form {
if let Some((name, arrow)) = definition_signature(items) {
env.define(name, arrow);
}
}
}
let mut diags = Vec::new();
for form in forms {
check_form(form, &mut env, &mut diags);
}
diags
}
#[derive(Default)]
struct TypeEnv {
bindings: HashMap<Arc<str>, StaticType>,
scopes: Vec<HashSet<Arc<str>>>,
}
impl TypeEnv {
fn lookup(&self, name: &str) -> StaticType {
if self.scopes.iter().any(|s| s.contains(name)) {
return StaticType::Any;
}
self.bindings.get(name).cloned().unwrap_or(StaticType::Any)
}
fn define(&mut self, name: impl Into<Arc<str>>, ty: StaticType) {
self.bindings.insert(name.into(), ty);
}
fn push_scope(&mut self, names: impl IntoIterator<Item = Arc<str>>) {
self.scopes.push(names.into_iter().collect());
}
fn pop_scope(&mut self) {
self.scopes.pop();
}
}
fn check_form(form: &Spanned, env: &mut TypeEnv, diags: &mut Vec<TypeDiagnostic>) {
let SpannedForm::List(items) = &form.form else {
return;
};
if let Some(head) = items.first().and_then(Spanned::as_symbol) {
match head {
"the" if items.len() == 3 => {
check_the(&items[1], &items[2], env, diags);
return;
}
"declare" if items.len() == 3 => {
check_declare(&items[1], &items[2], env, diags);
return;
}
"define" if items.len() >= 3 => {
check_define(items, env, diags);
return;
}
h if QUOTE_HEADS.contains(&h) => return,
h if LAMBDA_HEADS.contains(&h) => {
check_lambda(items, env, diags);
return;
}
h if LET_HEADS.contains(&h) => {
check_let(items, env, diags);
return;
}
h if h.starts_with(DEF_PREFIX) => {
check_def_family(items, env, diags);
return;
}
_ => {}
}
}
check_application_arity(items, env, diags);
for item in items {
check_form(item, env, diags);
}
}
fn check_application_arity(items: &[Spanned], env: &TypeEnv, diags: &mut Vec<TypeDiagnostic>) {
let Some(head) = items.first().and_then(Spanned::as_symbol) else {
return;
};
let StaticType::Fn { params, .. } = env.lookup(head) else {
return;
};
let got = items.len() - 1;
if got != params.len() {
diags.push(TypeDiagnostic {
span: items[0].span,
kind: TypeDiagnosticKind::Arity {
expected: params.len(),
got,
context: format!("call to {head}"),
},
});
}
}
fn check_lambda(items: &[Spanned], env: &mut TypeEnv, diags: &mut Vec<TypeDiagnostic>) {
env.push_scope(param_names(items.get(1)));
for item in items.iter().skip(2) {
check_form(item, env, diags);
}
env.pop_scope();
}
fn check_let(items: &[Spanned], env: &mut TypeEnv, diags: &mut Vec<TypeDiagnostic>) {
let bindings = items.get(1);
env.push_scope(let_binding_names(bindings));
if let Some(SpannedForm::List(pairs)) = bindings.map(|b| &b.form) {
for pair in pairs {
if let SpannedForm::List(kv) = &pair.form {
for init in kv.iter().skip(1) {
check_form(init, env, diags);
}
}
}
}
for item in items.iter().skip(2) {
check_form(item, env, diags);
}
env.pop_scope();
}
fn check_def_family(items: &[Spanned], env: &mut TypeEnv, diags: &mut Vec<TypeDiagnostic>) {
let mut inner: Vec<Arc<str>> = Vec::new();
if let Some(SpannedForm::List(sig)) = items.get(1).map(|t| &t.form) {
inner.extend(symbol_names(sig).into_iter().skip(1));
}
let mut body_start = 2;
if let Some(SpannedForm::List(sig)) = items.get(2).map(|p| &p.form) {
let names = symbol_names(sig);
if !sig.is_empty() && names.len() == sig.len() {
inner.extend(names);
body_start = 3;
}
}
env.push_scope(inner);
for item in items.iter().skip(body_start) {
check_form(item, env, diags);
}
env.pop_scope();
}
fn check_the(
type_form: &Spanned,
expr: &Spanned,
env: &mut TypeEnv,
diags: &mut Vec<TypeDiagnostic>,
) {
let Some(expected) = StaticType::from_spanned(type_form) else {
diags.push(TypeDiagnostic {
span: type_form.span,
kind: TypeDiagnosticKind::BadTypeSpec(format!(
"unrecognized type spec: {}",
render_form_brief(type_form)
)),
});
return;
};
let got = infer(expr, env);
if !got.conforms_to(&expected) {
diags.push(TypeDiagnostic {
span: expr.span,
kind: TypeDiagnosticKind::Mismatch {
expected,
got,
context: "the-form".into(),
},
});
}
check_form(expr, env, diags);
}
fn check_declare(
name_form: &Spanned,
type_form: &Spanned,
env: &mut TypeEnv,
diags: &mut Vec<TypeDiagnostic>,
) {
let Some(name) = name_form.as_symbol() else {
diags.push(TypeDiagnostic {
span: name_form.span,
kind: TypeDiagnosticKind::BadTypeSpec("declare: name must be a symbol".into()),
});
return;
};
let Some(ty) = StaticType::from_spanned(type_form) else {
diags.push(TypeDiagnostic {
span: type_form.span,
kind: TypeDiagnosticKind::BadTypeSpec(format!(
"unrecognized type spec: {}",
render_form_brief(type_form)
)),
});
return;
};
env.define(name, ty);
}
fn check_define(items: &[Spanned], env: &mut TypeEnv, diags: &mut Vec<TypeDiagnostic>) {
match &items[1].form {
SpannedForm::Atom(Atom::Symbol(name)) => {
let expected = env.lookup(name).clone();
let got = infer(&items[2], env);
if !got.conforms_to(&expected) {
diags.push(TypeDiagnostic {
span: items[2].span,
kind: TypeDiagnosticKind::Mismatch {
expected,
got: got.clone(),
context: format!("define {name}"),
},
});
}
env.define(name.as_str(), got);
check_form(&items[2], env, diags);
}
SpannedForm::List(sig) if !sig.is_empty() => {
if let Some(name) = sig[0].as_symbol() {
env.define(name, signature_arrow(&sig[1..]));
}
env.push_scope(symbol_names(&sig[1..]));
for body_form in &items[2..] {
check_form(body_form, env, diags);
}
env.pop_scope();
}
_ => {}
}
}
fn signature_arrow(params: &[Spanned]) -> StaticType {
let mut count = 0usize;
for p in params {
match p.as_symbol() {
Some(name) if !is_lambda_list_keyword(name) => count += 1,
_ => return StaticType::Procedure,
}
}
StaticType::Fn {
params: vec![StaticType::Any; count],
ret: Box::new(StaticType::Any),
}
}
fn definition_signature(items: &[Spanned]) -> Option<(Arc<str>, StaticType)> {
if items.first().and_then(Spanned::as_symbol) != Some("define") || items.len() < 3 {
return None;
}
match &items[1].form {
SpannedForm::List(sig) if !sig.is_empty() => {
let name = sig[0].as_symbol()?;
Some((Arc::from(name), signature_arrow(&sig[1..])))
}
SpannedForm::Atom(Atom::Symbol(name)) => match lambda_arrow(&items[2]) {
StaticType::Any => None,
arrow => Some((Arc::from(name.as_str()), arrow)),
},
_ => None,
}
}
fn lambda_arrow(form: &Spanned) -> StaticType {
let SpannedForm::List(items) = &form.form else {
return StaticType::Any;
};
let Some(head) = items.first().and_then(Spanned::as_symbol) else {
return StaticType::Any;
};
if !LAMBDA_HEADS.contains(&head) {
return StaticType::Any;
}
match items.get(1).map(|p| &p.form) {
Some(SpannedForm::List(sig)) => signature_arrow(sig),
Some(SpannedForm::Atom(Atom::Symbol(_))) => StaticType::Procedure,
Some(SpannedForm::Nil) => StaticType::Fn {
params: Vec::new(),
ret: Box::new(StaticType::Any),
},
_ => StaticType::Any,
}
}
fn symbol_names(items: &[Spanned]) -> Vec<Arc<str>> {
items
.iter()
.filter_map(|i| match &i.form {
SpannedForm::Atom(Atom::Symbol(s)) => Some(Arc::from(s.as_str())),
_ => None,
})
.collect()
}
fn param_names(form: Option<&Spanned>) -> Vec<Arc<str>> {
match form.map(|p| &p.form) {
Some(SpannedForm::List(sig)) => symbol_names(sig),
Some(SpannedForm::Atom(Atom::Symbol(s))) => vec![Arc::from(s.as_str())],
_ => Vec::new(),
}
}
fn let_binding_names(form: Option<&Spanned>) -> Vec<Arc<str>> {
let Some(SpannedForm::List(bindings)) = form.map(|b| &b.form) else {
return Vec::new();
};
bindings
.iter()
.filter_map(|b| match &b.form {
SpannedForm::List(pair) => match pair.first().map(|p| &p.form) {
Some(SpannedForm::Atom(Atom::Symbol(s))) => Some(Arc::from(s.as_str())),
_ => None,
},
SpannedForm::Atom(Atom::Symbol(s)) => Some(Arc::from(s.as_str())),
_ => None,
})
.collect()
}
fn infer(form: &Spanned, env: &TypeEnv) -> StaticType {
match &form.form {
SpannedForm::Nil => StaticType::Nil,
SpannedForm::Atom(a) => match a {
Atom::Bool(_) => StaticType::Bool,
Atom::Int(_) => StaticType::Int,
Atom::Float(_) => StaticType::Float,
Atom::Str(_) => StaticType::Str,
Atom::Keyword(_) => StaticType::Keyword,
Atom::Symbol(s) => env.lookup(s),
},
SpannedForm::List(items) if !items.is_empty() => {
if let Some(head) = items[0].as_symbol() {
if head == "the" && items.len() == 3 {
return StaticType::from_spanned(&items[1]).unwrap_or(StaticType::Any);
}
if head == "quote" {
return infer_quoted(&items[1]);
}
if head == "list" {
return infer_list_ctor(&items[1..], env);
}
if head == "begin" {
return match items.last() {
Some(last) if items.len() > 1 => infer(last, env),
_ => StaticType::Nil,
};
}
if LAMBDA_HEADS.contains(&head) {
let arrow = lambda_arrow(form);
if !matches!(arrow, StaticType::Any) {
return arrow;
}
}
if let StaticType::Fn { ret, .. } = env.lookup(head) {
return *ret;
}
if let Some(t) = primitive_return_type(head) {
return t;
}
}
StaticType::Any
}
SpannedForm::Quote(inner) => infer_quoted(inner),
SpannedForm::Quasiquote(_) | SpannedForm::Unquote(_) | SpannedForm::UnquoteSplice(_) => {
StaticType::Any
}
_ => StaticType::Any,
}
}
fn infer_quoted(form: &Spanned) -> StaticType {
match &form.form {
SpannedForm::Atom(Atom::Symbol(_)) => StaticType::Symbol,
SpannedForm::Atom(Atom::Keyword(_)) => StaticType::Keyword,
SpannedForm::Atom(Atom::Str(_)) => StaticType::Str,
SpannedForm::Atom(Atom::Int(_)) => StaticType::Int,
SpannedForm::Atom(Atom::Float(_)) => StaticType::Float,
SpannedForm::Atom(Atom::Bool(_)) => StaticType::Bool,
SpannedForm::Nil => StaticType::Nil,
SpannedForm::List(_) => StaticType::List(Box::new(StaticType::Any)),
_ => StaticType::Any,
}
}
fn infer_list_ctor(args: &[Spanned], env: &TypeEnv) -> StaticType {
if args.is_empty() {
return StaticType::List(Box::new(StaticType::Any));
}
let mut element = infer(&args[0], env);
for arg in &args[1..] {
let next = infer(arg, env);
element = least_upper_bound(element, next);
if matches!(element, StaticType::Any) {
break;
}
}
StaticType::List(Box::new(element))
}
fn least_upper_bound(a: StaticType, b: StaticType) -> StaticType {
if a == b {
return a;
}
if matches!(a, StaticType::Any) || matches!(b, StaticType::Any) {
return StaticType::Any;
}
if matches!(
(&a, &b),
(StaticType::Int, StaticType::Float) | (StaticType::Float, StaticType::Int)
) {
return StaticType::Number;
}
StaticType::Union(vec![a, b])
}
fn primitive_return_type(name: &str) -> Option<StaticType> {
Some(match name {
"+" | "-" | "*" | "/" | "abs" | "min" | "max" | "modulo" | "expt" | "sqrt" | "floor"
| "ceiling" | "round" | "truncate" | "gcd" | "lcm" | "sin" | "cos" | "tan" | "log"
| "exp" | "inc" | "dec" => StaticType::Number,
"=" | "<" | ">" | "<=" | ">=" | "not=" | "null?" | "pair?" | "list?" | "symbol?"
| "string?" | "integer?" | "number?" | "boolean?" | "procedure?" | "foreign?" | "atom?"
| "keyword?" | "even?" | "odd?" | "zero?" | "positive?" | "negative?" | "empty?"
| "not-empty?" | "any?" | "every?" | "member?" | "is?" | "hash-map?"
| "hash-map-empty?" | "hash-map-has?" | "chan?" | "chan-closed?" | "promise?"
| "error?" => StaticType::Bool,
"list" | "cons" | "reverse" | "append" | "take" | "drop" | "range" | "map" | "filter"
| "remove" | "concat" | "distinct" | "flatten" | "zip" | "partition" | "scan-left"
| "iterate" | "repeatedly" | "drain!" | "hash-map-keys" | "hash-map-values"
| "hash-map-entries" | "read-all" => StaticType::List(Box::new(StaticType::Any)),
"hash-map" | "hash-map-set" | "hash-map-remove" | "hash-map-merge" | "hash-map-update" => {
StaticType::Map(Box::new(StaticType::Any), Box::new(StaticType::Any))
}
"string-append" | "string" | "pr-str" | "symbol->string" | "keyword->string"
| "error-message" => StaticType::Str,
"length" | "count-if" | "find-index" | "position" | "compare" | "string-length"
| "hash-map-count" | "chan-len" => StaticType::Int,
"type-of" | "error-tag" => StaticType::Keyword,
_ => return None,
})
}
fn render_form_brief(form: &Spanned) -> String {
match &form.form {
SpannedForm::Atom(Atom::Symbol(s)) => s.to_string(),
SpannedForm::Atom(Atom::Keyword(k)) => format!(":{k}"),
SpannedForm::Atom(Atom::Str(s)) => format!("{s:?}"),
SpannedForm::Atom(Atom::Int(n)) => n.to_string(),
SpannedForm::Atom(Atom::Float(n)) => n.to_string(),
SpannedForm::Atom(Atom::Bool(b)) => if *b { "#t" } else { "#f" }.into(),
SpannedForm::Nil => "()".into(),
SpannedForm::List(_) => "(...)".into(),
_ => "?".into(),
}
}
#[derive(Debug, Clone)]
pub struct ExpansionFailure {
pub span: Span,
pub message: String,
}
impl ExpansionFailure {
pub fn render(&self, src: &str) -> String {
let (line, col) = Span::line_col(src, self.span.start);
format!(
"expand:{line}:{col}: macro expansion failed, checking the unexpanded form — {}",
self.message
)
}
}
#[derive(Debug, Clone, Default)]
pub struct ExpandedCheck {
pub diagnostics: Vec<TypeDiagnostic>,
pub expansion_failures: Vec<ExpansionFailure>,
}
pub struct BuildExpander {
base: crate::Interpreter<()>,
}
impl BuildExpander {
pub const DENIAL_REASON: &'static str =
"build-time macro expansion (tatara typecheck --expand) must not read the filesystem \
or the network; re-run the program itself if it genuinely needs this module";
#[must_use]
pub fn new() -> Self {
let mut base: crate::Interpreter<()> = crate::Interpreter::new();
base.set_loader(Arc::new(crate::DenyingLoader::new(Self::DENIAL_REASON)));
let mut host = ();
crate::install_full_stdlib_with(&mut base, &mut host);
Self { base }
}
#[must_use]
pub fn fork_interpreter(&self) -> crate::Interpreter<()> {
self.base.fork()
}
#[must_use]
pub fn expand(&self, forms: &[Spanned]) -> (Vec<Spanned>, Vec<ExpansionFailure>) {
let mut interp = self.base.fork();
let mut host = ();
let mut registered: Vec<bool> = Vec::with_capacity(forms.len());
for form in forms {
registered.push(
interp
.expander_mut()
.try_register_macro(form)
.unwrap_or(false),
);
}
let mut out = Vec::with_capacity(forms.len());
let mut failures = Vec::new();
for (form, was_macro_def) in forms.iter().zip(registered) {
if was_macro_def {
out.push(form.clone());
continue;
}
match interp.fully_expand(form, &mut host) {
Ok(expanded) => out.push(expanded),
Err(e) => {
failures.push(ExpansionFailure {
span: form.span,
message: format!("{e}"),
});
out.push(form.clone());
}
}
}
(out, failures)
}
#[must_use]
pub fn check(&self, forms: &[Spanned]) -> ExpandedCheck {
let (expanded, expansion_failures) = self.expand(forms);
ExpandedCheck {
diagnostics: check_program(&expanded),
expansion_failures,
}
}
}
impl Default for BuildExpander {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn check_program_expanded(forms: &[Spanned]) -> ExpandedCheck {
BuildExpander::new().check(forms)
}
#[cfg(test)]
mod tests {
use super::*;
use tatara_lisp::read_spanned;
fn check(src: &str) -> Vec<TypeDiagnostic> {
let forms = read_spanned(src).unwrap();
check_program(&forms)
}
#[test]
fn no_annotations_no_diagnostics() {
assert!(check("(define x 42) (+ 1 2)").is_empty());
}
#[test]
fn the_with_correct_atom_passes() {
assert!(check("(the :int 42)").is_empty());
assert!(check("(the :string \"hi\")").is_empty());
assert!(check("(the :bool #t)").is_empty());
}
#[test]
fn the_with_wrong_atom_flags() {
let diags = check("(the :int \"oops\")");
assert_eq!(diags.len(), 1);
match &diags[0].kind {
TypeDiagnosticKind::Mismatch { expected, got, .. } => {
assert!(matches!(expected, StaticType::Int));
assert!(matches!(got, StaticType::Str));
}
other => panic!("{other:?}"),
}
}
#[test]
fn declare_then_define_match_passes() {
assert!(check("(declare counter :int) (define counter 0)").is_empty());
}
#[test]
fn declare_then_define_mismatch_flags() {
let diags = check("(declare counter :int) (define counter \"oops\")");
assert_eq!(diags.len(), 1);
match &diags[0].kind {
TypeDiagnosticKind::Mismatch { expected, .. } => {
assert!(matches!(expected, StaticType::Int));
}
other => panic!("{other:?}"),
}
}
#[test]
fn list_ctor_infers_homogeneous_element_type() {
assert!(check("(the (:list-of :int) (list 1 2 3))").is_empty());
}
#[test]
fn list_ctor_heterogeneous_widens_to_any_or_union() {
let diags = check("(the (:list-of :int) (list 1 \"x\" 3))");
assert_eq!(diags.len(), 1);
}
#[test]
fn bad_type_spec_diagnoses() {
let diags = check("(the :nonsense 1)");
assert_eq!(diags.len(), 1);
assert!(matches!(diags[0].kind, TypeDiagnosticKind::BadTypeSpec(_)));
}
#[test]
fn primitive_return_type_drives_inference() {
let diags = check("(the :int (string-append \"a\" \"b\"))");
assert_eq!(diags.len(), 1);
}
#[test]
fn arithmetic_returns_number_so_conforms_to_int_or_float() {
assert!(check("(the :int (+ 1 2))").is_empty());
assert!(check("(the :float (+ 1.0 2.0))").is_empty());
}
#[test]
fn union_type_admits_any_branch() {
assert!(check("(the (:union :int :string) 42)").is_empty());
assert!(check("(the (:union :int :string) \"hi\")").is_empty());
let diags = check("(the (:union :int :string) #t)");
assert_eq!(diags.len(), 1);
}
#[test]
fn nested_list_inference() {
assert!(check("(the (:list-of (:list-of :int)) (list (list 1 2) (list 3)))").is_empty());
}
#[test]
fn conforms_to_total_for_any() {
assert!(StaticType::Any.conforms_to(&StaticType::Int));
assert!(StaticType::Int.conforms_to(&StaticType::Any));
assert!(
StaticType::Union(vec![StaticType::Int, StaticType::Str]).conforms_to(&StaticType::Any)
);
}
fn arities(src: &str) -> Vec<(usize, usize)> {
check(src)
.into_iter()
.filter_map(|d| match d.kind {
TypeDiagnosticKind::Arity { expected, got, .. } => Some((expected, got)),
_ => None,
})
.collect()
}
#[test]
fn wrong_arity_call_is_flagged() {
assert_eq!(
arities("(define (f a b c) a) (f 1 2)"),
vec![(3, 2)],
"too few arguments must report"
);
assert_eq!(
arities("(define (f a b c) a) (f 1 2 3 4)"),
vec![(3, 4)],
"too many arguments must report"
);
assert_eq!(
arities("(define (f) 1) (f 1)"),
vec![(0, 1)],
"a zero-argument function called with one must report"
);
}
#[test]
fn correct_arity_call_with_unannotated_args_is_not_flagged() {
assert!(arities("(define (f a b c) a) (f 1 \"two\" (list 3))").is_empty());
assert!(arities("(define (greet name) name) (greet \"world\")").is_empty());
assert!(arities("(define (nullary) 42) (nullary)").is_empty());
}
#[test]
fn arity_diagnostic_renders_with_the_call_site_position() {
let src = "(define (f a b) a)\n(f 1)";
let diags = check(src);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].render(src),
"type:2:2: arity mismatch in call to f: expected 2 argument(s), got 1"
);
}
#[test]
fn forward_and_mutually_recursive_calls_are_still_checked() {
assert_eq!(
arities("(define (g) (f 1)) (define (f a b) a)"),
vec![(2, 1)]
);
assert!(arities("(define (g) (f 1 2)) (define (f a b) a)").is_empty());
}
#[test]
fn a_variadic_signature_claims_no_arity() {
assert!(arities("(define (f a &rest xs) a) (f 1)").is_empty());
assert!(arities("(define (f a &rest xs) a) (f 1 2 3 4 5)").is_empty());
assert_eq!(arities("(define (f a xs) a) (f 1 2 3 4 5)"), vec![(2, 5)]);
}
#[test]
fn a_parameter_shadows_a_top_level_function_of_the_same_name() {
assert!(arities("(define (f a b) a) (define (twice f x) (f (f x)))").is_empty());
assert!(arities("(define (f a b) a) (define (g) (lambda (f) (f 1)))").is_empty());
assert!(arities("(define (f a b) a) (define (g h) (let ((f h)) (f 1)))").is_empty());
assert_eq!(
arities("(define (f a b) a) (define (g) (f 1))"),
vec![(2, 1)]
);
}
#[test]
fn quoted_data_is_not_a_call() {
assert!(arities("(define (f a b) a) (quote (f 1 2 3))").is_empty());
assert!(arities("(define (f a b) a) '(f 1 2 3)").is_empty());
}
#[test]
fn an_unknown_head_is_never_arity_checked() {
assert!(arities("(string-append \"a\" \"b\" \"c\")").is_empty());
assert!(arities("(some-macro a b c d e)").is_empty());
}
#[test]
fn a_lambda_bound_by_name_carries_its_arity() {
assert_eq!(arities("(define f (lambda (a b) a)) (f 1)"), vec![(2, 1)]);
assert!(arities("(define f (lambda (a b) a)) (f 1 2)").is_empty());
}
#[test]
fn nested_calls_inside_a_body_are_checked() {
assert_eq!(
arities("(define (f a b) a) (define (g x) (if x (f 1) (f 1 2)))"),
vec![(2, 1)]
);
}
#[test]
fn arity_check_survives_a_defmacro_body() {
assert!(arities("(define (f a b) a) (defmacro m (f x) `(,f ,x))").is_empty());
}
#[test]
fn a_definition_infers_as_its_arrow_and_a_call_as_the_return_type() {
let arrow = StaticType::Fn {
params: vec![StaticType::Any, StaticType::Any],
ret: Box::new(StaticType::Any),
};
assert_eq!(arrow.render(), "(:fn (:any :any) -> :any)");
assert!(arrow.conforms_to(&StaticType::Procedure));
assert!(StaticType::Procedure.conforms_to(&arrow));
assert!(check("(define (f a b) a) (the :procedure f)").is_empty());
assert!(check("(define (f a b) a) (the (:fn (:int :int) -> :int) f)").is_empty());
}
#[test]
fn arity_is_independent_of_inference_falling_to_any() {
let diags = check("(define (f a b) a) (f (some-unknown-thing) )");
assert_eq!(diags.len(), 1);
assert!(matches!(diags[0].kind, TypeDiagnosticKind::Arity { .. }));
}
#[test]
fn render_round_trips_canonical_forms() {
assert_eq!(StaticType::Int.render(), ":int");
assert_eq!(
StaticType::List(Box::new(StaticType::Str)).render(),
"(:list-of :string)"
);
assert_eq!(
StaticType::Map(Box::new(StaticType::Keyword), Box::new(StaticType::Int)).render(),
"(:map-of :keyword :int)"
);
assert_eq!(
StaticType::Union(vec![StaticType::Int, StaticType::Str]).render(),
"(:union :int :string)"
);
}
#[test]
fn the_empty_list_does_not_panic_the_arity_walker() {
assert!(check("()").is_empty());
assert!(check("(define (f a) a) (f ())").is_empty());
assert!(check("(define xs (list () ()))").is_empty());
assert_eq!(check("(define (f a) a) (f () ())").len(), 1);
}
#[test]
fn begin_infers_its_last_form() {
assert!(check("(the :int (begin 1 2 3))").is_empty());
assert!(check("(the :string (begin 1 2 \"s\"))").is_empty());
let diags = check("(the :string (begin 1 2 3))");
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn an_empty_begin_is_nil() {
assert!(check("(the :nil (begin))").is_empty());
assert_eq!(check("(the :int (begin))").len(), 1);
}
fn check_expanded(exp: &BuildExpander, src: &str) -> ExpandedCheck {
exp.check(&read_spanned(src).unwrap())
}
#[test]
fn defn_typed_return_mismatch_is_invisible_without_expansion() {
assert!(check("(defn-typed wrong ((n :int)) -> :string (* n 2))").is_empty());
}
#[test]
fn defn_typed_return_mismatch_is_caught_after_expansion() {
let exp = BuildExpander::new();
let out = check_expanded(&exp, "(defn-typed wrong ((n :int)) -> :string (* n 2))");
assert!(out.expansion_failures.is_empty());
assert_eq!(out.diagnostics.len(), 1, "{:?}", out.diagnostics);
match &out.diagnostics[0].kind {
TypeDiagnosticKind::Mismatch { expected, got, .. } => {
assert_eq!(expected.render(), ":string");
assert_eq!(got.render(), ":number");
}
other => panic!("expected a mismatch, got {other:?}"),
}
}
#[test]
fn a_correct_defn_typed_stays_clean_after_expansion() {
let exp = BuildExpander::new();
let out = check_expanded(&exp, "(defn-typed double-it ((n :int)) -> :int (* n 2))");
assert!(out.expansion_failures.is_empty());
assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics);
}
#[test]
fn expansion_makes_a_defn_typed_signature_arity_checkable() {
let src = "(defn-typed double-it ((n :int)) -> :int (* n 2))
(double-it 1 2 3)";
assert!(check(src).is_empty());
let out = check_expanded(&BuildExpander::new(), src);
assert!(
out.diagnostics.iter().any(|d| matches!(
d.kind,
TypeDiagnosticKind::Arity {
expected: 1,
got: 3,
..
}
)),
"{:?}",
out.diagnostics
);
}
#[test]
fn a_user_macro_body_runs_and_its_output_is_checked() {
let src = "(defmacro claim-int (x) `(the :int ,x))
(claim-int \"not an int\")";
assert!(check(src).is_empty());
let out = check_expanded(&BuildExpander::new(), src);
assert_eq!(out.diagnostics.len(), 1, "{:?}", out.diagnostics);
assert!(matches!(
out.diagnostics[0].kind,
TypeDiagnosticKind::Mismatch { .. }
));
}
#[test]
fn a_macro_used_above_its_definition_still_expands() {
let src = "(claim-int \"not an int\")
(defmacro claim-int (x) `(the :int ,x))";
let out = check_expanded(&BuildExpander::new(), src);
assert_eq!(out.diagnostics.len(), 1, "{:?}", out.diagnostics);
}
#[test]
fn an_unexpandable_form_is_kept_and_the_failure_reported() {
let src = "(defn-typed broken ((n :int)) :int (* n 2))
(the :int \"caught anyway\")";
let out = check_expanded(&BuildExpander::new(), src);
assert_eq!(
out.expansion_failures.len(),
1,
"{:?}",
out.expansion_failures
);
assert!(out.expansion_failures[0]
.render(src)
.contains("checking the unexpanded form"));
assert_eq!(out.diagnostics.len(), 1, "{:?}", out.diagnostics);
}
#[test]
fn macro_free_source_checks_identically_with_and_without_expansion() {
let exp = BuildExpander::new();
for src in [
"(define x 42) (+ 1 2)",
"(the :int \"oops\")",
"(define (f a b) a) (f 1 2 3)",
"(declare n :int) (define n \"nope\")",
"(define (twice g x) (g (g x))) (twice (lambda (y) y) 1)",
] {
let pure = check(src);
let out = check_expanded(&exp, src);
assert!(out.expansion_failures.is_empty(), "{src}");
assert_eq!(
pure.len(),
out.diagnostics.len(),
"expansion changed the verdict for {src}: {:?} vs {:?}",
pure,
out.diagnostics
);
}
}
#[test]
fn a_macro_from_one_file_does_not_leak_into_the_next() {
let exp = BuildExpander::new();
let a = "(defmacro claim-int (x) `(the :int ,x))
(claim-int \"not an int\")";
assert_eq!(check_expanded(&exp, a).diagnostics.len(), 1);
let b = "(claim-int \"not an int\")";
assert!(check_expanded(&exp, b).diagnostics.is_empty());
}
#[test]
fn the_build_expander_denies_every_module_load() {
let mut interp = BuildExpander::new().fork_interpreter();
let forms = read_spanned("(require \"anything\")").unwrap();
let err = interp
.eval_top_form(&forms[0], &mut ())
.expect_err("a build-time (require …) must not resolve");
let msg = format!("{err}");
assert!(
msg.contains("must not read the filesystem"),
"denial must name the gate, got: {msg}"
);
}
#[test]
fn the_same_require_succeeds_against_a_real_filesystem_loader() {
let dir =
std::env::temp_dir().join(format!("tatara-build-check-control-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("anything.tlisp"),
"(provide answer)\n(define answer 42)\n",
)
.unwrap();
let mut interp = BuildExpander::new().fork_interpreter();
interp.set_loader(Arc::new(crate::FilesystemLoader::new(&dir)));
let forms = read_spanned("(require \"anything\")").unwrap();
interp
.eval_top_form(&forms[0], &mut ())
.expect("the control must resolve — otherwise the denial test proves nothing");
std::fs::remove_dir_all(&dir).ok();
}
}