use std::collections::{BTreeMap, BTreeSet};
use brink_format::DefinitionId;
use brink_ir::hir::visit::{self, HirVisitor};
use brink_ir::{
AssignOp, BlockStmt, Diagnostic, DiagnosticCode, Expr, FileId, HirFile, Knot, ResolutionMap,
Stitch, StructLiteral, SymbolIndex, SymbolKind,
};
use rowan::TextRange;
use crate::annotations;
use crate::infer::{
FieldAssignMismatch, InferenceResult, InferredSig, Ty, is_string_numeric_concat,
};
use crate::resolve::ImportScope;
pub struct ShapeInfo {
fields: Vec<(String, Ty)>,
}
impl ShapeInfo {
#[must_use]
pub fn field_ty(&self, name: &str) -> Option<&Ty> {
self.fields.iter().find(|(n, _)| n == name).map(|(_, t)| t)
}
#[must_use]
pub fn has_field(&self, name: &str) -> bool {
self.fields.iter().any(|(n, _)| n == name)
}
}
#[must_use]
pub fn declared_shapes(files: &[(FileId, &HirFile)], index: &SymbolIndex) -> ShapeTable {
let names = annotations::TypeNames::new(index, None);
let mut by_def = BTreeMap::new();
for &(file, hir) in files {
for s in &hir.structs {
let Some(def_id) =
annotations::def_id_for(index, file, SymbolKind::Struct, &s.name.text)
else {
continue;
};
if by_def.contains_key(&def_id) {
continue;
}
let fields = s
.fields
.iter()
.map(|f| {
let ty = annotations::resolve(&f.ty, &names).unwrap_or(Ty::Unknown);
(f.name.text.clone(), ty)
})
.collect();
by_def.insert(def_id, ShapeInfo { fields });
}
}
ShapeTable { by_def }
}
#[derive(Default)]
pub struct ShapeTable {
by_def: BTreeMap<DefinitionId, ShapeInfo>,
}
impl ShapeTable {
#[must_use]
pub fn len(&self) -> usize {
self.by_def.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_def.is_empty()
}
#[must_use]
pub fn get_by_def(&self, id: DefinitionId) -> Option<&ShapeInfo> {
self.by_def.get(&id)
}
#[must_use]
pub fn resolve(
&self,
name: &str,
scope: &ImportScope,
index: &SymbolIndex,
) -> Option<&ShapeInfo> {
let def_id = crate::resolve::lookup_by_name(index, scope, name, &[SymbolKind::Struct])?;
self.by_def.get(&def_id)
}
}
#[must_use]
pub fn check(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
resolutions: &ResolutionMap,
) -> Vec<Diagnostic> {
let shapes = declared_shapes(files, index);
let globals = crate::infer::collect_globals(files, index, None);
let mut out = Vec::new();
for &(file, hir) in files {
let resolution_by_range = resolution_index(resolutions, file);
let mut v = ConstructionVisitor {
file,
shapes: &shapes,
index,
globals: &globals,
signatures: &inference.signatures,
bodies: &inference.bodies,
resolution_by_range: &resolution_by_range,
current_knot_name: None,
knot_locals: None,
stitch_locals: None,
lambda_locals: Vec::new(),
diagnostics: &mut out,
};
visit::visit_with_decl_initializers(hir, &mut v);
}
out
}
#[must_use]
pub fn check_assignments(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
) -> Vec<Diagnostic> {
let shapes = declared_shapes(files, index);
let scopes: BTreeMap<FileId, ImportScope> = files
.iter()
.map(|&(file, hir)| {
let scope = ImportScope::new(hir.module.as_ref().map(|m| m.name.clone()), &hir.imports);
(file, scope)
})
.collect();
let mut out = Vec::new();
for (def, body) in &inference.bodies {
let Some(info) = index.symbols.get(def) else {
continue;
};
let Some(scope) = scopes.get(&info.file) else {
continue;
};
for fact in &body.field_assign_mismatches {
check_field_assign_mismatch(fact, info.file, scope, index, &shapes, &mut out);
}
}
out
}
fn check_field_assign_mismatch(
fact: &FieldAssignMismatch,
file: FileId,
scope: &ImportScope,
index: &SymbolIndex,
shapes: &ShapeTable,
out: &mut Vec<Diagnostic>,
) {
let mut current = fact.root_ty.clone();
for segment in &fact.path {
let Ty::Struct(shape_name) = ¤t else {
return;
};
let Some(shape) = shapes.resolve(shape_name, scope, index) else {
return;
};
let Some(field_ty) = shape.field_ty(&segment.text) else {
out.push(Diagnostic {
file,
range: segment.range,
message: format!(
"{}: `{}` has no field `{}`",
DiagnosticCode::E185.title(),
shape_name,
segment.text
),
code: DiagnosticCode::E185,
});
return;
};
current = field_ty.clone();
}
if fact.found.is_unresolved() {
return;
}
if current.is_unresolved() || crate::infer::assignable(¤t, &fact.found) {
return;
}
if fact.op == AssignOp::Add && is_string_numeric_concat(¤t, &fact.found) {
return;
}
let Some(last) = fact.path.last() else {
return;
};
let dotted: Vec<&str> = std::iter::once(fact.root.as_str())
.chain(fact.path.iter().map(|n| n.text.as_str()))
.collect();
out.push(Diagnostic {
file,
range: last.range,
message: format!(
"`{}` has type `{}` but its declared type is `{}`",
dotted.join("."),
fact.found.display(),
current.display()
),
code: DiagnosticCode::E063,
});
}
fn range_key(range: TextRange) -> (u32, u32) {
(range.start().into(), range.end().into())
}
fn resolution_index(
resolutions: &ResolutionMap,
file: FileId,
) -> BTreeMap<(u32, u32), DefinitionId> {
resolutions
.iter()
.filter(|r| r.file == file)
.map(|r| (range_key(r.range), r.target))
.collect()
}
pub(crate) struct MistypeCtx<'a> {
pub(crate) index: &'a SymbolIndex,
pub(crate) globals: &'a BTreeMap<DefinitionId, Ty>,
pub(crate) signatures: &'a BTreeMap<DefinitionId, InferredSig>,
pub(crate) resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
pub(crate) locals: Option<&'a BTreeMap<String, Ty>>,
}
#[must_use]
pub(crate) fn pruned_locals_for_lambda(
l: &brink_ir::LambdaExpr,
index: &SymbolIndex,
outer_locals: Option<&BTreeMap<String, Ty>>,
) -> BTreeMap<String, Ty> {
let stmts: &[BlockStmt] = match &l.body {
brink_ir::LambdaBody::Block { stmts, .. } => stmts,
brink_ir::LambdaBody::Expr(_) => &[],
};
let mut body_names: BTreeMap<String, (TextRange, Option<brink_ir::TypeExpr>)> = BTreeMap::new();
crate::infer::lambda_own_bindings(stmts, &mut body_names);
let body_bound_names: BTreeSet<String> = body_names.keys().cloned().collect();
let mut own_names = body_names;
for p in &l.params {
own_names
.entry(p.name.text.clone())
.or_insert((p.name.range, None));
}
let mut pruned: BTreeMap<String, Ty> = outer_locals.map_or_else(BTreeMap::new, |locals| {
locals
.iter()
.filter(|(name, _)| !own_names.contains_key(*name))
.map(|(name, ty)| (name.clone(), ty.clone()))
.collect()
});
let type_names = annotations::TypeNames::new(index, None);
for p in &l.params {
if body_bound_names.contains(&p.name.text) {
continue;
}
if let Some(te) = &p.annotation
&& let Some(ty) = annotations::resolve(te, &type_names)
{
pruned.insert(p.name.text.clone(), ty);
}
}
pruned
}
struct ConstructionVisitor<'a> {
file: FileId,
shapes: &'a ShapeTable,
index: &'a SymbolIndex,
globals: &'a BTreeMap<DefinitionId, Ty>,
signatures: &'a BTreeMap<DefinitionId, InferredSig>,
bodies: &'a BTreeMap<DefinitionId, crate::infer::BodyTypes>,
resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
current_knot_name: Option<String>,
knot_locals: Option<&'a BTreeMap<String, Ty>>,
stitch_locals: Option<&'a BTreeMap<String, Ty>>,
lambda_locals: Vec<BTreeMap<String, Ty>>,
diagnostics: &'a mut Vec<Diagnostic>,
}
impl ConstructionVisitor<'_> {
fn current_locals(&self) -> Option<&BTreeMap<String, Ty>> {
self.lambda_locals
.last()
.or_else(|| self.stitch_locals.or(self.knot_locals))
}
fn knot_def_id(&self, knot: &Knot) -> Option<DefinitionId> {
let kind = knot.symbol_kind();
annotations::def_id_for(self.index, self.file, kind, &knot.name.text)
}
}
impl HirVisitor for ConstructionVisitor<'_> {
fn visit_exprs(&self) -> bool {
true
}
fn enter_knot(&mut self, knot: &Knot) {
self.current_knot_name = Some(knot.name.text.clone());
self.knot_locals = self
.knot_def_id(knot)
.and_then(|id| self.bodies.get(&id))
.map(|b| &b.locals);
}
fn exit_knot(&mut self, _knot: &Knot) {
self.current_knot_name = None;
self.knot_locals = None;
}
fn enter_stitch(&mut self, stitch: &Stitch) {
self.stitch_locals = self.current_knot_name.as_ref().and_then(|knot_name| {
let qualified = format!("{knot_name}.{}", stitch.name.text);
annotations::def_id_for(self.index, self.file, SymbolKind::Stitch, &qualified)
.and_then(|id| self.bodies.get(&id))
.map(|b| &b.locals)
});
}
fn exit_stitch(&mut self, _stitch: &Stitch) {
self.stitch_locals = None;
}
fn enter_expr(&mut self, expr: &Expr) {
if let Expr::StructLiteral(sl) = expr {
let ctx = MistypeCtx {
index: self.index,
globals: self.globals,
signatures: self.signatures,
resolution_by_range: self.resolution_by_range,
locals: self
.lambda_locals
.last()
.or_else(|| self.stitch_locals.or(self.knot_locals)),
};
check_literal(sl, self.file, self.shapes, &ctx, self.diagnostics);
}
}
fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
let pruned = pruned_locals_for_lambda(l, self.index, self.current_locals());
self.lambda_locals.push(pruned);
}
fn exit_lambda(&mut self, _l: &brink_ir::LambdaExpr) {
self.lambda_locals.pop();
}
}
#[must_use]
pub fn check_duplicates(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
let mut out = Vec::new();
for &(file, hir) in files {
let mut v = DuplicateFieldVisitor {
file,
diagnostics: &mut out,
};
visit::visit_with_decl_initializers(hir, &mut v);
}
out
}
struct DuplicateFieldVisitor<'a> {
file: FileId,
diagnostics: &'a mut Vec<Diagnostic>,
}
impl HirVisitor for DuplicateFieldVisitor<'_> {
fn visit_exprs(&self) -> bool {
true
}
fn enter_expr(&mut self, expr: &Expr) {
if let Expr::StructLiteral(sl) = expr {
check_literal_duplicates(sl, self.file, self.diagnostics);
}
}
}
fn check_literal_duplicates(sl: &StructLiteral, file: FileId, out: &mut Vec<Diagnostic>) {
let mut seen: crate::determinism::LookupSet<&str> = crate::determinism::LookupSet::new();
for (name, _value) in &sl.fields {
if !seen.insert(name.text.as_str()) {
out.push(Diagnostic {
file,
range: name.range,
message: format!(
"{}: field `{}` is initialized more than once",
DiagnosticCode::E084.title(),
name.text
),
code: DiagnosticCode::E084,
});
}
}
}
fn check_literal(
sl: &StructLiteral,
file: FileId,
shapes: &ShapeTable,
ctx: &MistypeCtx<'_>,
out: &mut Vec<Diagnostic>,
) {
let Some(shape) = ctx
.resolution_by_range
.get(&range_key(sl.shape.range))
.and_then(|def_id| shapes.get_by_def(*def_id))
else {
return;
};
for (name, _value) in &sl.fields {
if !shape.has_field(&name.text) {
out.push(Diagnostic {
file,
range: name.range,
message: format!(
"{}: `{}` has no field `{}`",
DiagnosticCode::E070.title(),
sl.shape.text,
name.text
),
code: DiagnosticCode::E070,
});
}
}
for (field_name, _ty) in &shape.fields {
if !sl.fields.iter().any(|(n, _)| &n.text == field_name) {
out.push(Diagnostic {
file,
range: sl.ptr.text_range(),
message: format!(
"{}: `{}` is missing field `{field_name}`",
DiagnosticCode::E069.title(),
sl.shape.text
),
code: DiagnosticCode::E069,
});
}
}
for (name, value) in &sl.fields {
let Some(declared_ty) = shape.field_ty(&name.text) else {
continue; };
if declared_ty.is_unresolved() {
continue; }
let Some(actual_ty) = classify_expr_ty(value, ctx) else {
continue; };
if !crate::infer::assignable(declared_ty, &actual_ty) {
out.push(Diagnostic {
file,
range: name.range,
message: format!(
"{}: field `{}` declared `{}` but initialized with `{}`",
DiagnosticCode::E071.title(),
name.text,
declared_ty.display(),
actual_ty.display()
),
code: DiagnosticCode::E071,
});
}
}
}
fn literal_ty(expr: &Expr) -> Option<Ty> {
match expr {
Expr::Int(_) => Some(Ty::Int),
Expr::Float(_) => Some(Ty::Float),
Expr::Bool(_) => Some(Ty::Bool),
Expr::String(s) => match s.parts.as_slice() {
[] | [brink_ir::StringPart::Literal(_)] => Some(Ty::String),
_ => None, },
Expr::ArrayLiteral(a) => {
let elems: Vec<Ty> = a.elements.iter().map(literal_ty).collect::<Option<_>>()?;
Some(Ty::Array(Box::new(crate::infer::unify_all(elems))))
}
Expr::MapLiteral(m) => {
let mut keys = Vec::with_capacity(m.entries.len());
let mut vals = Vec::with_capacity(m.entries.len());
for (k, v) in &m.entries {
keys.push(literal_ty(k)?);
vals.push(literal_ty(v)?);
}
Some(Ty::Map(
Box::new(crate::infer::unify_all(keys)),
Box::new(crate::infer::unify_all(vals)),
))
}
Expr::StructLiteral(sl) => Some(Ty::Struct(sl.shape.text.clone())),
_ => None,
}
}
pub(crate) fn classify_expr_ty(expr: &Expr, ctx: &MistypeCtx<'_>) -> Option<Ty> {
if let Some(ty) = literal_ty(expr) {
return Some(ty);
}
match expr {
Expr::Path(p) => resolved_symbol_ty(p.range, ctx),
Expr::Call(path, _args) => {
let def = ctx.resolution_by_range.get(&range_key(path.range))?;
let sig = ctx.signatures.get(def)?;
(!sig.return_ty.is_unresolved()).then(|| sig.return_ty.clone())
}
Expr::Index(idx) => {
let base_ty = classify_expr_ty(&idx.base, ctx)?;
match base_ty {
Ty::Array(elem) if !elem.is_unresolved() => Some(*elem),
Ty::Map(_key, val) if !val.is_unresolved() => Some(*val),
_ => None,
}
}
_ => None,
}
}
fn resolved_symbol_ty(range: TextRange, ctx: &MistypeCtx<'_>) -> Option<Ty> {
let def = *ctx.resolution_by_range.get(&range_key(range))?;
let info = ctx.index.symbols.get(&def)?;
let ty = match info.kind {
SymbolKind::Param | SymbolKind::Temp => ctx.locals?.get(&info.name)?.clone(),
SymbolKind::Variable | SymbolKind::Constant => ctx.globals.get(&def)?.clone(),
SymbolKind::List => Ty::List(info.name.clone()),
SymbolKind::ListItem => {
let (list, _item) = info.name.split_once('.')?;
Ty::List(list.to_string())
}
SymbolKind::Knot
| SymbolKind::Stitch
| SymbolKind::External
| SymbolKind::Struct
| SymbolKind::Label => {
return None;
}
};
if ty.is_unresolved() { None } else { Some(ty) }
}
#[cfg(test)]
mod tests {
use super::*;
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 build_with_inference(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
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());
let inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&resolutions,
None,
&BTreeMap::new(),
);
(hir, (*index).clone(), (*resolutions).clone(), inference)
}
fn check_all(src: &str) -> Vec<Diagnostic> {
let (hir, index, resolutions, inference) = build_with_inference(src);
check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
}
fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
let parsed = brink_syntax_native::parse(src);
assert!(parsed.errors().is_empty(), "{:?}", parsed.errors());
let (hir, manifest, _diag) = brink_ir::hir::lower_native::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());
let inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&resolutions,
None,
&BTreeMap::new(),
);
(hir, (*index).clone(), (*resolutions).clone(), inference)
}
fn check_all_native(src: &str) -> Vec<Diagnostic> {
let (hir, index, resolutions, inference) = build_native(src);
check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
}
fn check_assignments_all_native(src: &str) -> Vec<Diagnostic> {
let (hir, index, _resolutions, inference) = build_native(src);
check_assignments(&[(FileId(0), &hir)], &index, &inference)
}
#[test]
fn clean_construction_produces_no_diagnostics() {
let diags = check_all(
"STRUCT Point = #{x: float, y: float}\n\
=== main ===\n~ p = Point#{x: 1.0, y: 2.0}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn missing_field_is_e069_naming_the_field() {
let diags = check_all(
"STRUCT Point = #{x: float, y: float}\n\
=== main ===\n~ p = Point#{x: 1.0}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E069);
assert!(diags[0].message.contains('y'), "{:?}", diags[0].message);
}
#[test]
fn extra_field_is_e070_naming_the_field() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n~ p = Point#{x: 1.0, z: 2.0}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E070);
assert!(diags[0].message.contains('z'), "{:?}", diags[0].message);
}
#[test]
fn mistyped_field_is_e071_naming_the_field() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n~ p = Point#{x: \"hi\"}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
}
#[test]
fn int_initializer_for_a_float_field_is_the_legal_coercion() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n~ p = Point#{x: 1}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn global_variable_valued_initializer_fires_when_provably_mistyped() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
VAR v = \"hi\"\n=== main ===\n~ p = Point#{x: v}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
}
#[test]
fn global_variable_valued_initializer_of_the_right_type_is_clean() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
VAR v = 1.0\n=== main ===\n~ p = Point#{x: v}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn param_variable_valued_initializer_fires_when_provably_mistyped() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main(n) ===\n\
{n == \"a\":\n yes\n}\n~ p = Point#{x: n}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn annotated_fn_param_field_mismatch_is_e071() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main(n: string) ===\n~ p = Point#{x: n}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
}
#[test]
fn annotated_fn_param_field_agreement_stays_clean() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main(n: float) ===\n~ p = Point#{x: n}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn unused_param_variable_valued_initializer_stays_silent_when_unknown() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main(n) ===\n~ p = Point#{x: n}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn call_valued_initializer_fires_when_provably_mistyped() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== function label() ===\n~ return \"a\"\n\
=== main ===\n~ p = Point#{x: label()}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn call_valued_initializer_of_the_right_type_is_clean() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== function label() ===\n~ return 1.0\n\
=== main ===\n~ p = Point#{x: label()}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn index_valued_initializer_fires_when_provably_mistyped() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n\
~ temp xs = #[\"a\", \"b\"]\n~ p = Point#{x: xs[0]}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn index_valued_initializer_of_the_right_type_is_clean() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n\
~ temp xs = #[1.0, 2.0]\n~ p = Point#{x: xs[0]}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn index_valued_initializer_stays_silent_when_unknown() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== main(xs) ===\n~ p = Point#{x: xs[0]}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn unresolved_shape_name_is_not_double_reported_here() {
let diags = check_all("=== main ===\n~ p = Bogus#{x: 1}\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn nested_struct_literal_field_is_checked_by_shape_name() {
let diags = check_all(
"STRUCT Inner = #{v: float}\nSTRUCT Outer = #{inner: Inner}\n\
=== main ===\n~ o = Outer#{inner: Inner#{v: 1.0}}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn nested_struct_literal_mistyped_field_still_flags_outer() {
let diags = check_all(
"STRUCT Wrong = #{v: float}\nSTRUCT Inner = #{v: float}\nSTRUCT Outer = #{inner: Inner}\n\
=== main ===\n~ o = Outer#{inner: Wrong#{v: 1.0}}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn struct_literal_inside_var_initializer_is_checked() {
let diags = check_all("STRUCT Point = #{x: float}\nVAR p = Point#{x: \"hi\"}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn variable_valued_initializer_inside_var_initializer_uses_global_scope_only() {
let diags =
check_all("STRUCT Point = #{x: float}\nVAR other = \"hi\"\nVAR p = Point#{x: other}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn stitch_local_variable_valued_initializer_fires_when_provably_mistyped() {
let diags = check_all(
"STRUCT Point = #{x: float}\n\
=== room ===\n= inside\n~ temp t = \"hi\"\n~ p = Point#{x: t}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
}
#[test]
fn mistyped_variable_field_diagnostic_is_order_independent() {
let forward = "STRUCT Point = #{x: float, y: float}\n\
VAR v = \"hi\"\n=== main ===\n~ p = Point#{x: v, y: 1.0}\n-> DONE\n";
let reversed = "STRUCT Point = #{x: float, y: float}\n\
VAR v = \"hi\"\n=== main ===\n~ p = Point#{y: 1.0, x: v}\n-> DONE\n";
let diags_f = check_all(forward);
let diags_r = check_all(reversed);
assert_eq!(diags_f.len(), 1, "{diags_f:?}");
assert_eq!(diags_f[0].code, DiagnosticCode::E071);
assert!(diags_f[0].message.contains('x'), "{:?}", diags_f[0].message);
assert_eq!(diags_r.len(), 1, "{diags_r:?}");
assert_eq!(diags_r[0].code, DiagnosticCode::E071);
assert!(diags_r[0].message.contains('x'), "{:?}", diags_r[0].message);
}
fn check_assignments_all(src: &str) -> Vec<Diagnostic> {
let (hir, index, _resolutions, inference) = build_with_inference(src);
check_assignments(&[(FileId(0), &hir)], &index, &inference)
}
#[test]
fn field_assignment_mismatch_on_var_is_e063_naming_the_dotted_target() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float, y: float}\n\
VAR p: Point = Point#{x: 0.0, y: 0.0}\n\
=== main ===\n~ p.x = \"wrong\"\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
assert!(diags[0].message.contains("p.x"), "{:?}", diags[0].message);
}
#[test]
fn field_assignment_of_the_declared_type_is_clean() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float, y: float}\n\
VAR p: Point = Point#{x: 0.0, y: 0.0}\n\
=== main ===\n~ p.x = 1.0\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn field_assignment_int_initializer_for_a_float_field_is_the_legal_coercion() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\nVAR p: Point = Point#{x: 0.0}\n\
=== main ===\n~ p.x = 1\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn field_assignment_mismatch_on_annotated_temp_is_e063() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n~ temp p: Point = Point#{x: 0.0}\n~ p.x = \"wrong\"\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
}
#[test]
fn field_assignment_mismatch_on_unannotated_temp_with_construction_literal_initializer_is_e063_issue_2906()
{
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n~ temp p = Point#{x: 0.0}\n~ p.x = \"wrong\"\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
}
#[test]
fn field_assignment_on_genuinely_unresolved_temp_stays_silent() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
EXTERNAL make_point()\n\
=== main ===\n~ temp p = make_point()\n~ p.x = \"wrong\"\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn field_assignment_to_a_nonexistent_field_is_e185_issue_1944() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
VAR p: Point = Point#{x: 0.0}\n\
=== main ===\n~ p.bogus = \"wrong\"\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E185);
assert!(diags[0].message.contains("bogus"), "{:?}", diags[0].message);
}
#[test]
fn field_assignment_to_a_nonexistent_field_on_unresolved_receiver_stays_silent() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
=== function f(p) ===\n~ p.bogus = \"wrong\"\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_with_construction_literal_initializer_is_e185_issue_2906()
{
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
=== main ===\n~ temp p = Point#{x: 0.0}\n~ p.bogus = 1\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E185);
assert!(diags[0].message.contains("bogus"), "{:?}", diags[0].message);
}
#[test]
fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_reassigned_to_a_different_struct_stays_silent()
{
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
STRUCT Other = #{y: float}\n\
=== main ===\n~ temp p = Point#{x: 0.0}\n~ p = Other#{y: 1.0}\n\
~ p.bogus = 1\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn field_assignment_to_a_nonexistent_field_on_unannotated_temp_reassigned_to_an_unknown_shape_stays_silent()
{
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
EXTERNAL make_thing()\n\
=== main ===\n~ temp p = Point#{x: 0.0}\n~ p = make_thing()\n\
~ p.bogus = 1\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn dotted_assign_target_outer_temp_survives_a_lambda_local_shadow_of_the_same_name() {
let diags = check_assignments_all_native(
"struct Point { x: float }\n\
struct Other { y: float }\n\
fn main() {\n\
\x20 let p = Other { y: 0.0 };\n\
\x20 let f = ||: int { let p = Point { x: 0.0 }; 0 };\n\
\x20 p.y = 1.0;\n\
}\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn dotted_assign_target_stays_silent_after_a_ref_out_param_rebind() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
STRUCT Other = #{y: float}\n\
=== function reset(ref q) ===\n~ q = Other#{y: 1.0}\n~ return\n\
=== main ===\n~ temp p = Point#{x: 0.0}\n~ reset(ref p)\n\
~ p.bogus = 1\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn dotted_assign_target_reassignment_history_survives_a_later_redeclaration_of_the_same_name() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
EXTERNAL make_thing()\n\
=== main ===\n~ temp p = Point#{x: 0.0}\n~ p = make_thing()\n\
~ p.bogus = 1\n~ temp p = Point#{x: 0.0}\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn field_assignment_to_a_nonexistent_field_inside_a_block_stmt_is_e185_issue_1944() {
let diags = check_assignments_all(
"STRUCT Point = #{x: float}\n\
VAR p: Point = Point#{x: 0.0}\n\
=== main ===\n~ {\n p.bogus = \"wrong\"\n}\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E185);
}
#[test]
fn bare_var_assignment_is_not_double_reported_by_check_assignments() {
let diags = check_assignments_all("VAR v: int = 5\n=== main ===\n~ v = \"hi\"\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn duplicate_field_is_e084_naming_the_field() {
let (hir, _index) = build(
"STRUCT Point = #{x: float, y: float}\n\
=== main ===\n~ p = Point#{x: 1.0, x: 2.0, y: 3.0}\n-> DONE\n",
);
let diags = check_duplicates(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E084);
assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
}
#[test]
fn duplicate_field_points_at_the_repeated_occurrence_not_the_first() {
let src =
"STRUCT Point = #{x: float}\n=== main ===\n~ p = Point#{x: 1.0, x: 2.0}\n-> DONE\n";
let (hir, _index) = build(src);
let diags = check_duplicates(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
let second_x = src.rfind("x: 2.0").expect("second x initializer");
assert_eq!(usize::from(diags[0].range.start()), second_x);
}
#[test]
fn clean_construction_has_no_duplicate_diagnostic() {
let (hir, _index) = build(
"STRUCT Point = #{x: float, y: float}\n\
=== main ===\n~ p = Point#{x: 1.0, y: 2.0}\n-> DONE\n",
);
let diags = check_duplicates(&[(FileId(0), &hir)]);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn duplicate_field_flagged_even_under_gradual_and_unresolved_shape() {
let (hir, _index) = build("=== main ===\n~ p = Bogus#{x: 1, x: 2}\n-> DONE\n");
let diags = check_duplicates(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E084);
}
#[test]
fn duplicate_field_inside_var_initializer_is_checked() {
let (hir, _index) = build("STRUCT Point = #{x: float}\nVAR p = Point#{x: 1.0, x: 2.0}\n");
let diags = check_duplicates(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E084);
}
#[test]
fn a_duplicate_field_in_a_lambda_statement_of_a_var_initializer_is_reported() {
let (hir, _index, _res, _inf) = build_native(
"struct Point { x: float }\nvar f = ||: int {\n let p = Point { x: 1.0, x: 2.0 };\n 0\n};\n",
);
let diags = check_duplicates(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E084);
}
#[test]
fn a_mistyped_field_in_a_lambda_statement_of_a_var_initializer_is_reported() {
let diags = check_all_native(
"struct Point { x: float }\nvar f = ||: int {\n let p = Point { x: \"hi\" };\n 0\n};\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
assert!(diags[0].message.contains('x'), "{:?}", diags[0].message);
}
#[test]
fn a_mistyped_field_in_a_lambda_tail_of_a_var_initializer_is_still_reported() {
let diags = check_all_native(
"struct Point { x: float }\nvar f = ||: Point {\n let a = 1;\n Point { x: \"hi\" }\n};\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn lambda_param_shadowing_outer_local_of_a_different_type_is_not_misclassified() {
let diags = check_all_native(
"struct Point { x: float }\n\
fn build() {\n let x = [1, 2, 3];\n let f = |x: int| {\n let p = Point { x: x };\n };\n}\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn lambda_param_own_annotation_still_flags_a_genuine_mistype() {
let diags = check_all_native(
"struct Point { x: float }\n\
fn build() {\n let x = [1, 2, 3];\n let f = |x: string| {\n let p = Point { x: x };\n };\n}\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E071);
}
#[test]
fn three_way_duplicate_flags_every_repeat_after_the_first() {
let (hir, _index) = build(
"STRUCT Point = #{x: float}\n\
=== main ===\n~ p = Point#{x: 1.0, x: 2.0, x: 3.0}\n-> DONE\n",
);
let diags = check_duplicates(&[(FileId(0), &hir)]);
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E084));
}
fn build_project_with_std_homonym(
project_src: &str,
std_src: &str,
) -> (
FileId,
HirFile,
FileId,
HirFile,
SymbolIndex,
ResolutionMap,
InferenceResult,
) {
let project_file = FileId(0);
let std_file = FileId(1);
let project_parsed = brink_syntax::parse(project_src);
let (project_hir, project_manifest, _diag) = lower(project_file, &project_parsed.tree());
let std_parsed = brink_syntax::parse(std_src);
let (std_hir, std_manifest, _diag) = lower(std_file, &std_parsed.tree());
let mut modules = crate::ModuleMap::new();
modules.insert(
project_file,
crate::ResolvedModule {
name: "story::main".to_string(),
declared: true,
was: None,
},
);
modules.insert(
std_file,
crate::ResolvedModule {
name: "std::conventions::screenplay".to_string(),
declared: true,
was: None,
},
);
let (index, diag) = crate::symbol_index_with_modules(
&[(project_file, &project_manifest), (std_file, &std_manifest)],
&modules,
crate::Dialect::Brink,
false,
);
assert!(
diag.is_empty(),
"cross-declared-module `Cue`s must coexist with no diagnostic: {diag:?}"
);
let project_scope =
crate::ImportScope::new(Some("story::main".to_string()), &project_hir.imports);
let (project_resolutions, _diag) =
crate::resolve(project_file, &project_manifest, &index, &project_scope);
let std_scope = crate::ImportScope::new(
Some("std::conventions::screenplay".to_string()),
&std_hir.imports,
);
let (std_resolutions, _diag) = crate::resolve(std_file, &std_manifest, &index, &std_scope);
let mut resolutions: ResolutionMap = (*project_resolutions).clone();
resolutions.extend((*std_resolutions).iter().cloned());
let files = [(project_file, &project_hir), (std_file, &std_hir)];
let inference = crate::infer_project(&files, &index, &resolutions, None, &BTreeMap::new());
(
project_file,
project_hir,
std_file,
std_hir,
(*index).clone(),
resolutions,
inference,
)
}
#[test]
fn construction_check_resolves_the_referrers_own_shape_when_std_and_project_share_a_name() {
let project_src = "STRUCT Cue = #{speaker: string, voiceover: string}\n\
=== main ===\n~ p = Cue#{speaker: \"A\"}\n-> DONE\n";
let std_src = "STRUCT Cue = #{speaker: string}\nHello.\n";
let (project_file, project_hir, std_file, std_hir, index, resolutions, inference) =
build_project_with_std_homonym(project_src, std_src);
let files = [(project_file, &project_hir), (std_file, &std_hir)];
let diags = check(&files, &index, &inference, &resolutions);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E069);
assert!(
diags[0].message.contains("voiceover"),
"the missing-field diagnostic must name the PROJECT's own missing field \
(`voiceover`), proving the check validated the literal against the project's own \
2-field `Cue` shape rather than the coexisting file's 1-field one: {diags:?}"
);
}
#[test]
fn check_assignments_resolves_the_referrers_own_shape_when_std_and_project_share_a_name() {
let project_src = "STRUCT Cue = #{x: float}\n\
VAR p: Cue = Cue#{x: 0.0}\n\
=== main ===\n~ p.x = \"wrong\"\n-> DONE\n";
let std_src = "STRUCT Cue = #{x: string}\nHello.\n";
let (project_file, project_hir, std_file, std_hir, index, _resolutions, inference) =
build_project_with_std_homonym(project_src, std_src);
let files = [(project_file, &project_hir), (std_file, &std_hir)];
let diags = check_assignments(&files, &index, &inference);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
assert!(
diags[0].message.contains("float"),
"the mismatch must be reported against the PROJECT's own `float`-declared `x`, not \
std's `string`-declared one — which would silently accept the identically-typed \
\"wrong\" RHS and produce zero diagnostics: {diags:?}"
);
}
}