mod body;
mod effects;
mod graph;
mod intrinsics;
mod ty;
pub(crate) use body::{is_string_numeric_concat, lambda_own_bindings};
pub(crate) use intrinsics::{intrinsic_effects, intrinsic_returns_option};
use std::collections::{BTreeMap, BTreeSet};
use brink_format::{DefinitionId, DefinitionTag};
use brink_ir::{
AssignOp, BaseType, Block, DocBlock, FileId, HirFile, HostManifest, Name, Param, ResolutionMap,
SymbolIndex, SymbolKind, TypeExpr, TypeRef,
};
use rowan::TextRange;
pub use effects::{EffectAtoms, EffectRow, solve_scc_effects};
pub use graph::{CallGraph, SccGraph, scc_graph};
pub use ty::{
CoalesceError, FnRow, TowerTy, Ty, assignable, coalesce, erase_fn_rows, ref_assignable, unify,
unify_all,
};
use body::{BodyCtx, infer_def_body};
use graph::topo_order;
fn range_key(range: TextRange) -> (u32, u32) {
(range.start().into(), range.end().into())
}
const MAX_SCC_ITERATIONS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InferredSig {
pub params: Vec<Ty>,
pub return_ty: Ty,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct BodyTypes {
pub params: Vec<(String, Ty)>,
pub locals: BTreeMap<String, Ty>,
pub return_ty: Ty,
pub has_value_return: bool,
pub value_calls: Vec<ValueCallFact>,
pub array_remove_calls: Vec<TextRange>,
pub direct_call_arg_mismatches: Vec<DirectCallArgMismatch>,
pub typed_assign_mismatches: Vec<TypedAssignMismatch>,
pub field_assign_mismatches: Vec<FieldAssignMismatch>,
pub lambda_annotation_mismatches: Vec<LambdaAnnotationMismatch>,
pub ufcs_call_args: Vec<UfcsCallArgs>,
pub lambda_escapes: Vec<LambdaEscapeSlot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UfcsCallArgs {
pub range: TextRange,
pub args: Vec<Ty>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypedAssignMismatch {
pub range: TextRange,
pub target: String,
pub expected: Ty,
pub found: Ty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LambdaAnnotationMismatch {
pub range: TextRange,
pub param_name: Option<String>,
pub expected: Ty,
pub found: Ty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LambdaEscapeSlot {
pub range: TextRange,
pub ty: Ty,
pub annotated: bool,
pub slot_label: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldAssignMismatch {
pub root: String,
pub root_ty: Ty,
pub path: Vec<Name>,
pub op: AssignOp,
pub found: Ty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectCallArgMismatch {
pub range: TextRange,
pub callee: String,
pub index: usize,
pub expected: Ty,
pub found: Ty,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValueCallFact {
pub range: TextRange,
pub callee: String,
pub kind: ValueCallKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValueCallKind {
UnknownCallee,
ConflictedCallee,
NotCallable(Ty),
ArityMismatch { expected: usize, got: usize },
ArgMismatch {
index: usize,
expected: Ty,
found: Ty,
},
OverBind { available: usize, got: usize },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InferenceResult {
pub signatures: BTreeMap<DefinitionId, InferredSig>,
pub bodies: BTreeMap<DefinitionId, BodyTypes>,
}
impl From<crate::InferredType> for Ty {
fn from(t: crate::InferredType) -> Self {
match t {
crate::InferredType::Int => Ty::Int,
crate::InferredType::Float => Ty::Float,
crate::InferredType::Bool => Ty::Bool,
crate::InferredType::String => Ty::String,
crate::InferredType::Divert => Ty::Divert,
crate::InferredType::List(name) => Ty::List(name),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Def<'a> {
pub id: DefinitionId,
pub file: FileId,
pub params: &'a [Param],
pub body: &'a Block,
pub return_annotation: Option<&'a TypeExpr>,
pub native: bool,
}
pub(crate) fn index_resolutions_by_file(
resolutions: &ResolutionMap,
) -> BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> {
let mut by_file: BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> = BTreeMap::new();
for r in resolutions {
by_file
.entry(r.file)
.or_default()
.insert(range_key(r.range), r.target);
}
by_file
}
fn index_module_by_file(index: &SymbolIndex) -> BTreeMap<FileId, Option<String>> {
let mut by_file: BTreeMap<FileId, Option<String>> = BTreeMap::new();
for info in index.symbols.values() {
if info.scope.is_some() {
continue;
}
by_file
.entry(info.file)
.or_insert_with(|| info.module.clone());
}
by_file
}
pub(crate) fn collect_globals(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
manifest: Option<&HostManifest>,
) -> BTreeMap<DefinitionId, Ty> {
let mut globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
for (&id, info) in &index.symbols {
if matches!(info.kind, SymbolKind::Variable | SymbolKind::Constant)
&& let Some(sig) = crate::signature::signature(id, index, files, manifest)
&& let Some(ty) = sig.value_ty.clone()
{
globals.insert(id, ty);
}
}
globals
}
pub fn collect_external_sigs(
index: &SymbolIndex,
manifest: Option<&HostManifest>,
inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
) -> BTreeMap<DefinitionId, InferredSig> {
let mut sigs = BTreeMap::new();
let (types, registered) = crate::manifest_maps(manifest);
for (&id, info) in &index.symbols {
if info.kind != SymbolKind::External {
continue;
}
let inline = inline_docs.get(&(SymbolKind::External, info.name.clone()));
let reg = registered.get(info.name.as_str()).copied();
if inline.is_none() && reg.is_none() {
continue; }
let params: Vec<Ty> = info
.params
.iter()
.enumerate()
.map(|(i, p)| {
let tref: Option<&TypeRef> = inline
.and_then(|d| d.params.iter().find(|(n, _)| n == &p.name).map(|(_, t)| t))
.or_else(|| reg.and_then(|r| r.params.get(i).map(|mp| &mp.ty)));
tref.map_or(Ty::Unknown, |t| type_ref_to_ty(t, &types))
})
.collect();
let return_ty = inline
.and_then(|d| d.returns.as_ref())
.or_else(|| reg.map(|r| &r.returns))
.map_or(Ty::Unknown, |t| type_ref_to_ty(t, &types));
sigs.insert(id, InferredSig { params, return_ty });
}
sigs
}
fn type_ref_to_ty(t: &TypeRef, types: &BTreeMap<String, brink_ir::SemanticTypeDef>) -> Ty {
use crate::type_resolution::{TypeShape, classify};
match classify(t, types) {
TypeShape::Unspecified
| TypeShape::Unregistered
| TypeShape::Base(BaseType::Void | BaseType::Handle) => Ty::Unknown,
TypeShape::Base(BaseType::String) => Ty::String,
TypeShape::Base(BaseType::Int) => Ty::Int,
TypeShape::Base(BaseType::Float) => Ty::Float,
TypeShape::Base(BaseType::Bool) => Ty::Bool,
TypeShape::Registered(def) => match def.base {
BaseType::String => Ty::String,
BaseType::Int => Ty::Int,
BaseType::Float => Ty::Float,
BaseType::Bool => Ty::Bool,
BaseType::Void => Ty::Unknown,
BaseType::Handle => Ty::Handle(t.0.trim().to_string()),
},
}
}
pub(crate) fn root_content_def_id(file: FileId) -> DefinitionId {
DefinitionId::new(DefinitionTag::LocalVar, u64::from(file.0))
}
pub(crate) fn collect_defs<'a>(
files: &[(FileId, &'a HirFile)],
index: &SymbolIndex,
) -> Vec<Def<'a>> {
let mut def_of: BTreeMap<(FileId, SymbolKind, String), DefinitionId> = BTreeMap::new();
for (&id, info) in &index.symbols {
def_of.insert((info.file, info.kind, info.name.clone()), id);
}
let mut defs: Vec<Def<'a>> = Vec::new();
for &(file_id, hir) in files {
if !hir.root_content.stmts.is_empty() {
let synthetic_id = root_content_def_id(file_id);
defs.push(Def {
id: synthetic_id,
file: file_id,
params: &[],
body: &hir.root_content,
return_annotation: None,
native: hir.native,
});
}
for knot in &hir.knots {
let knot_symbol_kind = knot.symbol_kind();
if let Some(&id) = def_of.get(&(file_id, knot_symbol_kind, knot.name.text.clone())) {
defs.push(Def {
id,
file: file_id,
params: &knot.params,
body: &knot.body,
return_annotation: knot.return_type.as_ref(),
native: hir.native,
});
}
for stitch in &knot.stitches {
let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
if let Some(&id) = def_of.get(&(file_id, SymbolKind::Stitch, qualified)) {
defs.push(Def {
id,
file: file_id,
params: &stitch.params,
body: &stitch.body,
return_annotation: stitch.return_type.as_ref(),
native: hir.native,
});
}
}
}
}
defs.sort_by_key(|d| d.id);
defs
}
struct ProjectCtx<'a> {
index: &'a SymbolIndex,
globals: &'a BTreeMap<DefinitionId, Ty>,
by_file: &'a BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>>,
inferable: &'a BTreeSet<DefinitionId>,
list_names: BTreeSet<String>,
struct_names: BTreeSet<String>,
handle_names: BTreeSet<String>,
file_modules: BTreeMap<FileId, Option<String>>,
}
impl<'a> ProjectCtx<'a> {
fn new(
index: &'a SymbolIndex,
globals: &'a BTreeMap<DefinitionId, Ty>,
by_file: &'a BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>>,
inferable: &'a BTreeSet<DefinitionId>,
manifest: Option<&HostManifest>,
) -> Self {
Self {
index,
globals,
by_file,
inferable,
list_names: crate::annotations::declared_list_names(index),
struct_names: crate::annotations::declared_struct_names(index),
handle_names: crate::annotations::declared_handle_kinds(manifest),
file_modules: index_module_by_file(index),
}
}
fn body_ctx(
&'a self,
def: &Def<'_>,
known_sigs: &'a BTreeMap<DefinitionId, InferredSig>,
) -> BodyCtx<'a> {
static EMPTY: BTreeMap<(u32, u32), DefinitionId> = BTreeMap::new();
BodyCtx {
resolution_by_range: self.by_file.get(&def.file).unwrap_or(&EMPTY),
index: self.index,
globals: self.globals,
known_sigs,
inferable: self.inferable,
list_names: &self.list_names,
struct_names: &self.struct_names,
handle_names: &self.handle_names,
native: def.native,
referrer_module: self
.file_modules
.get(&def.file)
.and_then(|module| module.as_deref()),
}
}
}
fn build_call_graph(defs: &[Def<'_>], ctx: &ProjectCtx<'_>) -> CallGraph {
let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let mut graph = CallGraph::new();
for d in defs {
graph.add_node(d.id);
let body_ctx = ctx.body_ctx(d, &no_sigs);
let result = infer_def_body(d, &body_ctx);
for callee in result.calls {
graph.add_edge(d.id, callee);
}
}
graph
}
fn solve_one_batch(
batch: &BTreeSet<DefinitionId>,
by_id: &BTreeMap<DefinitionId, &Def<'_>>,
ctx: &ProjectCtx<'_>,
known_sigs: &mut BTreeMap<DefinitionId, InferredSig>,
) -> BTreeMap<DefinitionId, BodyTypes> {
for &id in batch {
known_sigs.entry(id).or_insert_with(|| {
let param_count = by_id.get(&id).map_or(0, |d| d.params.len());
InferredSig {
params: vec![Ty::Unknown; param_count],
return_ty: Ty::Unknown,
}
});
}
let mut last_round: BTreeMap<DefinitionId, body::BodyResult> = BTreeMap::new();
for _round in 0..MAX_SCC_ITERATIONS {
let mut round: BTreeMap<DefinitionId, body::BodyResult> = BTreeMap::new();
let mut changed = false;
for &id in batch {
let Some(&d) = by_id.get(&id) else { continue };
let body_ctx = ctx.body_ctx(d, known_sigs);
let result = infer_def_body(d, &body_ctx);
let new_sig = InferredSig {
params: result.params.iter().map(|(_, t)| t.clone()).collect(),
return_ty: result.return_ty.clone(),
};
if known_sigs.get(&id) != Some(&new_sig) {
changed = true;
}
known_sigs.insert(id, new_sig);
round.insert(id, result);
}
last_round = round;
if !changed {
break;
}
}
last_round
.into_iter()
.map(|(id, result)| {
(
id,
BodyTypes {
params: result.params,
locals: result.locals,
return_ty: result.return_ty,
has_value_return: result.has_value_return,
value_calls: result.value_calls,
array_remove_calls: result.array_remove_calls,
direct_call_arg_mismatches: result.direct_call_arg_mismatches,
typed_assign_mismatches: result.typed_assign_mismatches,
field_assign_mismatches: result.field_assign_mismatches,
lambda_annotation_mismatches: result.lambda_annotation_mismatches,
ufcs_call_args: result.ufcs_call_args,
lambda_escapes: result.lambda_escapes,
},
)
})
.collect()
}
fn solve_batches(
batches: &[BTreeSet<DefinitionId>],
by_id: &BTreeMap<DefinitionId, &Def<'_>>,
ctx: &ProjectCtx<'_>,
external_sigs: &BTreeMap<DefinitionId, InferredSig>,
) -> (
BTreeMap<DefinitionId, InferredSig>,
BTreeMap<DefinitionId, BodyTypes>,
) {
let mut known_sigs: BTreeMap<DefinitionId, InferredSig> = external_sigs.clone();
let mut bodies: BTreeMap<DefinitionId, BodyTypes> = BTreeMap::new();
for batch in batches {
let batch_bodies = solve_one_batch(batch, by_id, ctx, &mut known_sigs);
bodies.extend(batch_bodies);
}
(known_sigs, bodies)
}
#[must_use]
pub fn infer_project(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
manifest: Option<&HostManifest>,
inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
) -> InferenceResult {
let by_file = index_resolutions_by_file(resolutions);
let globals = collect_globals(files, index, manifest);
let defs = collect_defs(files, index);
let inferable: BTreeSet<DefinitionId> = defs.iter().map(|d| d.id).collect();
let by_id: BTreeMap<DefinitionId, &Def<'_>> = defs.iter().map(|d| (d.id, d)).collect();
let ctx = ProjectCtx::new(index, &globals, &by_file, &inferable, manifest);
let external_sigs = collect_external_sigs(index, manifest, inline_docs);
let graph = build_call_graph(&defs, &ctx);
let batches = topo_order(&graph);
let (signatures, bodies) = solve_batches(&batches, &by_id, &ctx, &external_sigs);
InferenceResult { signatures, bodies }
}
#[must_use]
pub fn inferable_defs(files: &[(FileId, &HirFile)], index: &SymbolIndex) -> BTreeSet<DefinitionId> {
collect_defs(files, index).iter().map(|d| d.id).collect()
}
#[must_use]
pub fn inferable_defs_from_index(index: &SymbolIndex) -> BTreeSet<DefinitionId> {
index
.symbols
.iter()
.filter(|(_, info)| matches!(info.kind, SymbolKind::Knot | SymbolKind::Stitch))
.map(|(&id, _)| id)
.collect()
}
#[must_use]
pub fn def_body(
def: DefinitionId,
declaring_file_hir: &[(FileId, &HirFile)],
index: &SymbolIndex,
) -> Option<(Vec<Param>, Option<TypeExpr>, Block)> {
collect_defs(declaring_file_hir, index)
.into_iter()
.find(|d| d.id == def)
.map(|d| {
(
d.params.to_vec(),
d.return_annotation.cloned(),
d.body.clone(),
)
})
}
#[must_use]
pub fn call_edges(
def: DefinitionId,
declaring_file_hir: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
inferable: &BTreeSet<DefinitionId>,
manifest: Option<&HostManifest>,
) -> BTreeSet<DefinitionId> {
let by_file = index_resolutions_by_file(resolutions);
let defs = collect_defs(declaring_file_hir, index);
let Some(d) = defs.iter().find(|d| d.id == def) else {
return BTreeSet::new();
};
let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
let ctx = ProjectCtx::new(index, &empty_globals, &by_file, inferable, manifest);
let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let body_ctx = ctx.body_ctx(d, &no_sigs);
infer_def_body(d, &body_ctx).calls
}
#[must_use]
pub fn referenced_globals(
def: DefinitionId,
declaring_file_hir: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
manifest: Option<&HostManifest>,
) -> BTreeSet<DefinitionId> {
let by_file = index_resolutions_by_file(resolutions);
let defs = collect_defs(declaring_file_hir, index);
let Some(d) = defs.iter().find(|d| d.id == def) else {
return BTreeSet::new();
};
let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
let empty_inferable: BTreeSet<DefinitionId> = BTreeSet::new();
let ctx = ProjectCtx::new(index, &empty_globals, &by_file, &empty_inferable, manifest);
let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let body_ctx = ctx.body_ctx(d, &no_sigs);
infer_def_body(d, &body_ctx).referenced_globals
}
#[must_use]
pub fn def_effect_atoms(
def: DefinitionId,
declaring_file_hir: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
inferable: &BTreeSet<DefinitionId>,
manifest: Option<&HostManifest>,
) -> EffectAtoms {
let by_file = index_resolutions_by_file(resolutions);
let defs = collect_defs(declaring_file_hir, index);
let Some(d) = defs.iter().find(|d| d.id == def) else {
return EffectAtoms::default();
};
let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
let ctx = ProjectCtx::new(index, &empty_globals, &by_file, inferable, manifest);
let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let body_ctx = ctx.body_ctx(d, &no_sigs);
let result = infer_def_body(d, &body_ctx);
EffectAtoms {
reads: result.referenced_globals,
writes: result.effect_writes,
calls: result.external_calls,
direct_calls: result.calls,
creates_fn_values: result.created_fn_values,
opaque: result.effect_opaque,
emits: result.effect_emits,
tags: result.effect_tags,
faults: result.effect_faults,
faults_refined: result.effect_faults_refined,
param_holes: result.param_holes,
call_fn_args: result.call_fn_args,
}
}
#[must_use]
pub fn effects_project(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
manifest: Option<&HostManifest>,
) -> BTreeMap<DefinitionId, EffectRow> {
let defs = collect_defs(files, index);
let inferable: BTreeSet<DefinitionId> = defs.iter().map(|d| d.id).collect();
let atoms: BTreeMap<DefinitionId, EffectAtoms> = defs
.iter()
.map(|d| {
(
d.id,
def_effect_atoms(d.id, files, index, resolutions, &inferable, manifest),
)
})
.collect();
let mut graph = CallGraph::new();
for (&id, a) in &atoms {
graph.add_node(id);
for &callee in a.direct_calls.iter().chain(&a.creates_fn_values) {
graph.add_edge(id, callee);
}
}
let batches = topo_order(&graph);
let mut rows: BTreeMap<DefinitionId, EffectRow> = BTreeMap::new();
for batch in &batches {
let solved = solve_scc_effects(batch, &atoms, &rows);
rows.extend(solved);
}
rows
}
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "the FG-2 per-SCC solve boundary (issue #631) — each parameter is an \
independently-narrowed input `brink-db`'s solve_scc_query assembles from its \
own per-def salsa queries; bundling them into a struct would just move the same \
shape one level down for no clarity gain, and this is the one call site (the \
salsa wrapper) plus tests, not a widely-called API"
)]
pub fn solve_scc(
batch: &BTreeSet<DefinitionId>,
defs: &[Def<'_>],
index: &SymbolIndex,
resolutions: &ResolutionMap,
globals: &BTreeMap<DefinitionId, Ty>,
inferable: &BTreeSet<DefinitionId>,
mut known_sigs: BTreeMap<DefinitionId, InferredSig>,
manifest: Option<&HostManifest>,
inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
) -> (
BTreeMap<DefinitionId, InferredSig>,
BTreeMap<DefinitionId, BodyTypes>,
) {
known_sigs.extend(collect_external_sigs(index, manifest, inline_docs));
let by_file = index_resolutions_by_file(resolutions);
let by_id: BTreeMap<DefinitionId, &Def<'_>> = defs.iter().map(|d| (d.id, d)).collect();
let ctx = ProjectCtx::new(index, globals, &by_file, inferable, manifest);
let bodies = solve_one_batch(batch, &by_id, &ctx, &mut known_sigs);
let signatures: BTreeMap<DefinitionId, InferredSig> = batch
.iter()
.filter_map(|id| known_sigs.get(id).map(|sig| (*id, sig.clone())))
.collect();
(signatures, bodies)
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::lower;
fn build(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())
}
fn build_with_module(src: &str, module_name: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
let parsed = brink_syntax::parse(src);
let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
let mut modules = crate::ModuleMap::new();
modules.insert(
FileId(0),
crate::ResolvedModule {
name: module_name.to_string(),
declared: true,
was: None,
},
);
let (index, _diag) = crate::symbol_index_with_modules(
&[(FileId(0), &manifest)],
&modules,
crate::Dialect::Brink,
false,
);
let scope = crate::ImportScope::new(Some(module_name.to_string()), &hir.imports);
let (resolutions, _diag) = crate::resolve(FileId(0), &manifest, &index, &scope);
(hir, (*index).clone(), (*resolutions).clone())
}
fn build_with_docs(
src: &str,
) -> (
HirFile,
SymbolIndex,
ResolutionMap,
BTreeMap<(SymbolKind, String), DocBlock>,
) {
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 inline_docs = crate::project_inline_docs(&[(FileId(0), &manifest)]);
(hir, (*index).clone(), (*resolutions).clone(), inline_docs)
}
fn sig_of<'a>(result: &'a InferenceResult, index: &SymbolIndex, name: &str) -> &'a InferredSig {
let id = index
.by_name
.get(name)
.and_then(|ids| ids.first())
.copied()
.expect("no def with this name");
result
.signatures
.get(&id)
.expect("no inferred signature for this def")
}
#[test]
fn param_type_inferred_from_arithmetic_use() {
let (hir, index, res) = build("=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "heal");
assert_eq!(sig.params, vec![Ty::Int]);
}
#[test]
fn param_type_inferred_from_comparison_with_float_literal() {
let (hir, index, res) = build("=== spend(gold) ===\n{gold > 1.5:\n ok\n}\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "spend");
assert_eq!(sig.params, vec![Ty::Float]);
}
#[test]
fn floating_stitch_body_is_inferred() {
let (hir, index, res) = build("= heal(hp)\n~ temp x = hp + 1\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "heal");
assert_eq!(sig.params, vec![Ty::Int]);
}
#[test]
fn floating_stitch_coexists_with_real_knot_and_its_nested_stitch() {
let (hir, index, res) = build(
"= intro(hp)\n~ temp x = hp + 1\n-> DONE\n\
=== knot_a(gold) ===\n{gold > 1.5:\n ok\n}\n-> stitch_a ->\n\
= stitch_a(silver)\n~ temp y = silver + 1\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
assert_eq!(sig_of(&result, &index, "intro").params, vec![Ty::Int]);
assert_eq!(sig_of(&result, &index, "knot_a").params, vec![Ty::Float]);
let stitch_a_id = index
.by_name
.get("knot_a.stitch_a")
.and_then(|ids| ids.first())
.copied()
.expect("no def for knot_a.stitch_a");
let stitch_a_sig = result
.signatures
.get(&stitch_a_id)
.expect("no inferred signature for knot_a.stitch_a");
assert_eq!(stitch_a_sig.params, vec![Ty::Int]);
}
#[test]
fn body_ctx_threads_referrer_module_for_a_real_def() {
let (hir, index, _res) = build_with_module(
"=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n",
"std::conventions::screenplay",
);
let id = index
.by_name
.get("heal")
.and_then(|ids| ids.first())
.copied()
.expect("heal def indexed");
let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
let by_file: BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> = BTreeMap::new();
let inferable: BTreeSet<DefinitionId> = [id].into_iter().collect();
let ctx = ProjectCtx::new(&index, &empty_globals, &by_file, &inferable, None);
let defs = collect_defs(&[(FileId(0), &hir)], &index);
let def = defs.iter().find(|d| d.id == id).expect("def found");
let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let body_ctx = ctx.body_ctx(def, &no_sigs);
assert_eq!(
body_ctx.referrer_module,
Some("std::conventions::screenplay"),
"a real def's referrer_module must come from its own declared module"
);
}
#[test]
fn body_ctx_threads_referrer_module_for_the_synthetic_root_content_def() {
let (hir, index, _res) = build_with_module(
"Hello.\n-> DONE\n=== knot_a ===\nworld\n-> DONE\n",
"std::conventions::screenplay",
);
assert!(
!hir.root_content.stmts.is_empty(),
"fixture must have non-empty root content to mint the synthetic def"
);
let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
let by_file: BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> = BTreeMap::new();
let inferable: BTreeSet<DefinitionId> = BTreeSet::new();
let ctx = ProjectCtx::new(&index, &empty_globals, &by_file, &inferable, None);
let defs = collect_defs(&[(FileId(0), &hir)], &index);
let synthetic = defs
.iter()
.find(|d| !index.symbols.contains_key(&d.id))
.expect("synthetic root-content def present");
let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let body_ctx = ctx.body_ctx(synthetic, &no_sigs);
assert_eq!(
body_ctx.referrer_module,
Some("std::conventions::screenplay"),
"the synthetic root-content def's referrer_module must still resolve, keyed by \
file rather than the def's own (absent) index entry"
);
}
#[test]
fn unused_param_is_unknown_and_legal() {
let (hir, index, res) = build("=== noop(x) ===\nHello.\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "noop");
assert_eq!(sig.params, vec![Ty::Unknown]);
}
#[test]
fn remove_at_index_arg_does_not_narrow_against_the_array_element_type() {
let (hir, index, res) = build(
"=== function drop_at(i) ===\n~ {\n temp arr = #[\"a\", \"b\", \"c\"]\n remove_at(arr, i)\n}\n~ return 0\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let drop_at_id = index
.by_name
.get("drop_at")
.and_then(|ids| ids.first())
.copied()
.expect("drop_at");
let body = result.bodies.get(&drop_at_id).expect("drop_at body");
assert_eq!(
body.locals.get("arr"),
Some(&Ty::Array(Box::new(Ty::String))),
"fixture sanity: arr must actually be known as an array of strings, or this test \
can't distinguish the fix from the bug it guards"
);
let sig = sig_of(&result, &index, "drop_at");
assert_eq!(
sig.params,
vec![Ty::Unknown],
"remove_at's index argument must not narrow against the array's element type"
);
}
#[test]
fn return_type_inferred_from_return_statement() {
let (hir, index, res) = build("=== function double(x) ===\n~ return x + x\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "double");
assert_eq!(sig.return_ty, Ty::Unknown);
}
fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
let parse = brink_syntax_native::parse(src);
assert!(
parse.errors().is_empty(),
"fixture must parse cleanly: {:?}",
parse.errors()
);
let tree = parse.tree();
let (hir, manifest, _diag) = brink_ir::hir::lower_native::lower(FileId(0), &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 coalesce_lhs_param_narrows_to_option_of_the_rhs_type() {
let (hir, index, res) = build_native("fn f(x) {\n return x or 0;\n}\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "f");
assert_eq!(sig.params, vec![Ty::Option(Box::new(Ty::Int))]);
}
#[test]
fn call_site_propagates_callee_param_type_to_caller_local() {
let (hir, index, res) = build(
"=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n big\n}\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let use_it = sig_of(&result, &index, "use_it");
assert_eq!(use_it.params, vec![Ty::Float]);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(main_body.locals.get("v"), Some(&Ty::Float));
}
fn audio_manifest_with_external(param_kind: &str) -> brink_ir::HostManifest {
brink_ir::HostManifest {
markup: Vec::new(),
types: vec![
brink_ir::SemanticTypeDef {
name: "AudioInstance".to_string(),
base: brink_ir::BaseType::Handle,
constraint: None,
values: None,
widget: None,
},
brink_ir::SemanticTypeDef {
name: "Timer".to_string(),
base: brink_ir::BaseType::Handle,
constraint: None,
values: None,
widget: None,
},
],
externals: vec![brink_ir::ManifestExternal {
name: "play_sound".to_string(),
params: vec![brink_ir::ManifestParam {
name: "inst".to_string(),
ty: brink_ir::TypeRef(param_kind.to_string()),
}],
returns: brink_ir::TypeRef::default(),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
}],
}
}
#[test]
fn external_call_propagates_declared_handle_kind_to_caller_local() {
let (hir, index, res) = build(
"EXTERNAL play_sound(inst)\n=== main ===\n~ temp s = get_sound(1)\n\
~ play_sound(s)\n-> DONE\n=== function get_sound(id): Handle<AudioInstance> ===\n~ return id\n",
);
let manifest = audio_manifest_with_external("AudioInstance");
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("s"),
Some(&Ty::Handle("AudioInstance".to_string())),
"s picks up its own declared return kind cleanly: {main_body:?}"
);
}
#[test]
fn external_call_with_cross_kind_argument_conflicts_the_caller_local() {
let (hir, index, res) = build(
"EXTERNAL play_sound(inst)\n=== main ===\n~ temp t = get_timer(1)\n\
~ play_sound(t)\n-> DONE\n=== function get_timer(id): Handle<Timer> ===\n~ return id\n",
);
let manifest = audio_manifest_with_external("AudioInstance");
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("t"),
Some(&Ty::Conflicted),
"t is Timer-kinded but play_sound declares AudioInstance: {main_body:?}"
);
}
#[test]
fn external_call_with_no_manifest_stays_unknown() {
let (hir, index, res) = build(
"EXTERNAL play_sound(inst)\n=== main ===\n~ temp t = 1\n~ play_sound(t)\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(main_body.locals.get("t"), Some(&Ty::Int));
}
#[test]
fn external_call_with_unregistered_name_stays_unknown() {
let (hir, index, res) = build(
"EXTERNAL other_call(inst)\n=== main ===\n~ temp t = 1\n~ other_call(t)\n-> DONE\n",
);
let manifest = audio_manifest_with_external("AudioInstance");
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(main_body.locals.get("t"), Some(&Ty::Int));
}
fn manifest_with_scalar_and_handle_types() -> brink_ir::HostManifest {
let mut manifest = audio_manifest_with_external("AudioInstance");
manifest.types.push(brink_ir::SemanticTypeDef {
name: "switch_id".to_string(),
base: brink_ir::BaseType::Int,
constraint: None,
values: None,
widget: None,
});
manifest
}
#[test]
fn external_call_scalar_semantic_type_param_propagates_to_caller_local() {
let mut manifest = manifest_with_scalar_and_handle_types();
manifest.externals.push(brink_ir::ManifestExternal {
name: "toggle".to_string(),
params: vec![brink_ir::ManifestParam {
name: "id".to_string(),
ty: brink_ir::TypeRef("switch_id".to_string()),
}],
returns: brink_ir::TypeRef::default(),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
});
let (hir, index, res) =
build("EXTERNAL toggle(id)\n=== main ===\n~ temp s = 1\n~ toggle(s)\n-> DONE\n");
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("s"),
Some(&Ty::Int),
"s unifies cleanly against toggle's declared switch_id (base int): {main_body:?}"
);
}
#[test]
fn external_call_scalar_semantic_type_mismatch_conflicts_the_caller_local() {
let mut manifest = manifest_with_scalar_and_handle_types();
manifest.externals.push(brink_ir::ManifestExternal {
name: "toggle".to_string(),
params: vec![brink_ir::ManifestParam {
name: "id".to_string(),
ty: brink_ir::TypeRef("switch_id".to_string()),
}],
returns: brink_ir::TypeRef::default(),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
});
let (hir, index, res) = build(
"EXTERNAL toggle(id)\n=== main ===\n~ temp s = \"harbor\"\n~ toggle(s)\n-> DONE\n",
);
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("s"),
Some(&Ty::Conflicted),
"s is a string but toggle declares switch_id (base int): {main_body:?}"
);
}
#[test]
fn inline_only_external_param_type_propagates_to_caller_local() {
let (hir, index, res, inline_docs) = build_with_docs(
"/// @param inst {AudioInstance}\n\
EXTERNAL play_sound(inst)\n\
=== main ===\n~ temp s = get_sound(1)\n~ play_sound(s)\n-> DONE\n\
=== function get_sound(id): Handle<AudioInstance> ===\n~ return id\n",
);
let manifest = brink_ir::HostManifest {
markup: Vec::new(),
types: vec![
brink_ir::SemanticTypeDef {
name: "AudioInstance".to_string(),
base: brink_ir::BaseType::Handle,
constraint: None,
values: None,
widget: None,
},
brink_ir::SemanticTypeDef {
name: "Timer".to_string(),
base: brink_ir::BaseType::Handle,
constraint: None,
values: None,
widget: None,
},
],
externals: Vec::new(), };
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&inline_docs,
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("s"),
Some(&Ty::Handle("AudioInstance".to_string())),
"s unifies cleanly against play_sound's inline-doc-declared AudioInstance: {main_body:?}"
);
}
#[test]
fn inline_only_external_cross_kind_argument_conflicts_the_caller_local() {
let (hir, index, res, inline_docs) = build_with_docs(
"/// @param inst {AudioInstance}\n\
EXTERNAL play_sound(inst)\n\
=== main ===\n~ temp t = get_timer(1)\n~ play_sound(t)\n-> DONE\n\
=== function get_timer(id): Handle<Timer> ===\n~ return id\n",
);
let manifest = brink_ir::HostManifest {
markup: Vec::new(),
types: vec![
brink_ir::SemanticTypeDef {
name: "AudioInstance".to_string(),
base: brink_ir::BaseType::Handle,
constraint: None,
values: None,
widget: None,
},
brink_ir::SemanticTypeDef {
name: "Timer".to_string(),
base: brink_ir::BaseType::Handle,
constraint: None,
values: None,
widget: None,
},
],
externals: Vec::new(),
};
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&inline_docs,
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("t"),
Some(&Ty::Conflicted),
"t is Timer-kinded but play_sound's inline doc declares AudioInstance: {main_body:?}"
);
}
#[test]
fn external_call_return_position_kind_mismatch_conflicts_the_caller_local() {
let mut manifest = audio_manifest_with_external("AudioInstance");
manifest.externals.push(brink_ir::ManifestExternal {
name: "spawn_timer".to_string(),
params: Vec::new(),
returns: brink_ir::TypeRef("Timer".to_string()),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
});
let (hir, index, res) = build(
"EXTERNAL play_sound(inst)\nEXTERNAL spawn_timer()\n\
=== main ===\n~ temp x = spawn_timer()\n~ play_sound(x)\n-> DONE\n",
);
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("x"),
Some(&Ty::Conflicted),
"x is spawn_timer's declared Timer return, passed where play_sound declares \
AudioInstance: {main_body:?}"
);
}
#[test]
fn external_call_return_position_kind_match_unifies_cleanly() {
let mut manifest = audio_manifest_with_external("AudioInstance");
manifest.externals.push(brink_ir::ManifestExternal {
name: "spawn_audio".to_string(),
params: Vec::new(),
returns: brink_ir::TypeRef("AudioInstance".to_string()),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
});
let (hir, index, res) = build(
"EXTERNAL play_sound(inst)\nEXTERNAL spawn_audio()\n\
=== main ===\n~ temp x = spawn_audio()\n~ play_sound(x)\n-> DONE\n",
);
let result = infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let main_body = result.bodies.get(&main_id).expect("main body");
assert_eq!(
main_body.locals.get("x"),
Some(&Ty::Handle("AudioInstance".to_string())),
"x is spawn_audio's declared AudioInstance return, matching play_sound's own \
declared param kind: {main_body:?}"
);
}
#[test]
#[expect(
clippy::similar_names,
reason = "ping/pong are the clearest names for this pair"
)]
fn mutual_recursion_params_stay_firewalled_to_each_defs_own_body() {
let (hir, index, res) = build(
"=== function ping(n) ===\n{n > 0:\n ~ return pong(n - 1)\n}\n~ return n\n\
=== function pong(n) ===\n{n > 0.5:\n ~ return ping(n - 1)\n}\n~ return n\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let ping_sig = sig_of(&result, &index, "ping");
let pong_sig = sig_of(&result, &index, "pong");
assert_eq!(
ping_sig.params,
vec![Ty::Int],
"ping's own body only compares n to an int"
);
assert_eq!(
pong_sig.params,
vec![Ty::Float],
"pong's own body only compares n to a float"
);
}
#[test]
#[expect(
clippy::similar_names,
reason = "ping/pong are the clearest names for this pair"
)]
fn mutual_recursion_return_type_converges_by_fixpoint() {
let (hir, index, res) = build(
"=== function ping(n) ===\n{n == 0:\n ~ return 0.0\n}\n~ return pong(n - 1)\n\
=== function pong(n) ===\n~ return ping(n)\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let ping_sig = sig_of(&result, &index, "ping");
let pong_sig = sig_of(&result, &index, "pong");
assert_eq!(ping_sig.return_ty, Ty::Float);
assert_eq!(pong_sig.return_ty, Ty::Float);
}
#[test]
fn intrinsic_len_types_int() {
let (hir, index, res) =
build("=== main ===\n~ temp arr = #[1, 2, 3]\n~ temp n = len(arr)\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let body = result.bodies.get(&main_id).expect("main body");
assert_eq!(body.locals.get("arr"), Some(&Ty::Array(Box::new(Ty::Int))));
assert_eq!(body.locals.get("n"), Some(&Ty::Int));
}
#[test]
fn determinism_same_input_same_output() {
let src = "=== function fib(n) ===\n{n < 2.0:\n ~ return n\n}\n~ return fib(n - 1) + fib(n - 2)\n";
let (hir_a, index_a, res_a) = build(src);
let (hir_b, index_b, res_b) = build(src);
let a = infer_project(
&[(FileId(0), &hir_a)],
&index_a,
&res_a,
None,
&BTreeMap::new(),
);
let b = infer_project(
&[(FileId(0), &hir_b)],
&index_b,
&res_b,
None,
&BTreeMap::new(),
);
assert_eq!(a, b, "same input must infer identical types every run");
}
#[test]
fn genuinely_disjoint_uses_infer_param_as_conflicted() {
let (hir, index, res) = build(
"=== conflict_case(hp) ===\n{hp > 5:\n ok\n}\n{hp == \"no\":\n no\n}\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "conflict_case");
assert_eq!(sig.params, vec![Ty::Conflicted]);
}
#[test]
fn conflict_detection_is_order_independent_across_real_source() {
let forward =
"=== conflict_fwd(hp) ===\n{hp > 5:\n ok\n}\n{hp == \"no\":\n no\n}\n-> DONE\n";
let reversed =
"=== conflict_rev(hp) ===\n{hp == \"no\":\n no\n}\n{hp > 5:\n ok\n}\n-> DONE\n";
let (hir_f, index_f, res_f) = build(forward);
let result_f = infer_project(
&[(FileId(0), &hir_f)],
&index_f,
&res_f,
None,
&BTreeMap::new(),
);
let sig_f = sig_of(&result_f, &index_f, "conflict_fwd");
let (hir_r, index_r, res_r) = build(reversed);
let result_r = infer_project(
&[(FileId(0), &hir_r)],
&index_r,
&res_r,
None,
&BTreeMap::new(),
);
let sig_r = sig_of(&result_r, &index_r, "conflict_rev");
assert_eq!(sig_f.params, vec![Ty::Conflicted], "int-then-string order");
assert_eq!(sig_r.params, vec![Ty::Conflicted], "string-then-int order");
assert_eq!(
sig_f.params, sig_r.params,
"conflict detection must not depend on observation order"
);
}
#[test]
#[expect(
clippy::similar_names,
reason = "ping/pong are the clearest names for this pair"
)]
fn conflicted_absorbs_through_the_scc_fixpoint() {
let (hir, index, res) = build(
"=== function ping(n) ===\n{n == 0:\n ~ return \"done\"\n}\n~ return pong(n - 1)\n\
=== function pong(n) ===\n{n == 0:\n ~ return 1\n}\n~ return ping(n - 1)\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let ping_sig = sig_of(&result, &index, "ping");
let pong_sig = sig_of(&result, &index, "pong");
assert_eq!(ping_sig.return_ty, Ty::Conflicted);
assert_eq!(pong_sig.return_ty, Ty::Conflicted);
}
#[test]
fn a_slot_written_from_two_creation_sites_carries_both_targets() {
let (hir, index, res) = build(
"=== function bump(n: int): int ===\n~ return n + 1\n\
=== function twice(n: int): int ===\n~ return n * 2\n\
=== main ===\n~ temp f = #fn(bump)\n~ f = #fn(twice)\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let id_of = |name: &str| {
index
.by_name
.get(name)
.and_then(|ids| ids.first())
.copied()
.unwrap_or_else(|| unreachable!("no symbol named {name}"))
};
let body = result.bodies.get(&id_of("main")).expect("main body");
let f = body.locals.get("f").expect("f");
let Ty::Fn(_, _, row) = f else {
unreachable!("expected a fn type, got {f:?}")
};
assert_eq!(
row.targets(),
Some(&BTreeSet::from([id_of("bump"), id_of("twice")]))
);
}
#[test]
fn one_unnameable_creation_site_poisons_the_row() {
for order in [
"~ temp f = #fn(bump)\n~ f = pick(#fn(bump))\n",
"~ temp f = pick(#fn(bump))\n~ f = #fn(bump)\n",
] {
let (hir, index, res) = build(&format!(
"=== function bump(n: int): int ===\n~ return n + 1\n\
=== function pick(cb: fn(int): int): fn(int): int ===\n~ return cb\n\
=== main ===\n{order}-> DONE\n"
));
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let body = result.bodies.get(&main_id).expect("main body");
let f = body.locals.get("f").expect("f");
let Ty::Fn(_, _, row) = f else {
unreachable!("expected a fn type for {order:?}, got {f:?}")
};
assert!(row.is_unknown(), "order {order:?} must poison the row");
}
}
#[test]
fn a_global_fn_cell_carries_its_declared_creation_target() {
let (hir, index, res) = build(
"=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
VAR player_hp = 10\n\
VAR healer = #fn(heal, player_hp)\n\
=== main ===\n-> DONE\n",
);
let _ = &res;
let files = [(FileId(0), &hir)];
let id_of = |name: &str| {
index
.by_name
.get(name)
.and_then(|ids| ids.first())
.copied()
.unwrap_or_else(|| unreachable!("no symbol named {name}"))
};
let sig = crate::signature::signature(id_of("healer"), &index, &files, None)
.expect("healer signature");
let ty = sig.value_ty.clone().expect("healer value_ty");
let Ty::Fn(params, _, row) = &ty else {
unreachable!("expected a fn type, got {ty:?}")
};
assert_eq!(params.len(), 1, "the `ref hp` prefix is bound away");
assert_eq!(row.targets(), Some(&BTreeSet::from([id_of("heal")])));
}
#[test]
fn bind_preserves_the_creation_target_row() {
let (hir, index, res) = build(
"=== function add(a: int, b: int): int ===\n~ return a + b\n\
=== main ===\n~ temp f = #fn(add)\n~ temp g = bind(f, 1)\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let id_of = |name: &str| {
index
.by_name
.get(name)
.and_then(|ids| ids.first())
.copied()
.unwrap_or_else(|| unreachable!("no symbol named {name}"))
};
let body = result.bodies.get(&id_of("main")).expect("main body");
let g = body.locals.get("g").expect("g");
let Ty::Fn(params, _, row) = g else {
unreachable!("expected a fn type, got {g:?}")
};
assert_eq!(params.len(), 1, "one param remains after binding one");
assert_eq!(row.targets(), Some(&BTreeSet::from([id_of("add")])));
}
#[test]
fn fn_literal_consumes_the_bound_prefix_of_the_targets_signature() {
let (hir, index, res) = build(
"=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
VAR player_hp = 10\n\
=== main ===\n~ temp heal_player = #fn(heal, player_hp)\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let body = result.bodies.get(&main_id).expect("main body");
let heal_id = index
.by_name
.get("heal")
.and_then(|ids| ids.first())
.copied()
.expect("heal");
let cb = body.locals.get("heal_player").expect("heal_player");
assert_eq!(
crate::infer::erase_fn_rows(cb),
Ty::Fn(vec![Ty::Int], Box::new(Ty::Int), FnRow::unknown())
);
let Ty::Fn(_, _, row) = cb else {
unreachable!("expected a fn type, got {cb:?}")
};
assert_eq!(row.targets(), Some(&BTreeSet::from([heal_id])));
}
#[test]
fn fn_literal_over_an_inferred_signature_needs_no_annotations() {
let (hir, index, res) = build(
"=== function double(x) ===\n~ return x * 2\n\
=== main ===\n~ temp f = #fn(double)\n-> DONE\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let body = result.bodies.get(&main_id).expect("main body");
let f = body.locals.get("f").expect("f");
assert_eq!(
crate::infer::erase_fn_rows(f),
Ty::Fn(vec![Ty::Int], Box::new(Ty::Int), FnRow::unknown())
);
}
#[test]
fn fn_literal_with_unresolvable_target_stays_unknown() {
let (hir, index, res) = build("=== main ===\n~ temp f = #fn(nowhere)\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let body = result.bodies.get(&main_id).expect("main body");
assert_eq!(body.locals.get("f"), Some(&Ty::Unknown));
}
#[test]
fn annotated_but_unconstrained_param_overlays_to_the_annotation_type() {
let (hir, index, res) = build("=== noop(x: int) ===\nHello.\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "noop");
assert_eq!(sig.params, vec![Ty::Int]);
}
#[test]
fn overlay_never_replaces_a_concrete_body_derivation() {
let (hir, index, res) = build("=== heal(hp: string) ===\n{hp > 1:\n ok\n}\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "heal");
assert_eq!(sig.params, vec![Ty::Int], "body derivation wins");
}
#[test]
fn some_of_an_unevidenced_annotated_param_infers_option_of_its_annotation() {
let (hir, index, res) = build("=== function f(x: int) ===\n~ return some(x)\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "f");
assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Int)));
}
#[test]
fn get_of_an_unevidenced_annotated_map_param_infers_option_of_the_value_type() {
let (hir, index, res) =
build("=== function f(m: Map<string, int>, k: string) ===\n~ return get(m, k)\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "f");
assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Int)));
}
#[test]
fn some_of_a_for_loop_var_over_an_unevidenced_annotated_array_param() {
let (hir, index, res) = build(
"=== function first_over(tab: Array<int>, floor: int) ===\n\
~ {\n for coins in tab {\n if coins > floor {\n return some(coins)\n }\n }\n}\n\
~ return none\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "first_over");
assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Int)));
}
#[test]
fn comparison_evidence_still_overrides_the_annotation_after_the_1168_fix() {
let (hir, index, res) = build("=== heal(hp: string) ===\n{hp > 1:\n ok\n}\n-> DONE\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "heal");
assert_eq!(sig.params, vec![Ty::Int]);
}
#[test]
fn unascribed_temp_copy_of_an_annotated_param_does_not_inherit_the_annotation() {
let (hir, index, res) =
build("=== function f(x: int) ===\n~ temp v = x\n~ return some(v)\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "f");
assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Unknown)));
}
#[test]
fn intrinsic_sibling_arg_never_seeds_from_a_containers_own_annotation() {
let (hir, index, res) = build(
"=== function f(tab: Array<int>, needle: string) ===\n\
~ return contains(tab, needle)\n",
);
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "f");
assert_eq!(sig.params, vec![Ty::Array(Box::new(Ty::Int)), Ty::String]);
}
#[test]
fn return_annotation_overlays_an_unconstrained_return() {
let (hir, index, res) = build("=== function passthru(hp): int ===\n~ return hp\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "passthru");
assert_eq!(sig.return_ty, Ty::Int);
}
#[test]
fn returning_an_annotated_param_exports_the_params_type() {
let (hir, index, res) = build("=== function passthru(hp: int) ===\n~ return hp\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "passthru");
assert_eq!(sig.return_ty, Ty::Int);
}
#[test]
fn a_concrete_body_derivation_still_beats_the_returned_params_annotation() {
let (hir, index, res) = build("=== function passthru(hp: int) ===\n~ return hp + \"x\"\n");
let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let sig = sig_of(&result, &index, "passthru");
assert_eq!(sig.params, vec![Ty::String]);
assert_eq!(sig.return_ty, Ty::String);
}
#[test]
fn call_edges_matches_the_calls_infer_project_discovers() {
let (hir, index, res) = build(
"=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n big\n}\n-> DONE\n",
);
let files = [(FileId(0), &hir)];
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let use_it_id = index
.by_name
.get("use_it")
.and_then(|ids| ids.first())
.copied()
.expect("use_it");
let inferable = inferable_defs_from_index(&index);
let edges = call_edges(main_id, &files, &index, &res, &inferable, None);
assert_eq!(
edges,
BTreeSet::from([use_it_id]),
"main's only call edge is to use_it"
);
let leaf_edges = call_edges(use_it_id, &files, &index, &res, &inferable, None);
assert!(leaf_edges.is_empty(), "use_it calls nothing");
}
#[test]
fn call_edges_is_empty_for_an_unknown_def() {
let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
let files = [(FileId(0), &hir)];
let bogus = DefinitionId::new(brink_format::DefinitionTag::Address, 0xDEAD_BEEF);
let inferable = inferable_defs_from_index(&index);
assert!(call_edges(bogus, &files, &index, &res, &inferable, None).is_empty());
}
#[test]
fn inferable_defs_from_index_matches_hir_derived_set() {
let fixtures = [
"=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n big\n}\n-> DONE\n",
"= heal(hp)\n~ temp x = hp + 1\n-> DONE\n",
"= intro(hp)\n~ temp x = hp + 1\n-> DONE\n\
=== knot_a(gold) ===\n{gold > 1.5:\n ok\n}\n-> stitch_a ->\n\
= stitch_a(silver)\n~ temp y = silver + 1\n-> DONE\n",
];
for src in fixtures {
let (hir, index, _res) = build(src);
let files = [(FileId(0), &hir)];
assert_eq!(
inferable_defs_from_index(&index),
inferable_defs(&files, &index),
"index-sourced and HIR-walking inferable sets diverged for: {src}"
);
}
}
#[test]
fn referenced_globals_finds_every_var_and_const_read_in_a_body() {
let (hir, index, res) = build(
"VAR gold = 10\nCONST max_gold = 100\n\
=== spend(cost) ===\n~ gold = gold - cost\n{gold > max_gold:\n rich\n}\n-> DONE\n",
);
let files = [(FileId(0), &hir)];
let spend_id = index
.by_name
.get("spend")
.and_then(|ids| ids.first())
.copied()
.expect("spend");
let gold_id = index
.by_name
.get("gold")
.and_then(|ids| ids.first())
.copied()
.expect("gold");
let max_gold_id = index
.by_name
.get("max_gold")
.and_then(|ids| ids.first())
.copied()
.expect("max_gold");
let global_refs = referenced_globals(spend_id, &files, &index, &res, None);
assert_eq!(
global_refs,
BTreeSet::from([gold_id, max_gold_id]),
"spend's body reads both gold and max_gold"
);
}
#[test]
fn referenced_globals_is_empty_when_a_body_reads_no_globals() {
let (hir, index, res) = build("=== main ===\n~ temp v = 1\n-> DONE\n");
let files = [(FileId(0), &hir)];
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
assert!(referenced_globals(main_id, &files, &index, &res, None).is_empty());
}
#[test]
fn inferable_defs_matches_every_knot_and_stitch() {
let (hir, index, res) = build(
"=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n big\n}\n-> DONE\n",
);
let files = [(FileId(0), &hir)];
let _ = &res;
let defs = inferable_defs(&files, &index);
let main_id = index
.by_name
.get("main")
.and_then(|ids| ids.first())
.copied()
.expect("main");
let use_it_id = index
.by_name
.get("use_it")
.and_then(|ids| ids.first())
.copied()
.expect("use_it");
assert_eq!(defs, BTreeSet::from([main_id, use_it_id]));
}
#[test]
fn composed_per_scc_solve_equals_monolithic_infer_project() {
let src = "=== function ping(n) ===\n{n == 0:\n ~ return 0.0\n}\n~ return pong(n - 1)\n\
=== function pong(n) ===\n~ return ping(n)\n\
=== caller ===\n~ temp x = ping(3)\n-> DONE\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let monolithic = infer_project(&files, &index, &res, None, &BTreeMap::new());
let defs = inferable_defs_from_index(&index);
let mut graph = CallGraph::new();
for &def in &defs {
graph.add_node(def);
for callee in call_edges(def, &files, &index, &res, &defs, None) {
graph.add_edge(def, callee);
}
}
let sg = scc_graph(&graph);
let mut known_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let mut signatures: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
let mut bodies: BTreeMap<DefinitionId, BodyTypes> = BTreeMap::new();
for batch in &sg.order {
let owned: Vec<(DefinitionId, Vec<Param>, Option<TypeExpr>, Block)> = batch
.iter()
.filter_map(|&id| def_body(id, &files, &index).map(|(p, ra, b)| (id, p, ra, b)))
.collect();
let batch_defs: Vec<Def<'_>> = owned
.iter()
.map(|(id, params, return_annotation, body)| Def {
id: *id,
file: FileId(0),
params,
body,
return_annotation: return_annotation.as_ref(),
native: false,
})
.collect();
let mut global_ids: BTreeSet<DefinitionId> = BTreeSet::new();
for &id in batch {
global_ids.extend(referenced_globals(id, &files, &index, &res, None));
}
let mut globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
for gid in global_ids {
if let Some(sig) = crate::signature::signature(gid, &index, &files, None)
&& let Some(vt) = sig.value_type.clone()
{
globals.insert(gid, Ty::from(vt));
}
}
let (sigs, bods) = solve_scc(
batch,
&batch_defs,
&index,
&res,
&globals,
&defs,
known_sigs.clone(),
None,
&BTreeMap::new(),
);
known_sigs.extend(sigs.iter().map(|(k, v)| (*k, v.clone())));
signatures.extend(sigs);
bodies.extend(bods);
}
let composed = InferenceResult { signatures, bodies };
assert_eq!(
composed, monolithic,
"per-SCC composed inference must equal a single infer_project call"
);
}
fn id_of(index: &SymbolIndex, name: &str) -> DefinitionId {
index
.by_name
.get(name)
.and_then(|ids| ids.first())
.copied()
.expect("no def with this name")
}
#[test]
fn conservative_total_no_under_report_over_mutual_recursion() {
let src = "VAR gold = 0\nVAR hp = 10\nEXTERNAL play_sfx(x)\n\
=== function ping(n) ===\n~ gold = gold + 1\n\
{n == 0:\n ~ return 0\n}\n~ play_sfx(n)\n~ return pong(n - 1)\n\
=== function pong(n) ===\n~ hp = hp - 1\n~ return ping(n)\n\
=== function apply(cb) ===\n~ return cb(1)\n\
=== caller ===\n~ temp x = ping(3)\n-> DONE\n\
=== hocaller ===\n~ temp y = apply(#fn(pong))\n-> DONE\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let inferable = inferable_defs(&files, &index);
let rows = effects_project(&files, &index, &res, None);
for &def in &inferable {
let row = rows.get(&def).cloned().unwrap_or_default();
let atoms = def_effect_atoms(def, &files, &index, &res, &inferable, None);
assert!(
row.covers(&atoms.base_row()),
"def {def:?} row must cover its own body atoms"
);
for callee in &atoms.direct_calls {
let callee_row = rows.get(callee).cloned().unwrap_or_default();
let mut effective = EffectRow {
holes: BTreeSet::new(),
..callee_row.clone()
};
for &hole in &callee_row.holes {
effects::instantiate_hole(
&mut effective,
atoms.call_fn_args.get(&(*callee, hole)),
&rows,
&BTreeMap::new(),
);
}
assert!(
row.covers(&effective),
"def {def:?} row must cover callee {callee:?}'s instantiated row"
);
}
}
}
#[test]
fn effect_row_collects_read_write_and_external_call_atoms() {
let src = "VAR gold = 0\nVAR hp = 10\nEXTERNAL play_sfx(x)\n\
=== function spend(cost) ===\n~ gold = gold - cost\n\
~ temp before = hp\n~ play_sfx(cost)\n~ return gold\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let spend = id_of(&index, "spend");
let gold = id_of(&index, "gold");
let hp = id_of(&index, "hp");
let row = &rows[&spend];
assert!(row.reads.contains(&gold), "reads gold ({gold:?})");
assert!(row.reads.contains(&hp), "reads hp ({hp:?})");
assert!(row.writes.contains(&gold), "writes gold ({gold:?})");
assert!(!row.writes.contains(&hp), "never writes hp");
assert!(row.calls.contains("play_sfx"), "calls the external kind");
assert!(!row.opaque, "a fully-visible body is not pessimal");
}
#[test]
fn rand_draw_verbs_write_the_rng_cell() {
use brink_format::DefinitionId;
let cases: &[(&str, &str)] = &[
(
"float_draw",
"=== function float_draw() ===\n~ return float()\n",
),
(
"chance_draw",
"=== function chance_draw() ===\n~ return chance(0.5)\n",
),
(
"pick_draw",
"=== function pick_draw() ===\n~ temp a = #[1, 2, 3]\n~ return pick(a)\n",
),
(
"shuffled_draw",
"=== function shuffled_draw() ===\n~ temp a = #[1, 2, 3]\n~ return shuffled(a)\n",
),
(
"shuffle_stmt",
"VAR deck = 0\n=== function shuffle_stmt() ===\n~ shuffle(deck)\n~ return 0\n",
),
(
"seed_stmt",
"=== function seed_stmt() ===\n~ seed(42)\n~ return 0\n",
),
];
for (name, src) in cases {
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let def = id_of(&index, name);
assert!(
rows[&def].writes.contains(&DefinitionId::RNG_CELL),
"`{name}`'s row must contain the RNG-cell write; got {:?}",
rows[&def].writes
);
}
}
#[test]
fn frozen_ink_random_spellings_write_the_same_rng_cell() {
use brink_format::DefinitionId;
let cases: &[(&str, &str)] = &[
(
"roll_ink",
"=== function roll_ink() ===\n~ return RANDOM(1, 6)\n",
),
(
"seed_ink",
"=== function seed_ink() ===\n~ SEED_RANDOM(9)\n~ return 0\n",
),
(
"pick_ink",
"LIST moods = happy, sad\n=== function pick_ink() ===\n~ return LIST_RANDOM(moods)\n",
),
];
for (name, src) in cases {
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let def = id_of(&index, name);
assert!(
rows[&def].writes.contains(&DefinitionId::RNG_CELL),
"ink `{name}`'s row must contain the RNG-cell write; got {:?}",
rows[&def].writes
);
}
}
#[test]
fn unary_float_conversion_does_not_write_the_rng_cell() {
use brink_format::DefinitionId;
let src = "=== function conv(x) ===\n~ return float(x)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let def = id_of(&index, "conv");
assert!(
!rows[&def].writes.contains(&DefinitionId::RNG_CELL),
"unary float(x) is the conversion — no draw, no cell write"
);
let src = "=== function draw() ===\n~ return float()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let draw = id_of(&index, "draw");
assert!(
!rows[&draw].faults,
"nullary float() has no argument and no fault path"
);
}
#[test]
fn shuffle_writes_both_the_receiver_and_the_rng_cell() {
use brink_format::DefinitionId;
let src = "VAR deck = 0\n=== function riffle() ===\n~ shuffle(deck)\n~ return 0\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let def = id_of(&index, "riffle");
let deck = id_of(&index, "deck");
assert!(rows[&def].writes.contains(&deck), "writes the receiver");
assert!(
rows[&def].writes.contains(&DefinitionId::RNG_CELL),
"writes the RNG cell"
);
}
#[test]
fn rng_write_propagates_to_callers_through_the_fixpoint() {
use brink_format::DefinitionId;
let src = "=== function outer() ===\n~ return inner()\n\
=== function inner() ===\n~ return chance(0.25)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
for name in ["outer", "inner"] {
let def = id_of(&index, name);
assert!(
rows[&def].writes.contains(&DefinitionId::RNG_CELL),
"`{name}` must carry the transitive RNG-cell write"
);
}
}
#[test]
fn mutually_recursive_defs_share_the_unioned_row() {
let src = "VAR gold = 0\nVAR hp = 10\n\
=== function ping(n) ===\n~ gold = gold + 1\n\
{n == 0:\n ~ return 0\n}\n~ return pong(n - 1)\n\
=== function pong(n) ===\n~ hp = hp - 1\n~ return ping(n)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let left = id_of(&index, "ping");
let right = id_of(&index, "pong");
let gold = id_of(&index, "gold");
let hp = id_of(&index, "hp");
for def in [left, right] {
let row = &rows[&def];
assert!(row.writes.contains(&gold), "{def:?} writes gold");
assert!(row.writes.contains(&hp), "{def:?} writes hp");
}
}
#[test]
fn a_call_through_a_function_value_is_pessimal() {
let src = "=== function apply(cb) ===\n~ return cb(1)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let apply = id_of(&index, "apply");
assert!(
rows[&apply].is_pessimal(),
"a call through a function value must be pessimal"
);
}
#[test]
fn a_call_through_a_fn_typed_param_mints_a_row_variable() {
let src = "=== function apply(a, cb) ===\n~ return cb(a)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let apply = id_of(&index, "apply");
assert_eq!(
rows[&apply].holes,
[1].into_iter().collect::<BTreeSet<u32>>(),
"the hole is keyed by the called param's declaration index"
);
assert!(
!rows[&apply].opaque,
"the floor is the hole, not intrinsic opacity"
);
assert!(
rows[&apply].is_pessimal(),
"an uninstantiated row variable still tops the lattice"
);
}
#[test]
fn a_caller_instantiates_the_callees_row_variable() {
let src = "VAR gold = 0\n\
=== function writer(n) ===\n~ gold = gold + n\n~ return gold\n\
=== function apply(cb) ===\n~ return cb(1)\n\
=== function main() ===\n~ return apply(#fn(writer))\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let main = id_of(&index, "main");
let gold = id_of(&index, "gold");
assert!(
!rows[&main].is_pessimal(),
"a fully-traced higher-order call is not pessimal"
);
assert!(
rows[&main].holes.is_empty(),
"a discharged hole belongs to the callee's param space, never the caller's"
);
assert!(
rows[&main].writes.contains(&gold),
"the instantiated row must carry the callback's own write"
);
}
#[test]
fn two_call_sites_join_both_callbacks_into_the_hole() {
let src = "VAR gold = 0\nVAR hp = 10\n\
=== function pays(n) ===\n~ gold = gold + n\n~ return gold\n\
=== function hurts(n) ===\n~ hp = hp - n\n~ return hp\n\
=== function apply(cb) ===\n~ return cb(1)\n\
=== function main() ===\n\
~ temp a = apply(#fn(pays))\n~ temp b = apply(#fn(hurts))\n~ return a + b\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let main = id_of(&index, "main");
assert!(!rows[&main].is_pessimal());
assert!(rows[&main].writes.contains(&id_of(&index, "gold")));
assert!(rows[&main].writes.contains(&id_of(&index, "hp")));
}
#[test]
fn one_untraced_call_site_poisons_the_whole_position() {
let src = "VAR gold = 0\n\
=== function pays(n) ===\n~ gold = gold + n\n~ return gold\n\
=== function apply(cb) ===\n~ return cb(1)\n\
=== function main(outside) ===\n\
~ temp a = apply(#fn(pays))\n~ temp b = apply(outside)\n~ return a + b\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let main = id_of(&index, "main");
assert!(
rows[&main].is_pessimal(),
"an untraced argument in a holed position must keep the floor"
);
}
#[test]
fn a_reassigned_param_carries_no_row_variable() {
let src = "VAR gold = 0\n\
=== function pays(n) ===\n~ gold = gold + n\n~ return gold\n\
=== function apply(cb) ===\n~ cb = #fn(pays)\n~ return cb(1)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let apply = id_of(&index, "apply");
assert!(
rows[&apply].holes.is_empty(),
"a written param is not a row variable"
);
assert!(
rows[&apply].opaque,
"it keeps the intrinsic pessimal floor instead"
);
}
#[test]
fn a_ref_param_carries_no_row_variable() {
let src = "=== function apply(ref cb) ===\n~ return cb(1)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let apply = id_of(&index, "apply");
assert!(rows[&apply].holes.is_empty(), "`ref` params are excluded");
assert!(rows[&apply].opaque, "so the call keeps the intrinsic floor");
}
#[test]
fn forwarding_a_param_into_another_hole_does_not_chain() {
let src = "=== function apply(cb) ===\n~ return cb(1)\n\
=== function forward(cb) ===\n~ return apply(cb)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let forward = id_of(&index, "forward");
assert!(
rows[&forward].is_pessimal(),
"a forwarded row variable is not chained — the floor stands"
);
}
#[test]
fn a_pure_body_has_an_empty_row() {
let src = "=== function double(n) ===\n~ return n * 2\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let double = id_of(&index, "double");
assert!(
rows[&double].is_empty(),
"a pure arithmetic body reads/writes/calls nothing"
);
}
#[test]
fn a_direct_call_writes_through_a_ref_param_at_the_call_site() {
let src = "VAR val = 5\n\
=== knot ===\n~ inc(val)\n{val}\n->->\n\
=== function inc(ref x) ===\n~ x = x + 1\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let knot = id_of(&index, "knot");
let inc = id_of(&index, "inc");
let val = id_of(&index, "val");
assert!(
rows[&knot].writes.contains(&val),
"knot's call `inc(val)` writes through inc's `ref x` param — the \
write atom must not be dropped"
);
assert!(
!rows[&inc].writes.contains(&val),
"inc's own body never names `val` — the write is only visible at \
the call site, not inc's own atoms"
);
}
#[test]
fn known_fn_value_call_narrows_the_row_instead_of_pessimal() {
let src = "VAR total = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function user() ===\n~ temp f = #fn(bar)\n~ return f()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
assert!(
!rows[&user].opaque,
"a call through a write-once local with a known #fn origin must narrow, not stay pessimal"
);
assert!(
rows[&user].writes.contains(&total),
"the narrowed row must cover bar's real write to total"
);
}
#[test]
fn known_fn_value_call_intrinsic_form_narrows_the_row() {
let src = "VAR total = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function user() ===\n~ temp f = #fn(bar)\n~ return call(f)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
assert!(
!rows[&user].opaque,
"call(f) through a known origin must narrow"
);
assert!(rows[&user].writes.contains(&total));
}
#[test]
fn bound_fn_value_through_a_write_once_local_narrows() {
let src = "VAR total = 0\n\
=== function bar(n) ===\n~ total = total + n\n~ return total\n\
=== function user() ===\n~ temp f = bind(#fn(bar), 5)\n~ return call(f)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
assert!(
!rows[&user].opaque,
"a bind()-wrapped known origin stored write-once must still narrow"
);
assert!(rows[&user].writes.contains(&total));
}
#[test]
fn inline_fn_literal_at_the_call_site_narrows_without_a_stored_local() {
let src = "VAR total = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function user() ===\n~ return call(#fn(bar))\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
assert!(
!rows[&user].opaque,
"an inline #fn literal callee must narrow"
);
assert!(rows[&user].writes.contains(&total));
}
#[test]
fn a_local_reassigned_to_a_second_known_origin_joins_both_rows() {
let src = "VAR total = 0\nVAR extra = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function baz() ===\n~ extra = extra + 100\n~ return extra\n\
=== function user(cond) ===\n~ temp f = #fn(bar)\n\
{cond:\n ~ f = #fn(baz)\n}\n~ return f()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
let extra = id_of(&index, "extra");
assert!(
!rows[&user].opaque,
"every write to f traced to an in-project creation site, so the \
row must collapse to a real row instead of the pessimal floor"
);
assert!(
rows[&user].writes.contains(&total),
"the join must cover bar's write to total"
);
assert!(
rows[&user].writes.contains(&extra),
"the join must cover baz's write to extra — narrowing to a single \
origin would under-report the other branch"
);
}
#[test]
fn a_local_with_one_untraced_write_stays_pessimal() {
let src = "VAR total = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function user(cond, cb) ===\n~ temp f = #fn(bar)\n\
{cond:\n ~ f = cb\n}\n~ return f()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
assert!(
rows[&user].opaque,
"a write from an untraceable source must keep the pessimal floor"
);
}
#[test]
fn a_ref_param_rebind_through_a_call_site_stays_pessimal() {
let src = "VAR total = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function poke(ref g, h) ===\n~ g = h\n\
=== function user(cond, cb) ===\n~ temp f = #fn(bar)\n\
{cond:\n ~ poke(f, cb)\n}\n~ return f()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
assert!(
rows[&user].opaque,
"a ref-param rebind at a call site is an untraced write to the \
local — narrowing through it under-reports whatever the caller \
actually passed"
);
}
#[test]
fn a_call_through_a_heap_stored_fn_value_stays_pessimal() {
let src = "VAR cb = #fn(bar)\n\
=== function bar() ===\n~ return 1\n\
=== function user() ===\n~ return cb()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
assert!(
rows[&user].opaque,
"a call through a VAR-held fn value is the heap channel — \
local_fn_origins never sees VAR/CONST writes at all, so it \
must stay pessimal rather than attempt to narrow"
);
}
#[test]
fn a_ref_param_write_to_an_unrelated_global_root_does_not_poison_a_traced_local() {
let src = "VAR total = 0\nVAR npc = 5\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function poke(ref g, h) ===\n~ g = h\n\
=== function user(cond, new_cb) ===\n~ temp f = #fn(bar)\n\
{cond:\n ~ poke(npc, new_cb)\n}\n~ return f()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
assert!(
!rows[&user].opaque,
"a ref-param write to an unrelated global root must not poison \
`f`'s own fully traced write set: {:?}",
rows[&user]
);
assert!(
rows[&user].writes.contains(&total),
"the narrowed call through f must still join bar's own write to \
total: {:?}",
rows[&user]
);
}
#[test]
fn a_fn_creation_site_ref_binding_records_the_bound_cell_as_a_write() {
let src = "VAR player_hp = 10\n\
=== function heal(ref hp, amount) ===\n~ hp = hp + amount\n\
=== function user() ===\n~ temp f = #fn(heal, player_hp)\n\
~ return f(5)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let player_hp = id_of(&index, "player_hp");
assert!(
rows[&user].writes.contains(&player_hp),
"the cell bound into `heal`'s ref param at the `#fn` creation site \
is genuinely written when the created value runs — omitting it \
from `user`'s row is the under-report §3 forbids: {:?}",
rows[&user]
);
}
#[test]
fn a_fn_creation_site_ref_binding_records_the_write_even_when_never_called() {
let src = "VAR player_hp = 10\n\
=== function heal(ref hp, amount) ===\n~ hp = hp + amount\n\
=== function user() ===\n~ return #fn(heal, player_hp)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let player_hp = id_of(&index, "player_hp");
assert!(
rows[&user].writes.contains(&player_hp),
"a created-but-uncalled `#fn` still binds the cell — the creation \
site is the only place the binding is visible: {:?}",
rows[&user]
);
}
#[test]
fn a_fn_creation_site_ref_projection_records_its_root_cell() {
let src = "VAR npc = 0\n\
=== function heal(ref hp, amount) ===\n~ hp = hp + amount\n\
=== function user() ===\n~ temp f = #fn(heal, ref npc.hp)\n\
~ return f(5)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let npc = id_of(&index, "npc");
assert!(
rows[&user].writes.contains(&npc),
"mutating a projection writes through the root global's own cell: \
{:?}",
rows[&user]
);
}
#[test]
fn a_fn_creation_site_without_a_ref_param_still_narrows() {
let src = "VAR total = 0\n\
=== function bar(n) ===\n~ total = total + n\n~ return total\n\
=== function user() ===\n~ temp f = #fn(bar, 1)\n~ return f()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
assert!(
!rows[&user].opaque,
"a non-`ref` bound prefix must not be charged as an untraced \
write — the local still narrows to `bar`: {:?}",
rows[&user]
);
assert!(
rows[&user].writes.contains(&total),
"the narrowed call through f still joins bar's own write: {:?}",
rows[&user]
);
}
#[test]
fn fn_value_creation_sites_are_harvested_as_a_structural_atom() {
let src = "VAR total = 0\n\
=== function bar(n) ===\n~ total = total + n\n~ return total\n\
=== function baz() ===\n~ return 0\n\
=== function creates() ===\n~ temp f = #fn(bar, 1)\n~ return call(f)\n\
=== function binds() ===\n~ return call(bind(#fn(bar), 5))\n\
=== function hands_out() ===\n~ return #fn(baz)\n\
=== function plain() ===\n~ return bar(1)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let inferable = inferable_defs(&files, &index);
let bar = id_of(&index, "bar");
let baz = id_of(&index, "baz");
let atoms = |name: &str| {
def_effect_atoms(id_of(&index, name), &files, &index, &res, &inferable, None)
};
assert!(
atoms("creates").creates_fn_values.contains(&bar),
"a bare #fn literal is a creation site"
);
assert!(
atoms("binds").creates_fn_values.contains(&bar),
"bind() copies a value rather than naming a target — the nested \
#fn literal is what gets recorded"
);
assert!(
atoms("hands_out").creates_fn_values.contains(&baz),
"a fn value that is created and returned, never called here, is \
still a creation site"
);
assert!(
atoms("plain").creates_fn_values.is_empty(),
"a direct call creates no fn value"
);
}
#[test]
fn every_fn_value_creation_target_is_also_a_call_graph_edge() {
let src = "VAR total = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function hands_out() ===\n~ return #fn(bar)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let inferable = inferable_defs(&files, &index);
let atoms = def_effect_atoms(
id_of(&index, "hands_out"),
&files,
&index,
&res,
&inferable,
None,
);
assert!(
atoms.creates_fn_values.is_subset(&atoms.direct_calls),
"creation targets must also be call-graph edges: {:?} ⊄ {:?}",
atoms.creates_fn_values,
atoms.direct_calls
);
}
#[test]
fn an_external_fn_value_target_is_a_call_kind_atom_not_a_creation_edge() {
let src = "EXTERNAL play_sfx(x)\n\
=== function hands_out() ===\n~ return #fn(play_sfx)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let inferable = inferable_defs(&files, &index);
let atoms = def_effect_atoms(
id_of(&index, "hands_out"),
&files,
&index,
&res,
&inferable,
None,
);
assert!(
atoms.creates_fn_values.is_empty(),
"an EXTERNAL target has no row to follow, so it is not an edge"
);
assert!(
atoms.calls.contains("play_sfx"),
"the call-kind atom is still harvested — no silent drop"
);
}
#[test]
fn creating_a_fn_value_joins_the_targets_row_even_without_a_call() {
let src = "VAR total = 0\n\
=== function bar() ===\n~ total = total + 1\n~ return total\n\
=== function hands_out() ===\n~ return #fn(bar)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let hands_out = id_of(&index, "hands_out");
let total = id_of(&index, "total");
assert!(
rows[&hands_out].writes.contains(&total),
"the creation edge must pull bar's row into hands_out"
);
assert!(
!rows[&hands_out].opaque,
"creating a fn value is not itself an opaque construct"
);
}
#[test]
fn narrowed_call_composes_transitively_through_the_callees_own_callee() {
let src = "VAR total = 0\n\
=== function baz() ===\n~ total = total + 1\n~ return total\n\
=== function bar() ===\n~ return baz()\n\
=== function user() ===\n~ temp f = #fn(bar)\n~ return f()\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let user = id_of(&index, "user");
let total = id_of(&index, "total");
assert!(
!rows[&user].opaque,
"narrowing to bar must not itself force pessimal"
);
assert!(
rows[&user].writes.contains(&total),
"bar's own row already transitively covers baz's write to total \
(ordinary direct-call SCC propagation) — user's narrowed edge to \
bar must inherit that whole row, not just bar's own direct atoms"
);
}
#[test]
fn a_call_through_an_unresolvable_param_stays_pessimal() {
let src = "=== function apply(cb) ===\n~ return cb(1)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let apply = id_of(&index, "apply");
assert!(
rows[&apply].is_pessimal(),
"a call through a function value with no known origin must stay pessimal"
);
}
#[test]
fn a_param_reassigned_once_called_before_the_write_stays_pessimal() {
let src = "VAR total = 0\n\
=== function bar(n) ===\n~ total = total + n\n~ return total\n\
=== function apply(cb, guard) ===\n\
{guard:\n ~ return cb(1)\n}\n~ cb = #fn(bar)\n~ return cb(1)\n";
let (hir, index, res) = build(src);
let files = [(FileId(0), &hir)];
let rows = effects_project(&files, &index, &res, None);
let apply = id_of(&index, "apply");
assert!(
rows[&apply].opaque,
"a param reassigned exactly once inside the body must not narrow \
calls reachable before that reassignment — the param still holds \
the caller's arbitrary fn value there"
);
}
#[test]
fn collect_external_sigs_and_resolve_type_agree_on_an_unregistered_semantic_type() {
let (_hir, index, _res, inline_docs) =
build_with_docs("/// @param id {var_id}\nEXTERNAL get_variable(id)\n-> DONE\n");
let manifest = brink_ir::HostManifest {
markup: Vec::new(),
types: vec![brink_ir::SemanticTypeDef {
name: "actor_id".to_string(),
base: brink_ir::BaseType::String,
constraint: None,
values: None,
widget: None,
}],
externals: Vec::new(),
};
let sigs = collect_external_sigs(&index, Some(&manifest), &inline_docs);
let ext_id = index
.by_name
.get("get_variable")
.and_then(|ids| ids.first())
.copied()
.expect("get_variable in index");
let sig = sigs.get(&ext_id).expect("seeded signature");
assert_eq!(
sig.params,
vec![Ty::Unknown],
"var_id is not registered — strict inference must not fabricate a type"
);
let (types, registered) = crate::manifest_maps(Some(&manifest));
let (metas, diags) = crate::external_check::analyze_externals(
&index,
&inline_docs,
&types,
®istered,
crate::ExternalCheckSeverity::Error,
true, );
let meta = metas.get(&ext_id).expect("meta for get_variable");
assert!(
meta.params[0]
.ty
.as_ref()
.is_some_and(|t| !t.is_registered()),
"var_id must render as unregistered (base: None), not a confident type: {:?}",
meta.params[0].ty
);
assert_eq!(
diags.len(),
1,
"the same unregistered name also raises E040 on this path: {diags:?}"
);
assert_eq!(diags[0].code, brink_ir::DiagnosticCode::E040);
}
}