use std::collections::BTreeSet;
use brink_format::DefinitionId;
use brink_ir::{
BaseType, Diagnostic, DiagnosticCode, FileId, HirFile, HostManifest, Knot, Stitch, SymbolIndex,
SymbolKind,
};
use crate::infer::{InferenceResult, Ty};
use crate::resolve::ImportScope;
fn is_known_leaf(name: &str) -> bool {
matches!(
name,
"int"
| "float"
| "bool"
| "string"
| "divert"
| "void"
| "vec2"
| "vec3"
| "vec4"
| "quat"
| "mat2"
| "mat3"
| "mat4"
| "content"
)
}
#[derive(Debug, Clone, Default)]
pub struct TypeNames {
pub lists: BTreeSet<String>,
pub structs: BTreeSet<String>,
pub handles: BTreeSet<String>,
}
impl TypeNames {
#[must_use]
pub fn new(index: &SymbolIndex, manifest: Option<&HostManifest>) -> Self {
Self {
lists: declared_list_names(index),
structs: declared_struct_names(index),
handles: declared_handle_kinds(manifest),
}
}
}
#[must_use]
pub fn declared_handle_kinds(manifest: Option<&HostManifest>) -> BTreeSet<String> {
manifest
.map(|m| {
m.types
.iter()
.filter(|t| t.base == BaseType::Handle)
.map(|t| t.name.clone())
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn resolve(te: &brink_ir::TypeExpr, names: &TypeNames) -> Option<Ty> {
match te {
brink_ir::TypeExpr::Named { name, .. } => match name.as_str() {
"int" => Some(Ty::Int),
"float" => Some(Ty::Float),
"bool" => Some(Ty::Bool),
"string" => Some(Ty::String),
"content" => Some(Ty::Content),
"divert" => Some(Ty::Divert),
_ if crate::infer::TowerTy::from_name(name).is_some() => {
crate::infer::TowerTy::from_name(name).map(Ty::Tower)
}
_ if names.structs.contains(name) => Some(Ty::Struct(name.clone())),
_ => None, },
brink_ir::TypeExpr::Generic { name, args, .. } => match name.as_str() {
"List" if args.len() == 1 => match &args[0] {
brink_ir::TypeExpr::Named { name: l, .. } if names.lists.contains(l) => {
Some(Ty::List(l.clone()))
}
_ => None,
},
"Handle" if args.len() == 1 => match &args[0] {
brink_ir::TypeExpr::Named { name: k, .. } if names.handles.contains(k) => {
Some(Ty::Handle(k.clone()))
}
_ => None,
},
"Array" if args.len() == 1 => resolve(&args[0], names).map(|t| Ty::Array(Box::new(t))),
"Map" if args.len() == 2 => {
let k = resolve(&args[0], names)?;
let v = resolve(&args[1], names)?;
Some(Ty::Map(Box::new(k), Box::new(v)))
}
"Option" if args.len() == 1 => {
resolve(&args[0], names).map(|t| Ty::Option(Box::new(t)))
}
"Weighted" if args.len() == 1 => {
resolve(&args[0], names).map(|t| Ty::Weighted(Box::new(t)))
}
_ => None,
},
brink_ir::TypeExpr::Fn { params, ret, .. } => {
let params: Option<Vec<Ty>> = params.iter().map(|p| resolve(p, names)).collect();
let ret = resolve(ret, names)?;
Some(Ty::Fn(
params?,
Box::new(ret),
crate::infer::FnRow::unknown(),
))
}
}
}
fn is_reserved_before_struct_lookup(name: &str) -> bool {
matches!(
name,
"int" | "float" | "bool" | "string" | "content" | "divert"
) || crate::infer::TowerTy::from_name(name).is_some()
}
#[must_use]
pub fn check_reserved_type_names(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
let mut out = Vec::new();
for &(file, hir) in files {
for s in &hir.structs {
let name = s.name.text.as_str();
if is_reserved_before_struct_lookup(name) {
out.push(Diagnostic {
file,
range: s.name.range,
message: format!(
"STRUCT `{name}` has the same name as a reserved builtin/tower type — \
a `{name}`-typed annotation will always resolve to the builtin, never \
to this struct (construction literals, `{name}#{{...}}`, are \
unaffected)"
),
code: DiagnosticCode::E188,
});
}
}
}
out
}
pub(crate) fn declared_list_names(index: &SymbolIndex) -> BTreeSet<String> {
index
.symbols
.values()
.filter(|s| s.kind == SymbolKind::List)
.map(|s| s.name.clone())
.collect()
}
pub(crate) fn declared_struct_names(index: &SymbolIndex) -> BTreeSet<String> {
index
.symbols
.values()
.filter(|s| s.kind == SymbolKind::Struct)
.map(|s| s.name.clone())
.collect()
}
#[must_use]
pub fn check(
file: FileId,
hir: &HirFile,
index: &SymbolIndex,
manifest: Option<&HostManifest>,
scope: &ImportScope,
) -> Vec<Diagnostic> {
let names = TypeNames::new(index, manifest);
let mut out = Vec::new();
for v in &hir.variables {
if let Some(te) = &v.annotation {
check_one(te, &names, index, scope, file, &mut out);
}
}
for c in &hir.constants {
if let Some(te) = &c.annotation {
check_one(te, &names, index, scope, file, &mut out);
}
}
for knot in &hir.knots {
check_knot(knot, file, &names, index, scope, &mut out);
}
out
}
fn check_knot(
knot: &Knot,
file: FileId,
names: &TypeNames,
index: &SymbolIndex,
scope: &ImportScope,
out: &mut Vec<Diagnostic>,
) {
for p in &knot.params {
if let Some(te) = &p.annotation {
check_one(te, names, index, scope, file, out);
}
}
if let Some(rt) = &knot.return_type {
check_one(rt, names, index, scope, file, out);
}
for stitch in &knot.stitches {
check_stitch(stitch, file, names, index, scope, out);
}
}
fn check_stitch(
stitch: &Stitch,
file: FileId,
names: &TypeNames,
index: &SymbolIndex,
scope: &ImportScope,
out: &mut Vec<Diagnostic>,
) {
for p in &stitch.params {
if let Some(te) = &p.annotation {
check_one(te, names, index, scope, file, out);
}
}
if let Some(rt) = &stitch.return_type {
check_one(rt, names, index, scope, file, out);
}
}
fn declared_struct_modules_hint(index: &SymbolIndex, name: &str) -> Option<String> {
let ids = index.by_name.get(name)?;
let modules: BTreeSet<String> = ids
.iter()
.filter_map(|id| index.symbols.get(id))
.filter(|info| info.kind == SymbolKind::Struct)
.filter_map(|info| info.module.clone())
.collect();
if modules.is_empty() {
None
} else {
Some(modules.into_iter().collect::<Vec<_>>().join(", "))
}
}
fn check_one(
te: &brink_ir::TypeExpr,
names: &TypeNames,
index: &SymbolIndex,
scope: &ImportScope,
file: FileId,
out: &mut Vec<Diagnostic>,
) {
match te {
brink_ir::TypeExpr::Named { name, range } => {
if !is_known_leaf(name)
&& crate::resolve::lookup_by_name(index, scope, name, &[SymbolKind::Struct])
.is_none()
{
let message = match declared_struct_modules_hint(index, name) {
Some(modules) => format!(
"`{name}` names a declared struct in `{modules}`, but it isn't \
reachable from this file yet (see #1582, #2167) — check the spelling, \
or declare/use it from a module this file can see"
),
None => format!(
"`{name}` is not a recognized type — expected int, float, bool, \
string, content, divert, void, a tower kind \
(vec2/vec3/vec4/quat/mat2/mat3/mat4), List<L>, Array<T>, Map<K, V>, \
Option<T>, Weighted<T>, Handle<K>, or a declared STRUCT name"
),
};
out.push(Diagnostic {
file,
range: *range,
message,
code: DiagnosticCode::E061,
});
}
}
brink_ir::TypeExpr::Generic { name, args, range } => match name.as_str() {
"List" => {
let bad = match args.as_slice() {
[brink_ir::TypeExpr::Named { name: l, .. }] => !names.lists.contains(l),
_ => true,
};
if bad {
out.push(Diagnostic {
file,
range: *range,
message: format!(
"`List<{}>` doesn't name a declared LIST",
args.first().map_or(String::new(), display_short)
),
code: DiagnosticCode::E061,
});
}
}
"Handle" => {
let bad = match args.as_slice() {
[brink_ir::TypeExpr::Named { name: k, .. }] => !names.handles.contains(k),
_ => true,
};
if bad {
out.push(Diagnostic {
file,
range: *range,
message: format!(
"`Handle<{}>` doesn't name a declared handle kind in the host \
manifest",
args.first().map_or(String::new(), display_short)
),
code: DiagnosticCode::E061,
});
}
}
"Array" | "Map" | "Option" | "Weighted" => {
for a in args {
check_one(a, names, index, scope, file, out);
}
}
_ => {
out.push(Diagnostic {
file,
range: *range,
message: format!("`{name}<...>` is not a recognized generic type"),
code: DiagnosticCode::E061,
});
}
},
brink_ir::TypeExpr::Fn { params, ret, .. } => {
for p in params {
check_one(p, names, index, scope, file, out);
}
check_one(ret, names, index, scope, file, out);
}
}
}
fn display_short(te: &brink_ir::TypeExpr) -> String {
match te {
brink_ir::TypeExpr::Named { name, .. } => name.clone(),
brink_ir::TypeExpr::Generic { name, .. } => format!("{name}<...>"),
brink_ir::TypeExpr::Fn { .. } => "fn(...)".to_owned(),
}
}
#[must_use]
pub fn mismatches(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
manifest: Option<&HostManifest>,
) -> Vec<Diagnostic> {
let names = TypeNames::new(index, manifest);
let mut out = Vec::new();
for &(file, hir) in files {
for knot in &hir.knots {
check_def_mismatch(knot, file, index, &names, inference, &mut out);
for stitch in &knot.stitches {
check_stitch_mismatch(
stitch,
&knot.name.text,
file,
index,
&names,
inference,
&mut out,
);
}
}
}
out
}
pub(crate) fn def_id_for(
index: &SymbolIndex,
file: FileId,
kind: SymbolKind,
name: &str,
) -> Option<DefinitionId> {
index
.by_name
.get(name)?
.iter()
.find(|id| {
index
.symbols
.get(id)
.is_some_and(|info| info.file == file && info.kind == kind)
})
.copied()
}
fn check_def_mismatch(
knot: &Knot,
file: FileId,
index: &SymbolIndex,
names: &TypeNames,
inference: &InferenceResult,
out: &mut Vec<Diagnostic>,
) {
let Some(id) = def_id_for(index, file, knot.symbol_kind(), &knot.name.text) else {
return;
};
let Some(inferred) = inference.signatures.get(&id) else {
return;
};
for (i, p) in knot.params.iter().enumerate() {
let Some(ann) = &p.annotation else { continue };
let Some(ann_ty) = resolve(ann, names) else {
continue;
};
let Some(body_ty) = inferred.params.get(i) else {
continue;
};
report_if_mismatched(ann, &ann_ty, body_ty, file, out);
}
if let Some(rt) = &knot.return_type
&& let Some(ann_ty) = resolve(rt, names)
{
report_if_mismatched(rt, &ann_ty, &inferred.return_ty, file, out);
}
}
fn check_stitch_mismatch(
stitch: &Stitch,
knot_name: &str,
file: FileId,
index: &SymbolIndex,
names: &TypeNames,
inference: &InferenceResult,
out: &mut Vec<Diagnostic>,
) {
let qualified = format!("{knot_name}.{}", stitch.name.text);
let Some(id) = def_id_for(index, file, SymbolKind::Stitch, &qualified) else {
return;
};
let Some(inferred) = inference.signatures.get(&id) else {
return;
};
for (i, p) in stitch.params.iter().enumerate() {
let Some(ann) = &p.annotation else { continue };
let Some(ann_ty) = resolve(ann, names) else {
continue;
};
let Some(body_ty) = inferred.params.get(i) else {
continue;
};
report_if_mismatched(ann, &ann_ty, body_ty, file, out);
}
if let Some(rt) = &stitch.return_type
&& let Some(ann_ty) = resolve(rt, names)
{
report_if_mismatched(rt, &ann_ty, &inferred.return_ty, file, out);
}
}
fn report_if_mismatched(
te: &brink_ir::TypeExpr,
ann_ty: &Ty,
body_ty: &Ty,
file: FileId,
out: &mut Vec<Diagnostic>,
) {
if body_ty.is_unresolved() {
return;
}
if crate::infer::assignable(ann_ty, body_ty) {
return;
}
out.push(Diagnostic {
file,
range: te.range(),
message: format!(
"annotated type `{}` disagrees with the type inferred from usage (`{}`)",
ann_ty.display(),
body_ty.display()
),
code: DiagnosticCode::E063,
});
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use brink_ir::ResolutionMap;
use brink_ir::hir::lower;
fn build(src: &str) -> (HirFile, SymbolIndex) {
let parsed = brink_syntax::parse(src);
let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
(hir, (*index).clone())
}
fn tn(lists: &BTreeSet<String>, structs: &BTreeSet<String>) -> TypeNames {
TypeNames {
lists: lists.clone(),
structs: structs.clone(),
handles: BTreeSet::new(),
}
}
fn build_with_resolutions(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
let parsed = brink_syntax::parse(src);
let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
let (resolutions, _diag) =
crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
(hir, (*index).clone(), (*resolutions).clone())
}
#[test]
fn resolve_recognizes_scalar_leaves() {
let (hir, _index) = build("VAR a: int = 1\nVAR b: float = 1.0\nVAR c: bool = true\n");
let a = hir.variables[0].annotation.as_ref().expect("a annotation");
let b = hir.variables[1].annotation.as_ref().expect("b annotation");
let c = hir.variables[2].annotation.as_ref().expect("c annotation");
let empty = BTreeSet::new();
assert_eq!(resolve(a, &tn(&empty, &empty)), Some(Ty::Int));
assert_eq!(resolve(b, &tn(&empty, &empty)), Some(Ty::Float));
assert_eq!(resolve(c, &tn(&empty, &empty)), Some(Ty::Bool));
}
#[test]
fn resolve_recognizes_content_leaf() {
let (hir, _index) = build("VAR v: content = 0\n");
let te = hir.variables[0].annotation.as_ref().expect("annotation");
let empty = BTreeSet::new();
assert_eq!(resolve(te, &tn(&empty, &empty)), Some(Ty::Content));
}
#[test]
fn resolve_array_and_map_generics() {
let (hir, _index) = build("VAR a: Array<int> = 0\nVAR m: Map<string, int> = 0\n");
let a = hir.variables[0].annotation.as_ref().expect("a");
let m = hir.variables[1].annotation.as_ref().expect("m");
let empty = BTreeSet::new();
assert_eq!(
resolve(a, &tn(&empty, &empty)),
Some(Ty::Array(Box::new(Ty::Int)))
);
assert_eq!(
resolve(m, &tn(&empty, &empty)),
Some(Ty::Map(Box::new(Ty::String), Box::new(Ty::Int)))
);
}
#[test]
fn resolve_list_generic_needs_declared_list_name() {
let (hir, _index) = build("VAR w: List<Weathers> = 0\n");
let te = hir.variables[0].annotation.as_ref().expect("annotation");
let empty = BTreeSet::new();
assert_eq!(
resolve(te, &tn(&empty, &empty)),
None,
"Weathers isn't declared here"
);
let declared: BTreeSet<String> = ["Weathers".to_string()].into_iter().collect();
assert_eq!(
resolve(te, &tn(&declared, &empty)),
Some(Ty::List("Weathers".to_string()))
);
}
#[test]
fn resolve_void_and_unknown_are_none() {
let (hir, _index) = build("VAR v: void = 0\nVAR u: Frobnicator = 0\n");
let empty = BTreeSet::new();
for v in &hir.variables {
let te = v.annotation.as_ref().expect("annotation");
assert_eq!(resolve(te, &tn(&empty, &empty)), None, "{v:?}");
}
}
#[test]
fn resolve_fn_type_form() {
let (hir, _index) = build("VAR cb: fn(int, string): bool = 0\nVAR z: fn(): int = 0\n");
let empty = BTreeSet::new();
let cb = hir.variables[0].annotation.as_ref().expect("cb");
let z = hir.variables[1].annotation.as_ref().expect("z");
assert_eq!(
resolve(cb, &tn(&empty, &empty)),
Some(Ty::Fn(
vec![Ty::Int, Ty::String],
Box::new(Ty::Bool),
crate::infer::FnRow::unknown()
))
);
assert_eq!(
resolve(z, &tn(&empty, &empty)),
Some(Ty::Fn(
Vec::new(),
Box::new(Ty::Int),
crate::infer::FnRow::unknown()
))
);
}
#[test]
fn resolve_nested_fn_type_forms() {
let (hir, _index) =
build("VAR a: Array<fn(int): int> = 0\nVAR b: fn(Array<int>): fn(int): bool = 0\n");
let empty = BTreeSet::new();
let a = hir.variables[0].annotation.as_ref().expect("a");
let b = hir.variables[1].annotation.as_ref().expect("b");
assert_eq!(
resolve(a, &tn(&empty, &empty)),
Some(Ty::Array(Box::new(Ty::Fn(
vec![Ty::Int],
Box::new(Ty::Int),
crate::infer::FnRow::unknown()
))))
);
assert_eq!(
resolve(b, &tn(&empty, &empty)),
Some(Ty::Fn(
vec![Ty::Array(Box::new(Ty::Int))],
Box::new(Ty::Fn(
vec![Ty::Int],
Box::new(Ty::Bool),
crate::infer::FnRow::unknown()
)),
crate::infer::FnRow::unknown()
))
);
}
#[test]
fn resolve_fn_type_with_void_return_is_none_in_this_slice() {
let (hir, _index) = build("VAR cb: fn(int): void = 0\n");
let te = hir.variables[0].annotation.as_ref().expect("annotation");
let empty = BTreeSet::new();
assert_eq!(resolve(te, &tn(&empty, &empty)), None);
}
#[test]
fn resolve_recognizes_declared_struct_name() {
let (hir, _index) = build("STRUCT Point = #{x: float}\nVAR p: Point = 0\n");
let te = hir.variables[0].annotation.as_ref().expect("annotation");
let empty = BTreeSet::new();
assert_eq!(
resolve(te, &tn(&empty, &empty)),
None,
"Point isn't in struct_names here"
);
let declared: BTreeSet<String> = ["Point".to_string()].into_iter().collect();
assert_eq!(
resolve(te, &tn(&empty, &declared)),
Some(Ty::Struct("Point".to_string()))
);
}
#[test]
fn resolve_option_and_weighted_generics() {
let (hir, _index) = build("VAR o: Option<int> = 0\nVAR w: Weighted<string> = 0\n");
let o = hir.variables[0].annotation.as_ref().expect("o");
let w = hir.variables[1].annotation.as_ref().expect("w");
let empty = BTreeSet::new();
assert_eq!(
resolve(o, &tn(&empty, &empty)),
Some(Ty::Option(Box::new(Ty::Int)))
);
assert_eq!(
resolve(w, &tn(&empty, &empty)),
Some(Ty::Weighted(Box::new(Ty::String)))
);
}
#[test]
fn check_accepts_option_and_weighted_annotations() {
let (hir, index) = build("VAR o: Option<int> = 0\nVAR w: Weighted<float> = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_flags_unknown_name_inside_option_element() {
let (hir, index) = build("VAR o: Option<Bogus> = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E061);
}
#[test]
fn old_lowercase_generic_heads_no_longer_resolve() {
let lower = |s: &str| s.to_lowercase();
let source = format!(
"LIST Weathers = sunny, rainy\n\
VAR a: {}<int> = 0\n\
VAR m: {}<string, int> = 0\n\
VAR w: {}<Weathers> = 0\n",
lower("Array"),
lower("Map"),
lower("List"),
);
let (hir, index) = build(&source);
let empty = BTreeSet::new();
let declared: BTreeSet<String> = ["Weathers".to_string()].into_iter().collect();
for v in &hir.variables {
let te = v.annotation.as_ref().expect("annotation");
assert_eq!(
resolve(te, &tn(&declared, &empty)),
None,
"{v:?} should no longer resolve under the old lowercase spelling"
);
}
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 3, "{diags:?}");
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E061));
}
fn audio_instance_manifest() -> HostManifest {
HostManifest {
markup: Vec::new(),
types: vec![brink_ir::SemanticTypeDef {
name: "AudioInstance".to_string(),
base: BaseType::Handle,
constraint: None,
values: None,
widget: None,
}],
..Default::default()
}
}
#[test]
fn declared_handle_kinds_reads_only_handle_based_semantic_types() {
let manifest = HostManifest {
markup: Vec::new(),
types: vec![
brink_ir::SemanticTypeDef {
name: "AudioInstance".to_string(),
base: BaseType::Handle,
constraint: None,
values: None,
widget: None,
},
brink_ir::SemanticTypeDef {
name: "switch_id".to_string(),
base: BaseType::Int,
constraint: None,
values: None,
widget: None,
},
],
..Default::default()
};
let kinds = declared_handle_kinds(Some(&manifest));
assert_eq!(kinds, ["AudioInstance".to_string()].into_iter().collect());
assert!(declared_handle_kinds(None).is_empty());
}
#[test]
fn resolve_handle_generic_needs_declared_manifest_kind() {
let (hir, index) = build("VAR h: Handle<AudioInstance> = 0\n");
let te = hir.variables[0].annotation.as_ref().expect("annotation");
assert_eq!(
resolve(te, &TypeNames::new(&index, None)),
None,
"AudioInstance isn't declared without a manifest"
);
let manifest = audio_instance_manifest();
assert_eq!(
resolve(te, &TypeNames::new(&index, Some(&manifest))),
Some(Ty::Handle("AudioInstance".to_string()))
);
}
#[test]
fn check_flags_undeclared_handle_kind() {
let (hir, index) = build("VAR h: Handle<Nope> = 0\n");
let diags = check(
FileId(0),
&hir,
&index,
Some(&audio_instance_manifest()),
&ImportScope::default(),
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E061);
}
#[test]
fn check_accepts_declared_handle_kind() {
let (hir, index) = build("VAR h: Handle<AudioInstance> = 0\n");
let diags = check(
FileId(0),
&hir,
&index,
Some(&audio_instance_manifest()),
&ImportScope::default(),
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_flags_handle_kind_with_no_manifest_registered() {
let (hir, index) = build("VAR h: Handle<AudioInstance> = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E061);
}
#[test]
fn check_flags_unknown_type_name() {
let (hir, index) = build("VAR p: Frobnicator = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E061);
}
#[test]
fn check_accepts_fn_type_since_t1c() {
let (hir, index) = build("VAR cb: fn(int, int): bool = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_still_flags_unknown_names_inside_a_fn_type() {
let (hir, index) = build("VAR cb: fn(Bogus): bool = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E061);
}
#[test]
fn check_accepts_known_scalar_and_generic_types() {
let (hir, index) =
build("VAR a: int = 1\nVAR b: Array<float> = 0\nVAR c: Map<string, bool> = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_accepts_void_return_type() {
let (hir, index) = build("=== function noop(): void ===\n~ return\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_accepts_content_param_annotation() {
let (hir, index) =
build("=== function radio(chan: string, text: content) ===\n~ return text\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_accepts_declared_list_name() {
let (hir, index) = build("LIST Weathers = sunny, rainy\nVAR w: List<Weathers> = sunny\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_accepts_declared_struct_name() {
let (hir, index) = build("STRUCT Point = #{x: float}\nVAR p: Point = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
fn inject_struct(index: &mut SymbolIndex, name: &str, module: &str, tag_seed: u64) {
let id = DefinitionId::new(brink_format::DefinitionTag::StructDef, tag_seed);
index.symbols.insert(
id,
brink_ir::SymbolInfo {
kind: SymbolKind::Struct,
file: FileId(9),
range: rowan::TextRange::default(),
id,
name: name.to_string(),
params: Vec::new(),
detail: None,
scope: None,
param_detail: None,
module: Some(module.to_string()),
visibility: brink_ir::Visibility::Public,
},
);
index.by_name.entry(name.to_string()).or_default().push(id);
}
#[test]
fn check_flags_unimported_std_only_struct_name_referrer_scoped() {
let (hir, mut index) = build("VAR c: Cue = 0\n");
inject_struct(&mut index, "Cue", "std::conventions::screenplay", 0xC0F);
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(
diags.len(),
1,
"an unimported std-only struct name must now raise E061 — before this fix it \
raised nothing anywhere: {diags:?}"
);
assert_eq!(diags[0].code, DiagnosticCode::E061);
assert!(
diags[0].message.contains("std::conventions::screenplay"),
"the message should hint the module a referrer would import from: {:?}",
diags[0].message
);
}
#[test]
fn check_accepts_std_only_struct_name_once_imported() {
let (hir, mut index) = build("VAR c: Cue = 0\n");
inject_struct(&mut index, "Cue", "std::conventions::screenplay", 0xC10);
let scope = ImportScope {
file_module: None,
qualified_modules: ["std::conventions::screenplay".to_string()]
.into_iter()
.collect(),
bare_imports: BTreeSet::new(),
aliases: BTreeMap::new(),
};
let diags = check(FileId(0), &hir, &index, None, &scope);
assert!(
diags.is_empty(),
"an imported std struct must stay clean through the real check() call: {diags:?}"
);
}
#[test]
fn check_still_accepts_locally_declared_struct_with_no_modules_in_play() {
let (hir, index) = build("STRUCT Cue = #{x: int}\nVAR c: Cue = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(
diags.is_empty(),
"an ordinary, single-file, no-`#@module` struct declaration must stay clean: \
{diags:?}"
);
}
#[test]
fn check_still_accepts_every_builtin_leaf_with_a_std_mount_present() {
let (hir, mut index) = build(
"VAR a: int = 0\nVAR b: float = 0\nVAR c: bool = true\nVAR d: string = \"x\"\n\
VAR e: content = 0\nVAR f: divert = 0\nVAR g: vec3 = 0\n",
);
inject_struct(
&mut index,
"Unrelated",
"std::conventions::screenplay",
0xC11,
);
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn check_flags_undeclared_struct_name_still() {
let (hir, index) = build("VAR w: NotAStruct = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E061);
}
#[test]
fn check_flags_undeclared_list_name() {
let (hir, index) = build("VAR w: List<Nope> = 0\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E061);
}
#[test]
fn check_flags_param_and_return_type_annotations() {
let (hir, index) = build("=== function heal(hp: Bogus): AlsoBogus ===\n~ return hp\n");
let diags = check(FileId(0), &hir, &index, None, &ImportScope::default());
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E061));
}
#[test]
fn a_fn_typed_annotation_does_not_disagree_with_its_body_derived_row() {
let (hir, index, res) = build_with_resolutions(
"=== function bump(n: int): int ===\n~ return n + 1\n\
=== function pick(): fn(int): int ===\n~ return #fn(bump)\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E063),
"{diags:?}"
);
}
#[test]
fn mismatches_flags_annotation_disagreeing_with_body_inference() {
let (hir, index, res) =
build_with_resolutions("=== heal(hp: string) ===\n{hp > 1:\n ok\n}\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
}
#[test]
fn mismatches_is_silent_when_annotation_and_inference_agree() {
let (hir, index, res) =
build_with_resolutions("=== heal(hp: int) ===\n{hp > 1:\n ok\n}\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn mismatches_is_silent_when_body_never_constrains_the_param() {
let (hir, index, res) = build_with_resolutions("=== heal(hp: int) ===\nHello.\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn mismatches_is_silent_for_the_legal_int_to_float_coercion() {
let (hir, index, res) =
build_with_resolutions("=== heal(hp: float) ===\n{hp > 1:\n ok\n}\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn mismatches_is_silent_when_body_is_conflicted() {
let (hir, index, res) = build_with_resolutions(
"=== heal(hp: int) ===\n{hp > 1:\n ok\n}\n{hp == \"x\":\n no\n}\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let heal_id = index
.by_name
.get("heal")
.and_then(|ids| ids.first())
.copied()
.expect("heal");
let sig = inference
.signatures
.get(&heal_id)
.expect("inferred signature for heal");
assert_eq!(sig.params, vec![Ty::Conflicted], "fixture sanity check");
let diags = mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn mismatches_flags_nested_stitch_return_type_disagreeing_with_body_inference() {
let (hir, index, res) =
build_with_resolutions("=== camp ===\n= fire(): string\n~ return 1\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
}
#[test]
fn red_annotation_named_content_resolves_to_builtin_not_the_colliding_struct() {
let (hir, index) = build("STRUCT content = #{x: int}\nVAR v: content = 0\n");
let names = TypeNames::new(&index, None);
assert!(
names.structs.contains("content"),
"fixture sanity check: the struct must actually be declared"
);
let te = hir.variables[0].annotation.as_ref().expect("annotation");
assert_eq!(
resolve(te, &names),
Some(Ty::Content),
"the builtin leaf wins — the struct is unreachable through this annotation"
);
}
#[test]
fn struct_named_content_collides_with_builtin_leaf_is_e188() {
let (hir, _index) = build("STRUCT content = #{x: int}\n");
let diags = check_reserved_type_names(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E188);
assert!(
diags[0].message.contains("content"),
"{:?}",
diags[0].message
);
}
#[test]
fn red_annotation_named_vec3_resolves_to_tower_kind_not_the_colliding_struct() {
let (hir, index) =
build("STRUCT vec3 = #{x: float, y: float, z: float}\nVAR v: vec3 = 0\n");
let names = TypeNames::new(&index, None);
let te = hir.variables[0].annotation.as_ref().expect("annotation");
assert!(
!matches!(resolve(te, &names), Some(Ty::Struct(_))),
"the tower kind must win over the colliding struct, got {:?}",
resolve(te, &names)
);
}
#[test]
fn struct_named_vec3_collides_with_tower_kind_is_e188() {
let (hir, _index) = build("STRUCT vec3 = #{x: float, y: float, z: float}\n");
let diags = check_reserved_type_names(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E188);
}
#[test]
fn ordinary_struct_name_gets_no_e188() {
let (hir, _index) = build("STRUCT Point = #{x: float, y: float}\n");
let diags = check_reserved_type_names(&[(FileId(0), &hir)]);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn struct_named_void_is_not_shadowed_and_resolves_fine() {
let (hir, index) = build("STRUCT void = #{x: int}\nVAR v: void = 0\n");
let diags = check_reserved_type_names(&[(FileId(0), &hir)]);
assert!(
diags.is_empty(),
"`void` has no collision to report: {diags:?}"
);
let names = TypeNames::new(&index, None);
let te = hir.variables[0].annotation.as_ref().expect("annotation");
assert_eq!(
resolve(te, &names),
Some(Ty::Struct("void".to_string())),
"a STRUCT named `void` must resolve fine — `resolve`'s Named arm has no \
explicit void case, so it falls through to the struct lookup"
);
}
#[test]
fn struct_named_array_stays_reachable_and_is_not_flagged() {
let (hir, index) = build("STRUCT Array = #{x: int}\nVAR v: Array = 0\n");
let diags = check_reserved_type_names(&[(FileId(0), &hir)]);
assert!(
diags.is_empty(),
"a bare `Array` annotation has no collision — Array is only special-cased \
inside TypeExpr::Generic, never TypeExpr::Named: {diags:?}"
);
let names = TypeNames::new(&index, None);
let te = hir.variables[0].annotation.as_ref().expect("annotation");
assert_eq!(
resolve(te, &names),
Some(Ty::Struct("Array".to_string())),
"a bare `Array` annotation must resolve to the struct, not silently fail"
);
}
#[test]
fn multiple_colliding_structs_each_get_their_own_e188() {
let (hir, _index) = build(
"STRUCT content = #{x: int}\nSTRUCT bool = #{y: int}\nSTRUCT Point = #{z: int}\n",
);
let diags = check_reserved_type_names(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E188));
let ranges: BTreeSet<(u32, u32)> = diags
.iter()
.map(|d| (d.range.start().into(), d.range.end().into()))
.collect();
assert_eq!(
ranges.len(),
2,
"each collision must point at its own declaration"
);
}
}