use std::collections::BTreeMap;
use brink_format::DefinitionId;
use brink_ir::hir::visit::{self, HirVisitor};
use brink_ir::{
Diagnostic, DiagnosticCode, Expr, FileId, HirFile, Knot, ResolutionMap, Stitch, SymbolIndex,
SymbolKind,
};
use rowan::TextRange;
use crate::annotations;
use crate::infer::{InferenceResult, InferredSig, Ty};
use crate::structs::{self, MistypeCtx};
#[must_use]
pub fn check(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
resolutions: &ResolutionMap,
) -> Vec<Diagnostic> {
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 = ContainsVisitor {
file,
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
}
struct ContainsVisitor<'a> {
file: FileId,
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 ContainsVisitor<'_> {
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 ContainsVisitor<'_> {
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) {
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_call(expr, self.file, &ctx, self.diagnostics);
}
fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
let pruned = structs::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();
}
}
fn check_call(expr: &Expr, file: FileId, ctx: &MistypeCtx<'_>, out: &mut Vec<Diagnostic>) {
let Expr::Call(path, args) = expr else {
return;
};
let [seg] = path.segments.as_slice() else {
return;
};
if seg.text != "contains" {
return;
}
if ctx.resolution_by_range.contains_key(&range_key(path.range)) {
return; }
let [container, needle] = args.as_slice() else {
return; };
let Some(Ty::Map(_, _)) = structs::classify_expr_ty(container, ctx) else {
return; };
let Some(kind) = non_key_domain_kind(needle, ctx) else {
return;
};
out.push(Diagnostic {
file,
range: path.range,
message: format!(
"{}: the needle is a statically-known `{kind}` value, which is outside the \
int/string/bool key domain — `contains` on this map always returns `false` \
(docs/decision-log.md 2026-07-12 ruling, issue #580)",
DiagnosticCode::E152.title(),
),
code: DiagnosticCode::E152,
});
}
fn non_key_domain_kind(expr: &Expr, ctx: &MistypeCtx<'_>) -> Option<&'static str> {
if let Some(kind) = literal_non_key_domain_kind(expr) {
return Some(kind);
}
let ty = structs::classify_expr_ty(expr, ctx)?;
non_key_domain_kind_for_ty(&ty)
}
fn literal_non_key_domain_kind(expr: &Expr) -> Option<&'static str> {
match expr {
Expr::DivertTarget(_) => Some("divert target"),
Expr::ListLiteral(_) => Some("list"),
Expr::FnLiteral(_) => Some("function"),
_ => None,
}
}
fn non_key_domain_kind_for_ty(ty: &Ty) -> Option<&'static str> {
match ty {
Ty::Float => Some("float"),
Ty::Divert => Some("divert target"),
Ty::List(_) => Some("list"),
Ty::Array(_) => Some("array"),
Ty::Map(_, _) => Some("map"),
Ty::Struct(_) => Some("struct"),
Ty::Fn(..) => Some("function"),
Ty::Handle(_) => Some("handle"),
Ty::Option(_) => Some("option"),
Ty::Range { .. } => Some("range"),
Ty::Weighted(_) => Some("weighted"),
Ty::Tower(_) => Some("tower"),
Ty::Content => Some("content"),
Ty::Int | Ty::String | Ty::Bool | Ty::Unknown | Ty::Conflicted => None,
}
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::hir::lower;
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 check_all_native(src: &str) -> Vec<Diagnostic> {
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(),
);
check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
}
#[test]
fn an_always_false_contains_in_a_lambda_statement_of_a_var_initializer_is_e152() {
let diags = check_all_native(
"var f = ||: int {\n let hit = contains(Map { 1: \"a\" }, 3.5);\n 0\n};\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E152);
assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
}
#[test]
fn an_always_false_contains_in_a_lambda_tail_of_a_var_initializer_is_still_e152() {
let diags = check_all_native(
"var f = ||: bool {\n let a = 1;\n contains(Map { 1: \"a\" }, 3.5)\n};\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E152);
}
#[test]
fn float_needle_against_a_map_literal_is_e152() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, 3.5)\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E152);
assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
}
#[test]
fn array_needle_against_a_map_literal_is_e152() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, #[1, 2])\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("array"), "{:?}", diags[0].message);
}
#[test]
fn map_needle_against_a_map_literal_is_e152() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, #{2: \"b\"})\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("map"), "{:?}", diags[0].message);
}
#[test]
fn struct_needle_against_a_map_literal_is_e152() {
let diags = check_all(
"STRUCT Point = #{x: int}\n\
=== main ===\n~ x = contains(#{1: \"a\"}, Point#{x: 1})\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0].message.contains("struct"),
"{:?}",
diags[0].message
);
}
#[test]
fn list_needle_against_a_map_literal_is_e152() {
let diags = check_all(
"LIST Colors = red, blue\n\
=== main ===\n~ x = contains(#{1: \"a\"}, (red))\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("list"), "{:?}", diags[0].message);
}
#[test]
fn divert_target_needle_against_a_map_literal_is_e152() {
let diags = check_all(
"=== main ===\n~ x = contains(#{1: \"a\"}, -> other)\n-> DONE\n\
=== other ===\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0].message.contains("divert target"),
"{:?}",
diags[0].message
);
}
#[test]
fn fn_literal_needle_against_a_map_literal_is_e152() {
let diags = check_all(
"=== main ===\n~ x = contains(#{1: \"a\"}, #fn(score))\n-> DONE\n\
=== score(x) ===\n~ return x\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0].message.contains("function"),
"{:?}",
diags[0].message
);
}
#[test]
fn int_needle_is_clean() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, 2)\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn string_needle_is_clean() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, \"k\")\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn bool_needle_is_clean() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, true)\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn key_domain_needle_of_a_mismatched_map_key_type_is_clean() {
let diags =
check_all("=== main ===\n~ x = contains(#{1: \"a\"}, \"not-an-int\")\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn float_needle_against_an_array_container_is_clean() {
let diags = check_all("=== main ===\n~ x = contains(#[1, 2], 3.5)\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn float_needle_against_an_unknown_container_is_clean() {
let diags = check_all("=== main(m) ===\n~ x = contains(m, 3.5)\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn global_map_var_container_with_float_needle_is_e152() {
let diags = check_all(
"VAR scores = #{1: \"a\"}\n=== main ===\n~ x = contains(scores, 3.5)\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
}
#[test]
fn global_const_map_container_with_array_needle_is_e152() {
let diags = check_all(
"CONST lookup = #{1: \"a\"}\n\
=== main ===\n~ x = contains(lookup, #[1])\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("array"), "{:?}", diags[0].message);
}
#[test]
fn global_float_var_needle_against_a_map_literal_is_e152() {
let diags =
check_all("VAR f = 3.5\n=== main ===\n~ x = contains(#{1: \"a\"}, f)\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
}
#[test]
fn global_int_var_needle_is_clean() {
let diags = check_all("VAR i = 5\n=== main ===\n~ x = contains(#{1: \"a\"}, i)\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn temp_array_needle_never_fires_self_unifies_or_conflicts() {
let diags = check_all(
"=== main ===\n~ temp xs = #[1, 2]\n~ x = contains(#{1: \"a\"}, xs)\n-> DONE\n",
);
assert!(
diags.is_empty(),
"a temp needle self-unifies/Conflicts against the container's key type, per the \
`infer::body` `contains` arm — see this test's module-doc-adjacent comment: {diags:?}"
);
}
#[test]
fn temp_map_container_fires_when_needle_is_out_of_domain() {
let diags =
check_all("=== main ===\n~ temp m = #{\"a\": 1}\n~ x = contains(m, 2.5)\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
}
#[test]
fn stitch_local_temp_needle_never_fires_self_unifies_or_conflicts() {
let diags = check_all(
"=== room ===\n= inside\n~ temp xs = #[1, 2]\n\
~ x = contains(#{1: \"a\"}, xs)\n-> DONE\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn annotated_fn_param_out_of_domain_needle_against_a_literal_container_is_not_visible_to_e152()
{
let src = "=== main(k: float) ===\n~ x = contains(#{1: \"a\"}, k)\n-> DONE\n";
let (hir, index, resolutions, inference) = build_with_inference(src);
let body = inference.bodies.values().next().expect("one inferable def");
assert_eq!(
body.locals.get("k"),
Some(&Ty::Int),
"documents the gap: the container's own key type, not `k`'s \
real `float` annotation, is what the finalized local reflects: \
{:?}",
body.locals
);
let diags = check(&[(FileId(0), &hir)], &index, &inference, &resolutions);
assert!(
diags.is_empty(),
"documents the gap: no E152 fires despite `k: float` disagreeing \
with `contains`'s int-keyed domain here: {diags:?}"
);
let mismatch_diags =
annotations::mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert!(mismatch_diags.is_empty(), "{mismatch_diags:?}");
}
#[test]
fn annotated_fn_param_out_of_domain_needle_against_a_literal_container_surfaces_as_e063_for_a_non_exempt_annotation()
{
let src = "=== main(k: string) ===\n~ x = contains(#{1: \"a\"}, k)\n-> DONE\n";
let (hir, index, resolutions, inference) = build_with_inference(src);
let diags = check(&[(FileId(0), &hir)], &index, &inference, &resolutions);
assert!(
diags.is_empty(),
"E152 itself still never sees this position: {diags:?}"
);
let mismatch_diags =
annotations::mismatches(&[(FileId(0), &hir)], &index, &inference, None);
assert_eq!(mismatch_diags.len(), 1, "{mismatch_diags:?}");
assert_eq!(mismatch_diags[0].code, DiagnosticCode::E063);
assert_eq!(
mismatch_diags[0].message,
"annotated type `string` disagrees with the type inferred from usage (`int`)",
"{mismatch_diags:?}"
);
}
#[test]
fn unresolved_param_needle_stays_silent_when_unknown() {
let diags = check_all("=== main(n) ===\n~ x = contains(#{1: \"a\"}, n)\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn call_valued_needle_fires_when_provably_out_of_domain() {
let diags = check_all(
"STRUCT Point = #{x: int}\n\
=== function shape() ===\n~ return Point#{x: 1}\n\
=== main ===\n~ x = contains(#{1: \"a\"}, shape())\n-> DONE\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0].message.contains("struct"),
"{:?}",
diags[0].message
);
}
#[test]
fn author_defined_contains_shadowing_the_builtin_is_never_checked() {
let diags = check_all(
"=== function contains(a, b) ===\n~ return true\n\
=== main ===\n~ x = contains(#{1: \"a\"}, 3.5)\n-> DONE\n",
);
assert!(
diags.is_empty(),
"a resolved call to the author's own `contains` must never be flagged: {diags:?}"
);
}
#[test]
fn wrong_arity_contains_call_is_not_flagged_here() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"})\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn contains_value_is_never_checked_by_this_pass() {
let diags = check_all("=== main ===\n~ x = contains_value(#{1: \"a\"}, 3.5)\n-> DONE\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn gradual_mode_never_reaches_this_pass_at_all() {
let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, 3.5)\n-> DONE\n");
assert_eq!(diags.len(), 1, "{diags:?}");
}
#[test]
fn lambda_param_shadowing_outer_array_local_is_not_misclassified_as_out_of_domain() {
let diags = check_all_native(
"fn build() {\n let k = [1, 2, 3];\n let f = |k: int| {\n contains(Map { 1: \"a\" }, k)\n };\n}\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn lambda_param_own_annotation_still_flags_a_genuine_out_of_domain_needle() {
let diags = check_all_native(
"fn build() {\n let k = [1, 2, 3];\n let f = |k: Map<int, int>| {\n contains(Map { 1: \"a\" }, k)\n };\n}\n",
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E152);
assert!(diags[0].message.contains("map"), "{:?}", diags[0].message);
}
}