use std::collections::{BTreeMap, BTreeSet};
use brink_format::DefinitionId;
use brink_ir::{
Block, BlockStmt, Content, ContentPart, ElseBranch, Expr, FileId, HirFile, IfStmt, Path,
ResolutionMap, Stmt, SymbolIndex, SymbolKind, TypeExpr,
};
use rowan::TextRange;
use crate::annotations;
use crate::infer::{InferenceResult, InferredSig, Ty};
pub use brink_project_config::TypePolicy;
pub use brink_project_config::LintLevel;
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LintPolicy {
pub overrides: BTreeMap<String, LintLevel>,
pub deny_warnings: bool,
}
#[must_use]
pub fn resolve_type_policy(dialect: crate::Dialect, explicit: Option<TypePolicy>) -> TypePolicy {
explicit.unwrap_or(match dialect {
crate::Dialect::Brink => TypePolicy::Strict,
crate::Dialect::StrictInk => TypePolicy::Gradual,
})
}
#[must_use]
pub fn effective_severity(
code: brink_ir::DiagnosticCode,
types: TypePolicy,
lints: &LintPolicy,
) -> brink_ir::Severity {
let base = if code == brink_ir::DiagnosticCode::E063 && types == TypePolicy::Strict {
brink_ir::Severity::Error
} else {
code.severity()
};
if base == brink_ir::Severity::Error {
return base;
}
let candidate = match lints.overrides.get(code.as_str()) {
Some(LintLevel::Deny) => return brink_ir::Severity::Error,
Some(LintLevel::Allow) => return base,
Some(LintLevel::Info) => return brink_ir::Severity::Info,
Some(LintLevel::Hint) => return brink_ir::Severity::Hint,
Some(LintLevel::Warn) => brink_ir::Severity::Warning,
None => base,
};
if candidate == brink_ir::Severity::Warning && lints.deny_warnings {
brink_ir::Severity::Error
} else {
candidate
}
}
#[must_use]
pub fn config_error(
dialect: crate::Dialect,
first_file: Option<FileId>,
) -> Option<brink_ir::Diagnostic> {
if dialect == crate::Dialect::Brink {
return None;
}
let file = first_file?;
Some(brink_ir::Diagnostic {
file,
range: TextRange::new(0.into(), 0.into()),
message: "types = strict requires dialect = brink — strict typing's annotation syntax \
is a brink-dialect extension (docs/typed-mode-spec.md §1); set \
`dialect = brink` or drop back to `types = gradual`"
.to_owned(),
code: brink_ir::DiagnosticCode::E064,
})
}
#[must_use]
pub fn native_strict_only_error(
file: FileId,
explicit_types: Option<TypePolicy>,
) -> Option<brink_ir::Diagnostic> {
if explicit_types != Some(TypePolicy::Gradual) {
return None;
}
Some(brink_ir::Diagnostic {
file,
range: TextRange::new(0.into(), 0.into()),
message: "native `.brink` compiles are strict-only — `types = gradual` is not a valid \
policy for native source (docs/decision-log.md \"Typing posture ruled\", \
2026-07-19); drop the `types` setting (native strict is the only policy) or \
set `types = strict` explicitly"
.to_owned(),
code: brink_ir::DiagnosticCode::E137,
})
}
#[must_use]
pub fn check(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
resolutions: &ResolutionMap,
manifest: Option<&brink_ir::HostManifest>,
) -> Vec<brink_ir::Diagnostic> {
let mut out = check_escapes(files, index, inference, manifest);
out.extend(annotations::mismatches(files, index, inference, manifest));
out.extend(check_void_assignments(files, index, resolutions, inference));
out.extend(check_value_calls(files, index, inference));
out.extend(check_direct_call_args(files, index, inference));
out.extend(check_typed_assign_mismatches(files, index, inference));
out.extend(check_lambda_annotation_mismatches(files, index, inference));
out.extend(check_global_initializers(files, index, manifest));
out.extend(check_array_remove_calls(files, index, inference));
out.extend(crate::ufcs::check_strict(
files,
index,
resolutions,
inference,
));
out.extend(crate::structs::check(files, index, inference, resolutions));
out.extend(crate::structs::check_assignments(files, index, inference));
out.extend(crate::ref_projection::check_strict(
files,
index,
resolutions,
));
out.extend(crate::conversions::check(
files,
index,
inference,
resolutions,
));
out.extend(crate::option_conditions::check(
files,
index,
inference,
resolutions,
));
out.extend(crate::range_refinement::check(
files,
index,
inference,
resolutions,
));
out.extend(crate::coalesce::check(files, index, inference, resolutions));
out.extend(crate::contains_domain::check(
files,
index,
inference,
resolutions,
));
out
}
#[must_use]
fn check_escapes(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
manifest: Option<&brink_ir::HostManifest>,
) -> Vec<brink_ir::Diagnostic> {
let names = annotations::TypeNames::new(index, manifest);
let mut out = Vec::new();
for &(file, hir) in files {
for knot in &hir.knots {
let kind = knot.symbol_kind();
if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
let body_has_value_return =
has_value_return_over_stitches(knot, id, file, index, inference);
check_def(
id,
file,
&knot.name.text,
knot.name.range,
knot.is_function,
knot.return_type.as_ref(),
&knot.params,
&knot.body,
&names,
inference,
body_has_value_return,
&mut out,
);
}
for stitch in &knot.stitches {
let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
if let Some(id) =
annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
{
let body_has_value_return = inference
.bodies
.get(&id)
.is_some_and(|b| b.has_value_return);
check_def(
id,
file,
&qualified,
stitch.name.range,
false,
stitch.return_type.as_ref(),
&stitch.params,
&stitch.body,
&names,
inference,
body_has_value_return,
&mut out,
);
}
}
}
}
out
}
#[must_use]
pub(crate) fn check_external_escapes(
index: &SymbolIndex,
external_sigs: &BTreeMap<DefinitionId, InferredSig>,
) -> Vec<brink_ir::Diagnostic> {
let mut externals: Vec<(&brink_ir::SymbolInfo, &InferredSig)> = external_sigs
.iter()
.filter_map(|(id, sig)| index.symbols.get(id).map(|info| (info, sig)))
.filter(|(info, _)| info.kind == SymbolKind::External)
.collect();
externals.sort_by_key(|(info, _)| (info.file.0, info.range.start()));
let mut out = Vec::new();
for (info, sig) in externals {
for (i, param) in info.params.iter().enumerate() {
let ty = sig.params.get(i).unwrap_or(&Ty::Unknown);
emit_escape(
info.file,
&info.name,
&format!("parameter `{}`", param.name),
info.range,
ty,
false,
&mut out,
);
}
}
out
}
fn has_value_return_over_stitches(
knot: &brink_ir::Knot,
own_id: DefinitionId,
file: FileId,
index: &SymbolIndex,
inference: &InferenceResult,
) -> bool {
let own = inference
.bodies
.get(&own_id)
.is_some_and(|b| b.has_value_return);
own || knot.stitches.iter().any(|st| {
annotations::def_id_for(
index,
file,
SymbolKind::Stitch,
&format!("{}.{}", knot.name.text, st.name.text),
)
.and_then(|sid| inference.bodies.get(&sid))
.is_some_and(|b| b.has_value_return)
})
}
#[expect(clippy::too_many_arguments, reason = "internal helper, not public API")]
fn check_def(
id: DefinitionId,
file: FileId,
def_label: &str,
name_range: TextRange,
is_function: bool,
return_type: Option<&TypeExpr>,
params: &[brink_ir::Param],
body: &Block,
names: &annotations::TypeNames,
inference: &InferenceResult,
body_has_value_return: bool,
out: &mut Vec<brink_ir::Diagnostic>,
) {
let Some(sig) = inference.signatures.get(&id) else {
return;
};
let Some(body_types) = inference.bodies.get(&id) else {
return;
};
for (i, p) in params.iter().enumerate() {
let annotated = p
.annotation
.as_ref()
.is_some_and(|ann| annotations::resolve(ann, names).is_some());
let ty = sig.params.get(i).unwrap_or(&Ty::Unknown);
emit_escape(
file,
def_label,
&format!("parameter `{}`", p.name.text),
p.name.range,
ty,
annotated,
out,
);
}
let has_void_annotation =
return_type.is_some_and(|rt| matches!(rt, TypeExpr::Named { name, .. } if name == "void"));
let declares_return_value = return_type.is_some() && !has_void_annotation;
if is_function || declares_return_value {
if body_types.has_value_return && !has_void_annotation {
let annotated = return_type.is_some_and(|rt| annotations::resolve(rt, names).is_some());
emit_escape(
file,
def_label,
"return type",
name_range,
&sig.return_ty,
annotated,
out,
);
} else if declares_return_value && !body_has_value_return {
out.push(brink_ir::Diagnostic {
file,
range: name_range,
message: format!(
"`{def_label}` declares a return type but its body never returns a value"
),
code: brink_ir::DiagnosticCode::E150,
});
}
}
let param_names: std::collections::BTreeSet<&str> =
params.iter().map(|p| p.name.text.as_str()).collect();
let temp_decls = collect_temps(body, names);
for (name, ty) in &body_types.locals {
if param_names.contains(name.as_str()) {
continue; }
let decl = temp_decls.get(name);
let annotated = decl.is_some_and(|d| d.annotation_ty.is_some());
let range = decl.map_or(name_range, |d| d.range);
emit_escape(
file,
def_label,
&format!("temp `{name}`"),
range,
ty,
annotated,
out,
);
}
for slot in &body_types.lambda_escapes {
emit_escape(
file,
def_label,
&slot.slot_label,
slot.range,
&slot.ty,
slot.annotated,
out,
);
}
}
fn emit_escape(
file: FileId,
def_label: &str,
slot_label: &str,
range: TextRange,
ty: &Ty,
annotated: bool,
out: &mut Vec<brink_ir::Diagnostic>,
) {
match classify(ty) {
Escape::Clean => {}
Escape::Unknown if annotated => {}
Escape::Unknown => out.push(brink_ir::Diagnostic {
file,
range,
message: format!(
"`{def_label}`'s {slot_label} escapes strict inference as Unknown — \
annotate or restructure"
),
code: brink_ir::DiagnosticCode::E065,
}),
Escape::Conflicted => out.push(brink_ir::Diagnostic {
file,
range,
message: format!(
"`{def_label}`'s {slot_label} is Conflicted under strict types — its uses \
disagree on its type (observed as `{}`)",
ty.display()
),
code: brink_ir::DiagnosticCode::E066,
}),
}
}
enum Escape {
Clean,
Unknown,
Conflicted,
}
fn classify(ty: &Ty) -> Escape {
match ty {
Ty::Conflicted => Escape::Conflicted,
Ty::Unknown => Escape::Unknown,
Ty::Array(elem) | Ty::Option(elem) | Ty::Weighted(elem) => classify(elem),
Ty::Map(k, v) => match (classify(k), classify(v)) {
(Escape::Conflicted, _) | (_, Escape::Conflicted) => Escape::Conflicted,
(Escape::Unknown, _) | (_, Escape::Unknown) => Escape::Unknown,
(Escape::Clean, Escape::Clean) => Escape::Clean,
},
Ty::Fn(params, ret, _) => {
params
.iter()
.chain(std::iter::once(ret.as_ref()))
.fold(Escape::Clean, |acc, t| match (acc, classify(t)) {
(Escape::Conflicted, _) | (_, Escape::Conflicted) => Escape::Conflicted,
(Escape::Unknown, _) | (_, Escape::Unknown) => Escape::Unknown,
(Escape::Clean, Escape::Clean) => Escape::Clean,
})
}
Ty::Int
| Ty::Float
| Ty::Bool
| Ty::String
| Ty::Content
| Ty::Divert
| Ty::List(_)
| Ty::Struct(_)
| Ty::Handle(_)
| Ty::Range { .. }
| Ty::Tower(_) => Escape::Clean,
}
}
fn check_value_calls(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
) -> Vec<brink_ir::Diagnostic> {
use crate::infer::ValueCallKind;
let mut out = Vec::new();
for &(file, hir) in files {
let mut def_ids: Vec<DefinitionId> = Vec::new();
for knot in &hir.knots {
let kind = knot.symbol_kind();
if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
def_ids.push(id);
}
for stitch in &knot.stitches {
let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
if let Some(id) =
annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
{
def_ids.push(id);
}
}
}
for id in def_ids {
let Some(body) = inference.bodies.get(&id) else {
continue;
};
for fact in &body.value_calls {
let callee = &fact.callee;
let (message, code) = match &fact.kind {
ValueCallKind::UnknownCallee => (
format!(
"`{callee}` is called as a function value but its type escapes \
strict inference as Unknown — annotate (`fn(T…): R`) or \
restructure"
),
brink_ir::DiagnosticCode::E065,
),
ValueCallKind::ConflictedCallee => (
format!(
"`{callee}` is called as a function value but its type is \
Conflicted under strict types — its uses disagree"
),
brink_ir::DiagnosticCode::E066,
),
ValueCallKind::NotCallable(ty) => (
format!(
"`{callee}` has type `{}` — not callable (a `fn(T…): R` \
function value is required in call position)",
ty.display()
),
brink_ir::DiagnosticCode::E063,
),
ValueCallKind::ArityMismatch { expected, got } => (
format!(
"call through `{callee}` supplies {got} argument(s) but its \
known type expects {expected}"
),
brink_ir::DiagnosticCode::E063,
),
ValueCallKind::ArgMismatch {
index,
expected,
found,
} => (
format!(
"argument {} of call through `{callee}` has type `{}` but its \
known type expects `{}`",
index + 1,
found.display(),
expected.display()
),
brink_ir::DiagnosticCode::E063,
),
ValueCallKind::OverBind { available, got } => (
format!(
"`bind` through `{callee}` supplies {got} argument(s) but only \
{available} parameter(s) remain in its known type"
),
brink_ir::DiagnosticCode::E063,
),
};
out.push(brink_ir::Diagnostic {
file,
range: fact.range,
message,
code,
});
}
}
}
out
}
fn check_direct_call_args(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
) -> Vec<brink_ir::Diagnostic> {
let mut out = Vec::new();
for &(file, hir) in files {
let mut def_ids: Vec<DefinitionId> = Vec::new();
if !hir.root_content.stmts.is_empty() {
let synthetic_id = crate::infer::root_content_def_id(file);
def_ids.push(synthetic_id);
}
for knot in &hir.knots {
let kind = knot.symbol_kind();
if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
def_ids.push(id);
}
for stitch in &knot.stitches {
let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
if let Some(id) =
annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
{
def_ids.push(id);
}
}
}
for id in def_ids {
let Some(body) = inference.bodies.get(&id) else {
continue;
};
for fact in &body.direct_call_arg_mismatches {
out.push(brink_ir::Diagnostic {
file,
range: fact.range,
message: format!(
"argument {} of call to `{}` has type `{}` but its known \
type expects `{}`",
fact.index + 1,
fact.callee,
fact.found.display(),
fact.expected.display()
),
code: brink_ir::DiagnosticCode::E063,
});
}
}
}
out
}
fn body_def_ids(file: FileId, hir: &HirFile, index: &SymbolIndex) -> Vec<DefinitionId> {
let mut def_ids: Vec<DefinitionId> = Vec::new();
if !hir.root_content.stmts.is_empty() {
let synthetic_id = crate::infer::root_content_def_id(file);
def_ids.push(synthetic_id);
}
for knot in &hir.knots {
let kind = knot.symbol_kind();
if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
def_ids.push(id);
}
for stitch in &knot.stitches {
let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
if let Some(id) = annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified) {
def_ids.push(id);
}
}
}
def_ids
}
fn check_typed_assign_mismatches(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
) -> Vec<brink_ir::Diagnostic> {
let mut out = Vec::new();
for &(file, hir) in files {
for id in body_def_ids(file, hir, index) {
let Some(body) = inference.bodies.get(&id) else {
continue;
};
for fact in &body.typed_assign_mismatches {
out.push(brink_ir::Diagnostic {
file,
range: fact.range,
message: format!(
"`{}` has type `{}` but its declared type is `{}`",
fact.target,
fact.found.display(),
fact.expected.display()
),
code: brink_ir::DiagnosticCode::E063,
});
}
}
}
out
}
fn check_lambda_annotation_mismatches(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
) -> Vec<brink_ir::Diagnostic> {
let mut out = Vec::new();
for &(file, hir) in files {
for id in body_def_ids(file, hir, index) {
let Some(body) = inference.bodies.get(&id) else {
continue;
};
for fact in &body.lambda_annotation_mismatches {
let message = match &fact.param_name {
Some(name) => format!(
"lambda parameter `{name}` is annotated `{}` but its body infers `{}`",
fact.expected.display(),
fact.found.display()
),
None => format!(
"lambda return type is annotated `{}` but its body infers `{}`",
fact.expected.display(),
fact.found.display()
),
};
out.push(brink_ir::Diagnostic {
file,
range: fact.range,
message,
code: brink_ir::DiagnosticCode::E174,
});
}
}
}
out
}
fn check_global_initializers(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
manifest: Option<&brink_ir::HostManifest>,
) -> Vec<brink_ir::Diagnostic> {
let names = annotations::TypeNames::new(index, manifest);
let mut out = Vec::new();
for &(file, hir) in files {
for v in &hir.variables {
check_one_global_initializer(
&v.name.text,
&v.value,
v.annotation.as_ref(),
file,
index,
&names,
&mut out,
);
}
for c in &hir.constants {
check_one_global_initializer(
&c.name.text,
&c.value,
c.annotation.as_ref(),
file,
index,
&names,
&mut out,
);
}
}
out
}
fn check_one_global_initializer(
name: &str,
value: &Expr,
annotation: Option<&TypeExpr>,
file: FileId,
index: &SymbolIndex,
names: &annotations::TypeNames,
out: &mut Vec<brink_ir::Diagnostic>,
) {
let Some(te) = annotation else { return };
let Some(ann_ty) = annotations::resolve(te, names) else {
return;
};
let Some(lit_ty) = crate::signature::literal_ty(value, index) else {
return;
};
if lit_ty.is_unresolved() || crate::infer::assignable(&ann_ty, &lit_ty) {
return;
}
out.push(brink_ir::Diagnostic {
file,
range: te.range(),
message: format!(
"`{name}`'s declared type `{}` disagrees with its initializer's type (`{}`)",
ann_ty.display(),
lit_ty.display()
),
code: brink_ir::DiagnosticCode::E063,
});
}
fn check_array_remove_calls(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
) -> Vec<brink_ir::Diagnostic> {
let mut out = Vec::new();
for &(file, hir) in files {
let mut def_ids: Vec<DefinitionId> = Vec::new();
for knot in &hir.knots {
let kind = knot.symbol_kind();
if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
def_ids.push(id);
}
for stitch in &knot.stitches {
let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
if let Some(id) =
annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
{
def_ids.push(id);
}
}
}
for id in def_ids {
let Some(body) = inference.bodies.get(&id) else {
continue;
};
for &range in &body.array_remove_calls {
out.push(brink_ir::Diagnostic {
file,
range,
message: brink_ir::DiagnosticCode::E149.title().to_owned(),
code: brink_ir::DiagnosticCode::E149,
});
}
}
}
out
}
fn range_key(range: TextRange) -> (u32, u32) {
(range.start().into(), range.end().into())
}
#[must_use]
fn check_void_assignments(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
inference: &InferenceResult,
) -> Vec<brink_ir::Diagnostic> {
let void_defs = collect_void_defs(files, index, inference);
if void_defs.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for &(file, hir) in files {
let resolution_by_range = resolution_index(resolutions, file);
for knot in &hir.knots {
check_void_block(file, &knot.body, &void_defs, &resolution_by_range, &mut out);
for stitch in &knot.stitches {
check_void_block(
file,
&stitch.body,
&void_defs,
&resolution_by_range,
&mut out,
);
}
}
}
out
}
fn collect_void_defs(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inference: &InferenceResult,
) -> BTreeSet<DefinitionId> {
let mut out = BTreeSet::new();
for &(file, hir) in files {
for knot in &hir.knots {
if !knot.is_function {
continue;
}
let kind = knot.symbol_kind();
let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) else {
continue;
};
let has_void_annotation = knot
.return_type
.as_ref()
.is_some_and(|rt| matches!(rt, TypeExpr::Named { name, .. } if name == "void"));
let inferred_void = knot.return_type.is_none()
&& inference.bodies.contains_key(&id)
&& !has_value_return_over_stitches(knot, id, file, index, inference);
if has_void_annotation || inferred_void {
out.insert(id);
}
}
}
out
}
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()
}
fn check_void_block(
file: FileId,
block: &Block,
void_defs: &BTreeSet<DefinitionId>,
resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
out: &mut Vec<brink_ir::Diagnostic>,
) {
for stmt in &block.stmts {
check_void_stmt(file, stmt, void_defs, resolution_by_range, out);
}
}
fn check_void_stmt(
file: FileId,
stmt: &Stmt,
void_defs: &BTreeSet<DefinitionId>,
resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
out: &mut Vec<brink_ir::Diagnostic>,
) {
match stmt {
Stmt::TempDecl(t) => {
if let Some(value) = &t.value {
check_void_root(file, value, void_defs, resolution_by_range, out);
}
}
Stmt::Assignment(a) => {
check_void_root(file, &a.value, void_defs, resolution_by_range, out);
}
Stmt::ChoiceSet(cs) => {
for choice in &cs.choices {
check_void_block(file, &choice.body, void_defs, resolution_by_range, out);
if let Some(c) = &choice.start_content {
check_void_content(file, c, void_defs, resolution_by_range, out);
}
if let Some(c) = &choice.bracket_content {
check_void_content(file, c, void_defs, resolution_by_range, out);
}
if let Some(c) = &choice.inner_content {
check_void_content(file, c, void_defs, resolution_by_range, out);
}
}
check_void_block(file, &cs.continuation, void_defs, resolution_by_range, out);
}
Stmt::LabeledBlock(b) => check_void_block(file, b, void_defs, resolution_by_range, out),
Stmt::Conditional(c) => {
for branch in &c.branches {
check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
}
}
Stmt::Sequence(s) => {
for branch in &s.branches {
check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
}
}
Stmt::Content(c) => check_void_content(file, c, void_defs, resolution_by_range, out),
Stmt::LogicBlock(lb) => {
for bs in &lb.stmts {
check_void_block_stmt(file, bs, void_defs, resolution_by_range, out);
}
}
Stmt::Await(a) => {
if let Some(cond) = &a.condition {
check_void_root(file, cond, void_defs, resolution_by_range, out);
}
}
Stmt::AttachElement(e) => {
check_void_root(file, e, void_defs, resolution_by_range, out);
}
Stmt::Divert(_)
| Stmt::TunnelCall(_)
| Stmt::ThreadStart(_)
| Stmt::Return(_)
| Stmt::ExprStmt(_)
| Stmt::EndOfLine
| Stmt::EndElementRun => {}
}
}
fn check_void_content(
file: FileId,
content: &Content,
void_defs: &BTreeSet<DefinitionId>,
resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
out: &mut Vec<brink_ir::Diagnostic>,
) {
for part in &content.parts {
check_void_content_part(file, part, void_defs, resolution_by_range, out);
}
}
fn check_void_content_part(
file: FileId,
part: &ContentPart,
void_defs: &BTreeSet<DefinitionId>,
resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
out: &mut Vec<brink_ir::Diagnostic>,
) {
match part {
ContentPart::InlineConditional(c) => {
for branch in &c.branches {
check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
}
}
ContentPart::InlineSequence(s) => {
for branch in &s.branches {
check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
}
}
ContentPart::Span(span) => {
for child in &span.children {
check_void_content_part(file, child, void_defs, resolution_by_range, out);
}
}
ContentPart::Interpolation(_)
| ContentPart::Text(_)
| ContentPart::Glue
| ContentPart::Spring => {}
}
}
fn check_void_block_stmt(
file: FileId,
bs: &BlockStmt,
void_defs: &BTreeSet<DefinitionId>,
resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
out: &mut Vec<brink_ir::Diagnostic>,
) {
match bs {
BlockStmt::TempDecl(t) => {
if let Some(value) = &t.value {
check_void_root(file, value, void_defs, resolution_by_range, out);
}
}
BlockStmt::Assignment(a) => {
check_void_root(file, &a.value, void_defs, resolution_by_range, out);
}
BlockStmt::If(i) => check_void_if(file, i, void_defs, resolution_by_range, out),
BlockStmt::While(w) => {
for s in &w.body {
check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
}
}
BlockStmt::For(f) => {
for s in &f.body {
check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
}
}
BlockStmt::Await(a) => {
if let Some(cond) = &a.condition {
check_void_root(file, cond, void_defs, resolution_by_range, out);
}
}
BlockStmt::Return(_)
| BlockStmt::ExprStmt(_)
| BlockStmt::Break(_)
| BlockStmt::Continue(_) => {}
}
}
fn check_void_if(
file: FileId,
i: &IfStmt,
void_defs: &BTreeSet<DefinitionId>,
resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
out: &mut Vec<brink_ir::Diagnostic>,
) {
for s in &i.body {
check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
}
match &i.else_branch {
Some(ElseBranch::ElseIf(inner)) => {
check_void_if(file, inner, void_defs, resolution_by_range, out);
}
Some(ElseBranch::Else(stmts)) => {
for s in stmts {
check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
}
}
None => {}
}
}
fn check_void_root(
file: FileId,
expr: &Expr,
void_defs: &BTreeSet<DefinitionId>,
resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
out: &mut Vec<brink_ir::Diagnostic>,
) {
let Expr::Call(path, _) = expr else {
return;
};
let Some(&def_id) = resolution_by_range.get(&range_key(path.range)) else {
return;
};
if !void_defs.contains(&def_id) {
return;
}
out.push(brink_ir::Diagnostic {
file,
range: path.range,
message: format!(
"`{}` returns void — its result cannot be assigned (docs/typed-mode-spec.md §3)",
path_display(path)
),
code: brink_ir::DiagnosticCode::E067,
});
}
fn path_display(path: &Path) -> String {
path.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(".")
}
struct TempDecl {
range: TextRange,
annotation_ty: Option<Ty>,
}
fn collect_temps(body: &Block, names: &annotations::TypeNames) -> BTreeMap<String, TempDecl> {
let mut out = BTreeMap::new();
collect_temps_block(body, names, &mut out);
out
}
fn collect_temps_block(
block: &Block,
names: &annotations::TypeNames,
out: &mut BTreeMap<String, TempDecl>,
) {
for stmt in &block.stmts {
collect_temps_stmt(stmt, names, out);
}
}
fn collect_temps_stmt(
stmt: &Stmt,
names: &annotations::TypeNames,
out: &mut BTreeMap<String, TempDecl>,
) {
match stmt {
Stmt::TempDecl(t) => {
let annotation_ty = t
.annotation
.as_ref()
.and_then(|te| annotations::resolve(te, names));
out.insert(
t.name.text.clone(),
TempDecl {
range: t.name.range,
annotation_ty,
},
);
}
Stmt::ChoiceSet(cs) => {
for choice in &cs.choices {
collect_temps_block(&choice.body, names, out);
if let Some(c) = &choice.start_content {
collect_temps_content(c, names, out);
}
if let Some(c) = &choice.bracket_content {
collect_temps_content(c, names, out);
}
if let Some(c) = &choice.inner_content {
collect_temps_content(c, names, out);
}
}
collect_temps_block(&cs.continuation, names, out);
}
Stmt::LabeledBlock(b) => collect_temps_block(b, names, out),
Stmt::Conditional(c) => {
for branch in &c.branches {
collect_temps_block(&branch.body, names, out);
}
}
Stmt::Sequence(s) => {
for branch in &s.branches {
collect_temps_block(&branch.body, names, out);
}
}
Stmt::Content(c) => collect_temps_content(c, names, out),
Stmt::LogicBlock(lb) => {
for bs in &lb.stmts {
collect_temps_block_stmt(bs, names, out);
}
}
Stmt::Divert(_)
| Stmt::TunnelCall(_)
| Stmt::ThreadStart(_)
| Stmt::Assignment(_)
| Stmt::Return(_)
| Stmt::ExprStmt(_)
| Stmt::Await(_)
| Stmt::EndOfLine
| Stmt::AttachElement(_)
| Stmt::EndElementRun => {}
}
}
fn collect_temps_content(
content: &Content,
names: &annotations::TypeNames,
out: &mut BTreeMap<String, TempDecl>,
) {
for part in &content.parts {
collect_temps_content_part(part, names, out);
}
}
fn collect_temps_content_part(
part: &ContentPart,
names: &annotations::TypeNames,
out: &mut BTreeMap<String, TempDecl>,
) {
match part {
ContentPart::InlineConditional(c) => {
for branch in &c.branches {
collect_temps_block(&branch.body, names, out);
}
}
ContentPart::InlineSequence(s) => {
for branch in &s.branches {
collect_temps_block(&branch.body, names, out);
}
}
ContentPart::Span(span) => {
for child in &span.children {
collect_temps_content_part(child, names, out);
}
}
ContentPart::Interpolation(_)
| ContentPart::Text(_)
| ContentPart::Glue
| ContentPart::Spring => {}
}
}
fn collect_temps_block_stmt(
bs: &BlockStmt,
names: &annotations::TypeNames,
out: &mut BTreeMap<String, TempDecl>,
) {
match bs {
BlockStmt::TempDecl(t) => {
let annotation_ty = t
.annotation
.as_ref()
.and_then(|te| annotations::resolve(te, names));
out.insert(
t.name.text.clone(),
TempDecl {
range: t.name.range,
annotation_ty,
},
);
}
BlockStmt::If(i) => collect_temps_if(i, names, out),
BlockStmt::While(w) => {
for s in &w.body {
collect_temps_block_stmt(s, names, out);
}
}
BlockStmt::For(f) => {
for s in &f.body {
collect_temps_block_stmt(s, names, out);
}
}
BlockStmt::Assignment(_)
| BlockStmt::Return(_)
| BlockStmt::ExprStmt(_)
| BlockStmt::Await(_)
| BlockStmt::Break(_)
| BlockStmt::Continue(_) => {}
}
}
fn collect_temps_if(
i: &IfStmt,
names: &annotations::TypeNames,
out: &mut BTreeMap<String, TempDecl>,
) {
for s in &i.body {
collect_temps_block_stmt(s, names, out);
}
match &i.else_branch {
Some(ElseBranch::ElseIf(inner)) => collect_temps_if(inner, names, out),
Some(ElseBranch::Else(stmts)) => {
for s in stmts {
collect_temps_block_stmt(s, names, out);
}
}
None => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::{Diagnostic, DiagnosticCode, ResolutionMap, hir::lower};
#[test]
fn resolve_brink_implicit_defaults_strict() {
assert_eq!(
resolve_type_policy(crate::Dialect::Brink, None),
TypePolicy::Strict
);
}
#[test]
fn resolve_strict_ink_implicit_defaults_gradual() {
assert_eq!(
resolve_type_policy(crate::Dialect::StrictInk, None),
TypePolicy::Gradual
);
}
#[test]
fn resolve_brink_explicit_gradual_wins() {
assert_eq!(
resolve_type_policy(crate::Dialect::Brink, Some(TypePolicy::Gradual)),
TypePolicy::Gradual
);
}
#[test]
fn resolve_brink_explicit_strict_stays_strict() {
assert_eq!(
resolve_type_policy(crate::Dialect::Brink, Some(TypePolicy::Strict)),
TypePolicy::Strict
);
}
#[test]
fn resolve_strict_ink_explicit_gradual_stays_gradual() {
assert_eq!(
resolve_type_policy(crate::Dialect::StrictInk, Some(TypePolicy::Gradual)),
TypePolicy::Gradual
);
}
#[test]
fn resolve_strict_ink_explicit_strict_wins() {
assert_eq!(
resolve_type_policy(crate::Dialect::StrictInk, Some(TypePolicy::Strict)),
TypePolicy::Strict
);
}
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 codes(diags: &[Diagnostic]) -> Vec<DiagnosticCode> {
let mut v: Vec<DiagnosticCode> = diags.iter().map(|d| d.code).collect();
v.sort_by_key(|c| c.as_str());
v
}
#[test]
fn config_error_fires_for_strict_ink_dialect() {
let diag = config_error(crate::Dialect::StrictInk, Some(FileId(0)));
assert!(diag.is_some());
assert_eq!(diag.expect("checked above").code, DiagnosticCode::E064);
}
#[test]
fn config_error_is_none_for_brink_dialect() {
assert!(config_error(crate::Dialect::Brink, Some(FileId(0))).is_none());
}
#[test]
fn config_error_is_none_with_no_files() {
assert!(config_error(crate::Dialect::StrictInk, None).is_none());
}
#[test]
fn strict_diagnostics_is_native_true_never_fires_config_error() {
let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
let opts = crate::AnalysisOptions {
types: Some(TypePolicy::Strict),
..Default::default()
};
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&opts,
true,
None,
&BTreeMap::new(),
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E064),
"native must never see the ink-only dialect config error: {diags:?}"
);
}
#[test]
fn strict_diagnostics_is_native_true_still_runs_inference_checks() {
let (hir, index, res) = build("=== noop(x) ===\nHello.\n-> DONE\n");
let opts = crate::AnalysisOptions {
types: Some(TypePolicy::Strict),
..Default::default()
};
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&opts,
true,
None,
&BTreeMap::new(),
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E065);
}
#[test]
fn strict_diagnostics_is_native_false_unaffected_still_fires_config_error() {
let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
let opts = crate::AnalysisOptions {
types: Some(TypePolicy::Strict),
..Default::default()
};
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&opts,
false,
None,
&BTreeMap::new(),
);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E064);
}
#[test]
fn native_strict_only_fires_for_explicit_gradual() {
let diag = native_strict_only_error(FileId(0), Some(TypePolicy::Gradual));
assert!(diag.is_some());
assert_eq!(diag.expect("checked above").code, DiagnosticCode::E137);
}
#[test]
fn native_strict_only_is_none_for_explicit_strict() {
assert!(native_strict_only_error(FileId(0), Some(TypePolicy::Strict)).is_none());
}
#[test]
fn native_strict_only_is_none_for_unset_types() {
assert!(native_strict_only_error(FileId(0), None).is_none());
}
#[test]
fn unused_param_escapes_as_unknown() {
let (hir, index, res) = build("=== noop(x) ===\nHello.\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E065);
assert!(diags[0].message.contains('x'));
}
#[test]
fn annotated_unused_param_is_exempt_from_unknown_escape() {
let (hir, index, res) = build("=== noop(x: int) ===\nHello.\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "annotation supplies the type: {diags:?}");
}
#[test]
fn annotated_handle_param_is_exempt_from_unknown_escape_when_kind_is_registered() {
let (hir, index, res) = build("=== noop(x: Handle<AudioInstance>) ===\nHello.\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
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,
}],
..Default::default()
};
let diags = check(
&[(FileId(0), &hir)],
&index,
&inference,
&res,
Some(&manifest),
);
assert!(diags.is_empty(), "annotation supplies the type: {diags:?}");
}
fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
let parsed = brink_syntax_native::parse(src);
assert!(
parsed.errors().is_empty(),
"fixture must parse cleanly: {:?}",
parsed.errors()
);
let tree = parsed.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())
}
fn native_strict_diags(src: &str) -> Vec<Diagnostic> {
let (hir, index, res) = build_native(src);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
check(&[(FileId(0), &hir)], &index, &inference, &res, None)
}
#[test]
fn native_unannotated_param_escapes_as_unknown() {
let diags = native_strict_diags("flow noop(x) {\n Hello.\n}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E065);
}
#[test]
fn native_annotated_param_is_exempt_from_unknown_escape() {
let diags = native_strict_diags("flow noop(x: int) {\n Hello.\n}\n");
assert!(
diags.is_empty(),
"the `: int` annotation supplies the type: {diags:?}"
);
}
#[test]
fn native_returning_a_content_param_takes_its_annotated_type() {
let bare = native_strict_diags("fn passthru(t: content) {\n return t;\n}\n");
assert!(
bare.is_empty(),
"`t: content` supplies the return type: {bare:?}"
);
let annotated = native_strict_diags("fn passthru(t: content): content {\n return t;\n}\n");
assert!(
annotated.is_empty(),
"the annotated twin stays clean: {annotated:?}"
);
}
#[test]
fn native_returning_an_annotated_param_is_not_content_specific() {
for ty in ["int", "float", "bool", "string"] {
let src = format!("fn passthru(t: {ty}) {{\n return t;\n}}\n");
let diags = native_strict_diags(&src);
assert!(
diags.is_empty(),
"`t: {ty}` supplies the return type: {diags:?}"
);
}
}
#[test]
fn native_a_body_use_contradicting_the_annotation_still_reports_e063() {
let diags = native_strict_diags("fn f(a: int) {\n return a + \"x\";\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E063),
"a body use disagreeing with the annotation still reports E063: {diags:?}"
);
}
#[test]
fn native_returning_a_param_that_disagrees_with_the_return_annotation_reports_e063() {
let diags = native_strict_diags("fn f(t: content): string {\n return t;\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E063),
"returning a `content` param from a `: string` fn disagrees: {diags:?}"
);
}
#[test]
fn native_unresolvable_param_annotation_still_escapes() {
let diags = native_strict_diags("flow noop(x: Nonesuch) {\n Hello.\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E065),
"an unresolvable annotation must not exempt the slot: {diags:?}"
);
}
#[test]
fn native_annotated_let_is_exempt_from_unknown_escape() {
let bare = native_strict_diags("fn f(n: int): int {\n let t;\n return n;\n}\n");
assert!(
bare.iter().any(|d| d.code == DiagnosticCode::E065),
"an unannotated, uninferable `let` escapes: {bare:?}"
);
let annotated =
native_strict_diags("fn f(n: int): int {\n let t: string;\n return n;\n}\n");
assert!(
annotated.is_empty(),
"the `: string` ascription supplies the type: {annotated:?}"
);
}
#[test]
fn native_lambda_local_temp_ascription_now_reaches_its_own_escape_check() {
let unannotated = native_strict_diags(
"fn f(n: int): int {\n let g = |x: int|: int {\n let t;\n x\n };\n return n;\n}\n",
);
assert_eq!(
unannotated.len(),
1,
"the lambda's own unannotated `let t` (never used, so genuinely \
`Unknown`) now escapes in its own right: {unannotated:?}"
);
assert_eq!(unannotated[0].code, DiagnosticCode::E065);
assert!(
unannotated[0].message.contains("lambda temp `t`"),
"{unannotated:?}"
);
let ascribed = native_strict_diags(
"fn f(n: int): int {\n let g = |x: int|: int {\n let t: string;\n x\n };\n return n;\n}\n",
);
assert!(
ascribed.is_empty(),
"the `: string` ascription now supplies the type, exempting the \
lambda's own temp exactly like a top-level one: {ascribed:?}"
);
}
#[test]
fn native_shadowed_lambda_local_temp_does_not_exempt_enclosing_temp() {
let diags = native_strict_diags(
"fn f(n: int): int {\n let t;\n let g = |x: int|: int {\n let t: string;\n x\n };\n return n;\n}\n",
);
assert_eq!(
diags.len(),
1,
"the enclosing, unannotated `let t;` must still escape as E065 \
even though a lambda-local `let t: string;` shadows the same \
bare name with its own ascription — a naive `Expr::Lambda` arm \
in `collect_temps` would overwrite the enclosing `TempDecl` \
and silently swallow this; the lambda's own `t` is separately \
exempt by its own ascription, so nothing else should appear: \
{diags:?}"
);
assert_eq!(diags[0].code, DiagnosticCode::E065);
}
#[test]
fn native_lambda_local_temp_with_conflicting_uses_reports_e066() {
let diags = native_strict_diags(
"fn f(n: int): int {\n let g = |x: int|: int {\n let t = 1;\n t = \"oops\";\n x\n };\n return n;\n}\n",
);
assert_eq!(
diags.len(),
1,
"the lambda's own `t` genuinely disagrees with itself (`int` \
then `string`) and must escape as E066, not merely go \
unreported the way a lambda-local temp did before #1770: \
{diags:?}"
);
assert_eq!(diags[0].code, DiagnosticCode::E066);
assert!(diags[0].message.contains("lambda temp `t`"), "{diags:?}");
}
#[test]
fn native_lambda_rebound_param_escape_is_attributed_to_the_temp_not_the_param() {
let diags = native_strict_diags(
"fn f(n: int): int {\n let g = |t: int| {\n let t = 1;\n t = \"oops\";\n t\n };\n return n;\n}\n",
);
assert_eq!(
diags.len(),
2,
"the rebound local `t`'s own int/string contradiction escapes \
at its own lambda-frame slot, and `g`'s own inferred \
fn(Conflicted): Conflicted type recursively escapes too: \
{diags:?}"
);
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E066));
assert!(
diags.iter().any(|d| d.message.contains("lambda temp `t`")),
"must be attributed to the rebound local, not the annotated \
parameter of the same name: {diags:?}"
);
assert!(
diags.iter().any(|d| d.message.contains("temp `g`")),
"{diags:?}"
);
assert!(
diags
.iter()
.all(|d| !d.message.contains("lambda parameter `t`")),
"the annotated parameter `t` must never be blamed for a \
contradiction entirely internal to the local that shadows it: \
{diags:?}"
);
}
#[test]
fn native_nested_lambda_inside_lambda_gets_its_own_escape_frame_too() {
let diags = native_strict_diags(
"fn f(n: int): int {\n let g = |x: int|: int {\n let h = |y| y;\n x\n };\n return n;\n}\n",
);
assert_eq!(
diags.len(),
2,
"`h`'s own param `y` (only reachable by recursing into `g`'s \
nested lambda) and `g`'s own temp `h` (whose `fn(Unknown): \
Unknown` type itself classifies as Unknown) are two \
independent escapes: {diags:?}"
);
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E065));
assert!(
diags
.iter()
.any(|d| d.message.contains("lambda parameter `y`")),
"{diags:?}"
);
assert!(
diags.iter().any(|d| d.message.contains("lambda temp `h`")),
"{diags:?}"
);
}
#[test]
fn native_lambda_tail_sees_its_own_block_locals() {
let stmt_position = native_strict_diags(
"fn f(n: int): int {\n let g = |x: int|: int {\n let h = |y: int|: int { y };\n h(1, 2);\n x\n };\n return n;\n}\n",
);
assert!(
stmt_position.iter().any(|d| d.code == DiagnosticCode::E063),
"baseline: an over-applied call to a lambda-local fn temp in \
*statement* position is inside the #1750 frame window and has \
always been checked: {stmt_position:?}"
);
let tail_position = native_strict_diags(
"fn f(n: int): int {\n let g = |x: int|: int {\n let h = |y: int|: int { y };\n h(1, 2)\n };\n return n;\n}\n",
);
assert!(
tail_position.iter().any(|d| d.code == DiagnosticCode::E063),
"the very same over-application in *tail* position must be \
checked too — the tail is the block's value position and reads \
the locals its own statements bound (#1789): {tail_position:?}"
);
}
#[test]
fn native_lambda_tail_does_not_corrupt_a_shadowed_enclosing_local() {
const TAKES_STRING: &str = "fn takes_string(s: string): string {\n return s;\n}\n";
let stmt_position = native_strict_diags(&format!(
"{TAKES_STRING}fn f(n: int): int {{\n let t = 1;\n let g = |x: int|: int {{\n let t = \"hi\";\n takes_string(t);\n x\n }};\n return n;\n}}\n"
));
assert!(
stmt_position.is_empty(),
"baseline: a lambda-local `t` used in argument position from a \
*statement* is confined by #1750's snapshot/restore, so the \
enclosing `let t = 1` stays `int`: {stmt_position:?}"
);
let tail_position = native_strict_diags(&format!(
"{TAKES_STRING}fn f(n: int): int {{\n let t = 1;\n let g = |x: int|: string {{\n let t = \"hi\";\n takes_string(t)\n }};\n return n;\n}}\n"
));
assert!(
tail_position.is_empty(),
"the same use in *tail* position must be confined the same way — \
before #1789 it unified `string` into the enclosing `f`'s own \
`t: int` and reported a spurious E066 Conflicted-escape on it: \
{tail_position:?}"
);
}
#[test]
fn native_lambda_tail_capture_use_no_longer_narrows_enclosing_capture() {
const TAKES_STRING: &str = "fn takes_string(s: string): string {\n return s;\n}\n";
let stmt_position = native_strict_diags(&format!(
"{TAKES_STRING}fn f(n: int): int {{\n let c;\n let g = ||: int {{ takes_string(c); 1 }};\n return n;\n}}\n"
));
assert!(
stmt_position.iter().any(|d| d.code == DiagnosticCode::E065),
"baseline: `f`'s own unannotated `let c;` still E065-escapes \
when the capturing use is in *statement* position, on both \
sides of #1789 — the use is never enough to narrow it: \
{stmt_position:?}"
);
let tail_position = native_strict_diags(&format!(
"{TAKES_STRING}fn f(n: int): int {{\n let c;\n let g = ||: string {{ takes_string(c) }};\n return n;\n}}\n"
));
assert!(
tail_position.iter().any(|d| d.code == DiagnosticCode::E065),
"the same capturing use from *tail* position must escape the \
same way — before #1789 the tail's `observe` ran against \
whatever frame was live *after* the restore and could narrow \
`f`'s own `c`; opening the frame around the tail keys that \
`observe` to the lambda's own (discarded) frame instead, so \
`c` is left exactly as unannotated as the statement-position \
twin: {tail_position:?}"
);
}
#[test]
fn native_map_result_infers_from_unannotated_lambda_body() {
let diags = native_strict_diags(
"fn doubled() {\n let items = [1, 2, 3];\n return map(items, |x| x * 2);\n}\n",
);
assert!(
diags.is_empty(),
"`x * 2` pins `x` (and so `map`'s result) to `int` from the \
callback's own body alone, with no surrounding annotation: \
{diags:?}"
);
}
#[test]
fn native_fold_result_falls_back_to_the_seed_when_the_callback_body_is_unconstrained() {
let diags = native_strict_diags(
"fn total() {\n let items = [1, 2, 3, 4];\n return fold(items, 0, |acc, x| acc + x);\n}\n",
);
assert_eq!(
diags.len(),
2,
"only the lambda's own two unconstrained params should escape — \
`total`'s own return type must still fall back cleanly to the \
seed's `int`: {diags:?}"
);
assert!(
diags.iter().all(|d| d.code == DiagnosticCode::E065
&& (d.message.contains("lambda parameter `acc`")
|| d.message.contains("lambda parameter `x`"))),
"{diags:?}"
);
}
#[test]
fn native_fold_still_reports_conflicted_when_the_callback_body_genuinely_conflicts() {
let diags = native_strict_diags(
"fn fold_conflicted() {\n let items = [1, 2, 3];\n return fold(items, 0, |a, b| {\n let t = a + 1;\n let t2 = a - \"oops\";\n t2\n });\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E066),
"`a` is joined against `int` (`a + 1`) and then `string` \
(`a - \"oops\"`) inside the callback's own body — a genuine \
conflict that must surface as E066, not be silently replaced \
by the seed's `int`: {diags:?}"
);
}
#[test]
fn native_verb_result_bound_to_a_let_is_not_unknown() {
let diags = native_strict_diags(
"fn let_map_then_len() {\n let items = [1, 2, 3];\n let out = map(items, |x| x * 2);\n return len(out);\n}\n",
);
assert!(
diags.is_empty(),
"`out`'s type comes from `map`'s own now-concrete result, not \
just the return position — a genuinely intermediate binding \
must be just as clean: {diags:?}"
);
}
#[test]
fn native_block_bodied_lambda_return_feeds_the_verb_result_too() {
let diags = native_strict_diags(
"fn ret_from_block_lambda() {\n let items = [1, 2, 3];\n return map(items, |x| {\n return x * 3;\n });\n}\n",
);
assert!(
diags.is_empty(),
"the callback's `return x * 3;` pins its own return type to \
`int` exactly like a trailing tail expression would: {diags:?}"
);
}
#[test]
fn native_lambda_bound_local_takes_its_own_fn_type() {
let diags = native_strict_diags(
"fn lambda_let(): int {\n let f = |x| x + 1;\n return f(1);\n}\n",
);
assert!(
diags.is_empty(),
"`f`'s own inferred type is `fn(int): int` (from `x + 1`'s body \
alone), not `Unknown` — `docs/typed-mode-spec.md` §3: a \
lambda-bound local takes the lambda's own `fn(T…): R` type: \
{diags:?}"
);
}
#[test]
fn native_lambda_temp_shadowing_an_enclosing_local_does_not_poison_the_lambda_result() {
let diags = native_strict_diags(
"fn scaled() {\n let a = 1;\n let items = [1, 2, 3];\n let scaled = map(items, |x| {\n let a = \"str\";\n a\n });\n return len(scaled);\n}\n",
);
assert_eq!(
diags.len(),
1,
"only the lambda's own unused param `x` should escape — the \
lambda's own `let a = \"str\";` is a fresh binding, wholly \
unrelated to the enclosing `let a = 1;` of the same name, and \
must not corrupt the lambda's own inferred `string` return \
into `Conflicted`: {diags:?}"
);
assert_eq!(diags[0].code, DiagnosticCode::E065);
assert!(
diags[0].message.contains("lambda parameter `x`"),
"{diags:?}"
);
}
#[test]
fn native_lambda_param_does_not_inherit_an_enclosing_annotated_local_of_the_same_name() {
let diags = native_strict_diags(
"fn f(): Array<Option<int>> {\n let x: string = \"s\";\n let items = [1, 2, 3];\n return map(items, |x| some(x));\n}\n",
);
assert_eq!(
diags.len(),
1,
"only the lambda's own unconstrained param `x` should escape — \
it must not inherit the enclosing `let x: string`'s annotated \
type merely because they share a bare name: {diags:?}"
);
assert_eq!(diags[0].code, DiagnosticCode::E065);
assert!(
diags[0].message.contains("lambda parameter `x`"),
"{diags:?}"
);
}
#[test]
fn native_fold_accumulator_is_not_poisoned_by_an_unrelated_dotted_field_read() {
let diags = native_strict_diags(
"struct Point {\n x: int,\n y: int\n}\n\nfn g(): int {\n let p = Point { x: 3, y: 4 };\n let items = [1, 2];\n return fold(items, 0, |a, b| p.x + a + b);\n}\n",
);
assert_eq!(
diags.len(),
2,
"only the lambda's own two params should escape — `p.x`'s own \
mistyped read must not poison `g`'s own `: int` return \
annotation, which has nothing to do with `p`'s own struct \
type: {diags:?}"
);
assert!(
diags.iter().all(|d| d.code == DiagnosticCode::E065
&& (d.message.contains("lambda parameter `a`")
|| d.message.contains("lambda parameter `b`"))),
"{diags:?}"
);
}
#[test]
fn native_verb_callback_param_still_escapes_when_the_body_places_no_constraint_on_it() {
let diags = native_strict_diags(
"fn scaled(factor) {\n let items = [1, 2, 3];\n return map(items, |x| x * factor);\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E065),
"`factor` is never pinned by any use anywhere in `scaled`'s own \
body (call-site-driven inference is forbidden by \
`docs/typed-mode-spec.md` §2), so both `factor` and the return \
type must still escape: {diags:?}"
);
}
#[test]
fn native_lambda_tail_reading_a_content_param_takes_its_annotated_type() {
let bare = native_strict_diags("fn f() {\n let g = |t: content| {\n t\n };\n}\n");
assert!(
bare.is_empty(),
"`t: content`'s tail read supplies the lambda's own return type: {bare:?}"
);
let annotated =
native_strict_diags("fn f() {\n let g = |t: content|: content {\n t\n };\n}\n");
assert!(
annotated.is_empty(),
"the lambda-return-annotated twin stays clean: {annotated:?}"
);
}
#[test]
fn native_lambda_tail_reading_an_annotated_param_is_not_content_specific() {
for ty in ["int", "float", "bool", "string"] {
let src = format!("fn f() {{\n let g = |t: {ty}| {{\n t\n }};\n}}\n");
let diags = native_strict_diags(&src);
assert!(
diags.is_empty(),
"`t: {ty}`'s tail read supplies the lambda's own return type: {diags:?}"
);
}
}
#[test]
fn native_lambda_expr_body_reading_an_annotated_param_exports_its_declared_type() {
let diags = native_strict_diags("fn f() {\n let g = |t: content| t;\n}\n");
assert!(
diags.is_empty(),
"an expression-bodied lambda's sole expression is its value \
position, exactly like a block's tail: {diags:?}"
);
}
#[test]
fn native_lambda_param_annotation_seed_does_not_leak_into_a_rebound_temp_of_the_same_name() {
let diags = native_strict_diags(
"fn f() {\n let g = |t: int| {\n let t = \"a\";\n t = \"b\";\n t\n };\n}\n",
);
assert!(
diags.is_empty(),
"the lambda body's own `t` re-declaration shadows the param \
entirely; it has no `int` annotation of its own to conflict \
with a `string` assignment: {diags:?}"
);
}
#[test]
fn native_lambda_param_annotation_seed_reaches_every_own_annotation_read_site_in_the_body() {
let via_intrinsic_arg =
native_strict_diags("fn f() {\n let g = |t: int| {\n some(t)\n };\n}\n");
assert!(
via_intrinsic_arg.is_empty(),
"the seed reaches `some`'s argument-position read of `t`, not \
just the lambda's own tail: {via_intrinsic_arg:?}"
);
let via_callee_ty =
native_strict_diags("fn f() {\n let g = |cb: fn(int): int| {\n cb(1)\n };\n}\n");
assert!(
via_callee_ty.is_empty(),
"the seed reaches `annotated_callee_ty`'s direct-call read of \
`cb`, not just the lambda's own tail: {via_callee_ty:?}"
);
}
#[test]
fn native_lambda_return_annotation_disagreement_is_e174() {
let diags =
native_strict_diags("fn f() {\n let g = |k: int|: int {\n \"wrong\"\n };\n}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E174);
assert!(
diags[0]
.message
.contains("lambda return type is annotated `int` but its body infers `string`"),
"{:?}",
diags[0].message
);
}
#[test]
fn native_lambda_param_annotation_disagreement_is_e174() {
let diags = native_strict_diags("fn f() {\n let g = |k: int| k == true;\n}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E174);
assert!(
diags[0]
.message
.contains("lambda parameter `k` is annotated `int` but its body infers `bool`"),
"{:?}",
diags[0].message
);
}
#[test]
fn native_lambda_param_widening_use_is_not_a_mismatch() {
let diags = native_strict_diags("fn f() {\n let g = |x: int| {\n x + 1.0\n };\n}\n");
assert!(
diags.is_empty(),
"an int-annotated param used as a float is legal widening, not \
a mismatch: {diags:?}"
);
}
#[test]
fn native_value_returning_knot_falling_through_is_e150() {
let diags = native_strict_diags("flow quest(): int {\n Onward.\n}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E150);
assert!(
diags[0].message.contains("never returns a value"),
"{:?}",
diags[0].message
);
}
#[test]
fn native_value_returning_nested_stitch_falling_through_is_e150() {
let diags =
native_strict_diags("flow garden() {\n flow gate(): int {\n Creak.\n }\n}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E150);
}
#[test]
fn native_value_returning_knot_that_always_returns_is_clean() {
let diags = native_strict_diags("flow quest(): int ~{\n return 5;\n}\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn native_value_returning_nested_stitch_that_always_returns_is_clean() {
let diags =
native_strict_diags("flow garden() {\n flow gate(): int ~{\n return 5;\n }\n}\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn native_value_returning_knot_with_unresolvable_return_still_escapes_as_unknown() {
let diags = native_strict_diags("flow quest(x): int ~{\n return x;\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E065),
"{diags:?}"
);
}
#[test]
fn native_void_annotated_knot_falling_through_is_exempt_from_e150() {
let diags = native_strict_diags("flow quest(): void {\n Onward.\n}\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn native_plain_knot_and_stitch_with_no_return_type_stay_unchecked() {
let diags = native_strict_diags("flow quest() {\n Onward.\n}\n");
assert!(diags.is_empty(), "{diags:?}");
let nested_diags =
native_strict_diags("flow garden() {\n flow gate() {\n Creak.\n }\n}\n");
assert!(nested_diags.is_empty(), "{nested_diags:?}");
}
#[test]
fn native_annotated_function_falling_through_is_e150_latent_bug_fix() {
let diags = native_strict_diags("fn noop(): int {\n let x = 1;\n}\n");
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E150);
}
#[test]
fn native_void_annotated_def_that_actually_returns_a_value_is_exempt() {
let function_diags = native_strict_diags("fn f(x: int): void {\n return x;\n}\n");
assert!(
function_diags.is_empty(),
"a void-annotated fn's own return value must not escape-check: {function_diags:?}"
);
let flow_diags = native_strict_diags("flow gate(x: int): void ~{\n return x;\n}\n");
assert!(
flow_diags.is_empty(),
"the flow/stitch twin must agree with the fn case: {flow_diags:?}"
);
}
#[test]
fn native_value_returning_knot_with_a_partial_return_path_is_undocumented_gap() {
let diags =
native_strict_diags("flow quest(): int ~{\n if true {\n return 1;\n }\n}\n");
assert!(
diags.is_empty(),
"partial-path fall-through is not currently detected: {diags:?}"
);
}
#[test]
fn handle_param_escapes_as_unknown_with_no_manifest_registered() {
let (hir, index, res) = build("=== noop(x: Handle<AudioInstance>) ===\nHello.\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E065);
}
#[test]
fn cross_kind_handle_comparison_from_body_usage_is_conflicted_under_strict() {
let src = "EXTERNAL spawn_audio()\nEXTERNAL spawn_timer()\n\
=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
=== main ===\n~ temp a = get_audio()\n~ temp b = get_timer()\n{a == b:\n ok\n}\n-> DONE\n";
let (hir, index, res) = build(src);
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![
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(),
},
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 inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let diags = check(
&[(FileId(0), &hir)],
&index,
&inference,
&res,
Some(&manifest),
);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `a`")),
"cross-kind handle comparison must Conflicted-escape temp `a`: {diags:?}"
);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `b`")),
"cross-kind handle comparison must Conflicted-escape temp `b`: {diags:?}"
);
assert!(
diags.iter().all(|d| d.code == DiagnosticCode::E066),
"no other diagnostic code expected: {diags:?}"
);
}
#[test]
fn same_kind_handle_comparison_from_body_usage_is_clean_under_strict() {
let src = "EXTERNAL spawn_audio()\n\
=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
=== function get_audio2(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
=== main ===\n~ temp a = get_audio()\n~ temp c = get_audio2()\n{a == c:\n ok\n}\n-> DONE\n";
let (hir, index, res) = build(src);
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,
}],
externals: vec![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 inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let diags = check(
&[(FileId(0), &hir)],
&index,
&inference,
&res,
Some(&manifest),
);
assert!(
diags.is_empty(),
"same-kind comparison must not escape: {diags:?}"
);
}
#[test]
fn temp_headed_dotted_field_read_does_not_corrupt_the_temp_s_own_type() {
let src = "STRUCT Point = #{x: float}\n\
=== function useInt(n: int): int ===\n~ return n\n\
=== main ===\n~ temp t = Point#{x: 1.0}\n~ temp r = useInt(t.x)\n-> DONE\n";
let (hir, index, res) = build(src);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().all(|d| d.code != DiagnosticCode::E066),
"a dotted field read must never Conflicted-escape its head temp: {diags:?}"
);
}
#[test]
fn bare_temp_with_genuinely_conflicting_uses_still_escapes_as_conflicted() {
let src = "=== function useInt(n: int): int ===\n~ return n\n\
=== main ===\n~ temp t = \"hello\"\n~ temp r = useInt(t)\n-> DONE\n";
let (hir, index, res) = build(src);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `t`")),
"a bare temp with genuinely conflicting uses must still Conflicted-escape: {diags:?}"
);
}
#[test]
fn cross_kind_handle_mismatch_is_unreachable_without_manifest_reaching_inference() {
let src = "EXTERNAL spawn_audio()\nEXTERNAL spawn_timer()\n\
=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
=== main ===\n~ temp a = get_audio()\n~ temp b = get_timer()\n{a == b:\n ok\n}\n-> DONE\n";
let (hir, index, res) = build(src);
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![
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(),
},
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 inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(
&[(FileId(0), &hir)],
&index,
&inference,
&res,
Some(&manifest),
);
assert!(
diags.iter().all(|d| d.code == DiagnosticCode::E065),
"with no manifest reaching inference, temps escape as Unknown, not Conflicted: {diags:?}"
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E066),
"a real cross-kind mismatch must never be reachable without T1d-2b's fix: {diags:?}"
);
}
fn audio_and_timer_manifest(play_sound_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(play_sound_param_kind.to_string()),
}],
returns: brink_ir::TypeRef::default(),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
},
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(),
},
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(),
},
],
}
}
#[test]
fn external_binding_rejects_cross_kind_handle_argument_under_strict() {
let src = "EXTERNAL play_sound(inst)\nEXTERNAL spawn_timer()\n\
=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
=== main ===\n~ temp t = get_timer()\n~ play_sound(t)\n-> DONE\n";
let (hir, index, res) = build(src);
let manifest = audio_and_timer_manifest("AudioInstance");
let inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let diags = check(
&[(FileId(0), &hir)],
&index,
&inference,
&res,
Some(&manifest),
);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `t`")),
"a Timer-kinded argument to an AudioInstance-declared binding must \
Conflicted-escape temp `t`: {diags:?}"
);
assert!(
diags.iter().all(|d| d.code == DiagnosticCode::E066),
"no other diagnostic code expected: {diags:?}"
);
}
#[test]
fn external_binding_accepts_same_kind_handle_argument_under_strict() {
let src = "EXTERNAL play_sound(inst)\nEXTERNAL spawn_audio()\n\
=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
=== main ===\n~ temp t = get_audio()\n~ play_sound(t)\n-> DONE\n";
let (hir, index, res) = build(src);
let manifest = audio_and_timer_manifest("AudioInstance");
let inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let diags = check(
&[(FileId(0), &hir)],
&index,
&inference,
&res,
Some(&manifest),
);
assert!(
diags.is_empty(),
"same-kind binding argument must not escape: {diags:?}"
);
}
#[test]
fn external_binding_cross_kind_argument_is_not_checked_under_gradual() {
let src = "EXTERNAL play_sound(inst)\nEXTERNAL spawn_timer()\n\
=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
=== main ===\n~ temp t = get_timer()\n~ play_sound(t)\n-> DONE\n";
let (hir, index, res) = build(src);
let manifest = audio_and_timer_manifest("AudioInstance");
let opts = crate::AnalysisOptions {
host_manifest: Some(manifest),
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Gradual),
..Default::default()
};
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&opts,
false,
None,
&BTreeMap::new(),
);
assert!(
diags.is_empty(),
"gradual mode must never run the strict handle-kind check: {diags:?}"
);
}
#[test]
fn external_binding_with_unregistered_name_is_unchecked() {
let src = "EXTERNAL other_call(inst)\nEXTERNAL spawn_timer()\n\
=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
=== main ===\n~ temp t = get_timer()\n~ other_call(t)\n-> DONE\n";
let (hir, index, res) = build(src);
let manifest = audio_and_timer_manifest("AudioInstance");
let inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(&manifest),
&BTreeMap::new(),
);
let diags = check(
&[(FileId(0), &hir)],
&index,
&inference,
&res,
Some(&manifest),
);
assert!(
diags.is_empty(),
"an unregistered external's call sites stay unchecked: {diags:?}"
);
}
fn get_thing_manifest(ty: &str) -> brink_ir::HostManifest {
brink_ir::HostManifest {
markup: Vec::new(),
types: vec![brink_ir::SemanticTypeDef {
name: "thing_id".to_string(),
base: brink_ir::BaseType::Int,
constraint: None,
values: None,
widget: None,
}],
externals: vec![brink_ir::ManifestExternal {
name: "get_thing".to_string(),
params: vec![brink_ir::ManifestParam {
name: "id".to_string(),
ty: brink_ir::TypeRef(ty.to_string()),
}],
returns: brink_ir::TypeRef("float".to_string()),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
}],
}
}
fn strict_opts(manifest: Option<brink_ir::HostManifest>) -> crate::AnalysisOptions {
crate::AnalysisOptions {
host_manifest: manifest,
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..Default::default()
}
}
const EXT_SRC: &str = "EXTERNAL get_thing(id)\n=== start ===\n{get_thing(1)}\n-> DONE\n";
#[test]
fn manifest_typed_external_param_is_clean_under_strict() {
let (hir, index, res) = build(EXT_SRC);
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&strict_opts(Some(get_thing_manifest("thing_id"))),
false,
None,
&BTreeMap::new(),
);
assert!(
diags.is_empty(),
"a manifest-typed external param must not escape: {diags:?}"
);
}
#[test]
fn unresolvable_external_param_escapes_at_its_own_decl_span() {
let (hir, index, res) = build(EXT_SRC);
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&strict_opts(Some(get_thing_manifest(""))),
false,
None,
&BTreeMap::new(),
);
let escape = diags
.iter()
.find(|d| d.code == DiagnosticCode::E065)
.expect("expected an E065 escape from the unresolvable external param");
assert!(
escape.message.contains("get_thing") && escape.message.contains("parameter `id`"),
"escape must name the offending external param: {escape:?}"
);
assert_eq!(
(
u32::from(escape.range.start()),
u32::from(escape.range.end())
),
(9, 18),
"escape anchors at the external's own declaration span: {escape:?}"
);
}
#[test]
fn unregistered_external_declaration_stays_unchecked_under_strict() {
let (hir, index, res) = build(EXT_SRC);
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&strict_opts(None),
false,
None,
&BTreeMap::new(),
);
assert!(
diags.is_empty(),
"an unregistered external's params must stay unchecked: {diags:?}"
);
}
#[test]
fn external_declaration_escapes_never_fire_under_gradual() {
let (hir, index, res) = build(EXT_SRC);
let mut opts = strict_opts(Some(get_thing_manifest("")));
opts.types = Some(TypePolicy::Gradual);
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&opts,
false,
None,
&BTreeMap::new(),
);
assert!(
diags.is_empty(),
"gradual mode never escape-checks external declarations: {diags:?}"
);
}
#[test]
fn unconstrained_empty_array_temp_escapes_as_unknown() {
let (hir, index, res) = build("=== main ===\n~ temp x = #[]\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E065);
}
#[test]
fn annotated_empty_array_temp_is_exempt() {
let (hir, index, res) = build("=== main ===\n~ temp x: Array<int> = #[]\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "ascription supplies the type: {diags:?}");
}
#[test]
fn unannotated_function_with_no_return_statement_infers_void() {
let (hir, index, res) = build("=== function noop() ===\nHello.\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn unannotated_function_with_unresolvable_return_value_still_escapes() {
let (hir, index, res) = build("=== function noop(x) ===\n~ return x\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 2, "{diags:?}");
assert!(diags.iter().all(|d| d.code == DiagnosticCode::E065));
assert!(
diags.iter().any(|d| d.message.contains("return type")),
"expected a return-type escape among {diags:?}"
);
}
#[test]
fn some_of_an_unevidenced_annotated_param_no_longer_escapes() {
let (hir, index, res) = build("=== function f(x: int) ===\n~ return some(x)\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn first_over_style_option_return_no_longer_escapes() {
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 inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn void_annotated_function_return_is_exempt() {
let (hir, index, res) = build("=== function noop(): void ===\n~ return\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn non_function_knot_return_is_never_checked() {
let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
fn notify_manifest() -> brink_ir::HostManifest {
brink_ir::HostManifest {
markup: Vec::new(),
types: Vec::new(),
externals: vec![brink_ir::ManifestExternal {
name: "notify".to_string(),
params: Vec::new(),
returns: brink_ir::TypeRef("void".to_string()),
kind: brink_ir::ExternalKind::default(),
doc: None,
widgets: Vec::new(),
path: Vec::new(),
}],
}
}
#[test]
fn wrapper_around_void_external_with_no_explicit_return_infers_void_and_is_strict_clean() {
let (hir, index, res) =
build("EXTERNAL notify()\n=== function wrap_notify() ===\n~ notify()\n");
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&strict_opts(Some(notify_manifest())),
false,
None,
&BTreeMap::new(),
);
assert!(
diags.is_empty(),
"a void-external wrapper with no explicit return must infer void, not \
Unknown-escape: {diags:?}"
);
}
#[test]
fn wrapper_around_void_external_with_a_real_return_path_is_unaffected() {
let (hir, index, res) = build(
"EXTERNAL notify()\n=== function wrap_and_report() ===\n~ notify()\n~ return 5\n",
);
let inference = crate::infer_project(
&[(FileId(0), &hir)],
&index,
&res,
Some(¬ify_manifest()),
&BTreeMap::new(),
);
let wrap_id =
annotations::def_id_for(&index, FileId(0), SymbolKind::Knot, "wrap_and_report")
.expect("wrap_and_report must resolve");
assert_eq!(
inference.signatures.get(&wrap_id).map(|s| &s.return_ty),
Some(&Ty::Int),
"a real return path must still infer its own concrete type, unaffected by the \
sibling void-external call"
);
let diags = crate::strict_diagnostics(
&[(FileId(0), &hir)],
&index,
&res,
&strict_opts(Some(notify_manifest())),
false,
None,
&BTreeMap::new(),
);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn genuinely_disjoint_param_uses_escape_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 inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E066);
}
#[test]
fn annotation_never_exempts_a_conflicted_slot() {
let (hir, index, res) = build(
"=== conflict_case(hp: int) ===\n{hp > 5:\n ok\n}\n{hp == \"no\":\n no\n}\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E066);
}
#[test]
fn heterogeneous_array_literal_temp_escapes_as_conflicted() {
let (hir, index, res) = build("=== main ===\n~ temp x = #[1, \"a\"]\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E066);
}
#[test]
fn condition_position_int_truthiness_survives_strict() {
let (hir, index, res) = build("=== main ===\nVAR gold = 5\n{gold:\n rich\n}\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn int_to_float_join_survives_strict_with_no_escape() {
let (hir, index, res) = build("=== spend(gold) ===\n{gold > 1.5:\n ok\n}\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.is_empty(),
"int->float directional join is clean: {diags:?}"
);
}
#[test]
fn check_wires_in_e063_mismatches() {
let (hir, index, res) = build("=== heal(hp: string) ===\n{hp > 1:\n ok\n}\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E063),
"{diags:?}"
);
}
#[test]
fn escape_diagnostics_are_order_independent() {
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 inference_f = crate::infer_project(
&[(FileId(0), &hir_f)],
&index_f,
&res_f,
None,
&BTreeMap::new(),
);
let diags_f = check(&[(FileId(0), &hir_f)], &index_f, &inference_f, &res_f, None);
let (hir_r, index_r, res_r) = build(reversed);
let inference_r = crate::infer_project(
&[(FileId(0), &hir_r)],
&index_r,
&res_r,
None,
&BTreeMap::new(),
);
let diags_r = check(&[(FileId(0), &hir_r)], &index_r, &inference_r, &res_r, None);
assert_eq!(codes(&diags_f), vec![DiagnosticCode::E066]);
assert_eq!(codes(&diags_r), vec![DiagnosticCode::E066]);
}
#[test]
fn clean_strict_project_compiles_with_no_strict_diagnostics() {
let (hir, index, res) = build(
"=== function heal(hp: int): int ===\n~ temp bonus: int = 5\n~ return hp + bonus\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn effective_severity_e063_is_warning_under_gradual() {
assert_eq!(
effective_severity(
DiagnosticCode::E063,
TypePolicy::Gradual,
&LintPolicy::default()
),
brink_ir::Severity::Warning
);
}
#[test]
fn effective_severity_e063_is_error_under_strict() {
assert_eq!(
effective_severity(
DiagnosticCode::E063,
TypePolicy::Strict,
&LintPolicy::default()
),
brink_ir::Severity::Error
);
}
#[test]
fn effective_severity_other_codes_are_policy_independent() {
for policy in [TypePolicy::Gradual, TypePolicy::Strict] {
assert_eq!(
effective_severity(DiagnosticCode::E065, policy, &LintPolicy::default()),
DiagnosticCode::E065.severity()
);
assert_eq!(
effective_severity(DiagnosticCode::E022, policy, &LintPolicy::default()),
DiagnosticCode::E022.severity()
);
}
}
#[test]
fn absent_lints_table_is_byte_identical_to_default_severity() {
for policy in [TypePolicy::Gradual, TypePolicy::Strict] {
for code in [
DiagnosticCode::E014,
DiagnosticCode::E022,
DiagnosticCode::E025,
DiagnosticCode::E037,
] {
assert_eq!(
effective_severity(code, policy, &LintPolicy::default()),
code.severity(),
"code {code:?} under {policy:?} must be unaffected by an empty LintPolicy"
);
}
}
}
#[test]
fn lint_override_deny_relevels_a_warning_code_to_error() {
assert_eq!(DiagnosticCode::E014.severity(), brink_ir::Severity::Warning);
let lints = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Deny)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn lint_override_allow_keeps_a_warning_code_at_warning() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Allow)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Warning
);
}
#[test]
fn lint_override_info_relevels_a_warning_code_to_info() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Info)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Info
);
}
#[test]
fn lint_override_hint_relevels_a_warning_code_to_hint() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Hint)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Hint
);
}
#[test]
fn deny_warnings_does_not_touch_an_info_or_hint_override() {
let lints_info = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Info)]),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints_info),
brink_ir::Severity::Info
);
let lints_hint = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Hint)]),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints_hint),
brink_ir::Severity::Hint
);
}
#[test]
fn hard_error_code_is_never_downgraded_to_info_or_hint() {
assert_eq!(DiagnosticCode::E025.severity(), brink_ir::Severity::Error);
let lints = LintPolicy {
overrides: BTreeMap::from([("E025".to_owned(), LintLevel::Hint)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E025, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn deny_warnings_promotes_unconfigured_warning_codes_to_error() {
let lints = LintPolicy {
overrides: BTreeMap::new(),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
assert_eq!(
effective_severity(DiagnosticCode::E022, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn deny_warnings_does_not_touch_an_allow_override() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Allow)]),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Warning
);
}
#[test]
fn hard_error_code_is_never_downgraded_by_lints_or_deny_warnings() {
assert_eq!(DiagnosticCode::E025.severity(), brink_ir::Severity::Error);
let lints = LintPolicy {
overrides: BTreeMap::from([("E025".to_owned(), LintLevel::Allow)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E025, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn deny_override_wins_even_without_deny_warnings() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Deny)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn explicit_warn_override_is_still_escalated_by_deny_warnings() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Warn)]),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn info_base_code_defaults_to_info_with_no_lints() {
assert_eq!(
DiagnosticCode::E157.severity(),
brink_ir::Severity::Info,
"E157 is the off/info-by-default lint issue #1674 rules for"
);
assert_eq!(
effective_severity(
DiagnosticCode::E157,
TypePolicy::Gradual,
&LintPolicy::default()
),
brink_ir::Severity::Info
);
}
#[test]
fn info_base_code_is_immune_to_deny_warnings_when_unconfigured() {
let lints = LintPolicy {
overrides: BTreeMap::new(),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
brink_ir::Severity::Info
);
}
#[test]
fn info_base_code_can_be_raised_to_warn_via_lints() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Warn)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
brink_ir::Severity::Warning
);
}
#[test]
fn info_base_code_raised_to_warn_is_then_escalated_by_deny_warnings() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Warn)]),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn info_base_code_can_be_denied_straight_to_error() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Deny)]),
deny_warnings: false,
};
assert_eq!(
effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
brink_ir::Severity::Error
);
}
#[test]
fn info_base_code_can_be_downleveled_to_hint() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Hint)]),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
brink_ir::Severity::Hint,
"an explicit Hint downgrade must stay immune to deny-warnings too"
);
}
#[test]
fn info_base_code_allow_override_is_a_no_op() {
let lints = LintPolicy {
overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Allow)]),
deny_warnings: true,
};
assert_eq!(
effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
brink_ir::Severity::Info,
"Allow keeps the code at its own base — Info here, not Warning"
);
}
#[test]
fn void_assigned_to_temp_is_e067() {
let (hir, index, res) = build(
"=== function noop(): void ===\n~ return\n\
=== main ===\n~ temp x = noop()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E067),
"{diags:?}"
);
}
#[test]
fn void_assigned_to_var_is_e067() {
let (hir, index, res) = build(
"VAR gold = 0\n=== function noop(): void ===\n~ return\n\
=== main ===\n~ gold = noop()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E067),
"{diags:?}"
);
}
#[test]
fn void_call_in_statement_position_is_clean() {
let (hir, index, res) = build(
"=== function noop(): void ===\n~ return\n\
=== main ===\n~ noop()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E067),
"statement-position void call must never be flagged: {diags:?}"
);
}
#[test]
fn non_void_call_assigned_is_clean_of_e067() {
let (hir, index, res) = build(
"=== function give(): int ===\n~ return 5\n\
=== main ===\n~ temp x: int = give()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E067),
"{diags:?}"
);
}
#[test]
fn inferred_void_assigned_to_temp_is_e067() {
let (hir, index, res) = build(
"=== function noop() ===\nHello.\n\
=== main ===\n~ temp x = noop()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E067),
"{diags:?}"
);
}
#[test]
fn inferred_void_assigned_to_var_is_e067() {
let (hir, index, res) = build(
"VAR gold = 0\n=== function noop() ===\nHello.\n\
=== main ===\n~ gold = noop()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E067),
"{diags:?}"
);
}
#[test]
fn inferred_void_call_in_statement_position_is_clean() {
let (hir, index, res) = build(
"=== function noop() ===\nHello.\n\
=== main ===\n~ noop()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E067),
"statement-position inferred-void call must never be flagged: {diags:?}"
);
}
#[test]
fn function_with_real_return_path_is_not_inferred_void_and_stays_clean_of_e067() {
let (hir, index, res) = build(
"=== function give() ===\n~ return 5\n\
=== main ===\n~ temp x = give()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E067),
"{diags:?}"
);
}
#[test]
fn stitch_return_value_reached_by_fallthrough_is_not_inferred_void_and_stays_clean_of_e067() {
let (hir, index, res) = build(
"=== function f() ===\n= compute\n~ return 5\n\
=== main ===\n~ temp x: int = f()\nx={x}\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E067),
"{diags:?}"
);
}
#[test]
fn declared_non_void_return_falling_through_is_e150_not_e067() {
let (hir, index, res) = build(
"=== function broken(): int ===\nHello.\n\
=== main ===\n~ temp x = broken()\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E150),
"{diags:?}"
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E067),
"a declared-return-type fall-through must be E150, not also E067: {diags:?}"
);
}
#[test]
fn stitch_return_value_reached_by_fallthrough_is_not_e150() {
let (hir, index, res) = build("=== function f(): int ===\n= compute\n~ return 5\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E150),
"{diags:?}"
);
}
#[test]
fn unannotated_function_return_value_reached_by_fallthrough_stitch_is_not_e065() {
let (hir, index, res) = build("=== function f() ===\n= compute\n~ return 5\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn void_assignment_never_checked_under_gradual() {
let parsed = brink_syntax::parse(
"=== function noop(): void ===\n~ return\n\
=== main ===\n~ temp x = noop()\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Gradual),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E067),
"gradual must never surface E067: {:?}",
result.diagnostics
);
}
const HEAL: &str = "=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
VAR player_hp = 10\n";
#[test]
fn well_typed_call_through_a_fn_value_is_clean_under_strict() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp heal_player = #fn(heal, player_hp)\n\
~ temp result: int = heal_player(5)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn differing_effect_rows_are_not_an_argument_mismatch() {
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\
=== function apply(cb, x: int): int ===\n\
~ cb = #fn(bump)\n~ return cb(x)\n\
=== main ===\n~ temp a = #fn(apply)\n\
~ temp r: int = a(#fn(twice), 1)\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a differing effect row is not a type mismatch: {diags:?}"
);
}
#[test]
fn differing_effect_rows_are_not_a_bind_argument_mismatch() {
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\
=== function apply(cb, x: int): int ===\n\
~ cb = #fn(bump)\n~ return cb(x)\n\
=== main ===\n~ temp a = #fn(apply)\n\
~ temp p = bind(a, #fn(twice))\n~ temp r: int = p(1)\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a differing effect row is not a bind mismatch: {diags:?}"
);
}
#[test]
fn int_to_float_coercion_applies_to_fn_value_call_arguments() {
let (hir, index, res) = build(
"=== function scale(factor: float): float ===\n~ return factor * 2.0\n\
=== main ===\n~ temp f = #fn(scale)\n~ temp r: float = f(2)\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn fn_value_call_arity_mismatch_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp r: int = f(5, 6)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
assert!(diags[0].message.contains("2 argument"), "{diags:?}");
}
#[test]
fn fn_value_call_argument_type_mismatch_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp r: int = f(\"lots\")\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{diags:?}"
);
}
#[test]
fn float_to_int_narrowing_at_a_fn_value_call_is_an_error() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp r: int = f(1.5)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{diags:?}"
);
}
#[test]
fn unknown_callee_in_call_position_is_an_escape_error() {
let (hir, index, res) = build("=== main(g) ===\n~ temp r = g(1)\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E065
&& d.message.contains("called as a function value")),
"{diags:?}"
);
}
#[test]
fn conflicted_callee_in_call_position_is_a_conflicted_escape_error() {
let (hir, index, res) =
build("=== main ===\n~ temp f = 1\n{f == \"x\":\n no\n}\n~ temp r = f(5)\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E066
&& d.message.contains("called as a function value")),
"{diags:?}"
);
}
#[test]
fn calling_a_known_non_fn_value_is_a_typed_mismatch_error() {
let (hir, index, res) = build("=== main ===\n~ temp n = 5\n~ temp r = n(1)\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("not callable")),
"{diags:?}"
);
}
#[test]
fn annotated_fn_typed_param_is_callable_under_strict() {
let (hir, index, res) =
build("=== function apply(cb: fn(int): int, x: int): int ===\n~ return cb(x)\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn annotated_fn_typed_param_call_still_checks_argument_types() {
let (hir, index, res) =
build("=== function apply(cb: fn(int): int): int ===\n~ return cb(\"nope\")\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{diags:?}"
);
}
#[test]
fn fn_value_call_checks_never_surface_under_gradual() {
let parsed = brink_syntax::parse("=== main ===\n~ temp n = 5\n~ temp r = n(1)\n-> DONE\n");
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Gradual),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
!result.diagnostics.iter().any(|d| matches!(
d.code,
DiagnosticCode::E063 | DiagnosticCode::E065 | DiagnosticCode::E066
)),
"gradual must never surface value-call checks: {:?}",
result.diagnostics
);
}
#[test]
fn strict_fn_value_mismatch_fires_through_the_real_pipeline() {
let parsed = brink_syntax::parse(
"=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
VAR player_hp = 10\n\
=== main ===\n~ temp f = #fn(heal, player_hp)\n~ temp r: int = f(\"x\")\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{:?}",
result.diagnostics
);
}
const HEAL_GLOBAL: &str = "=== function heal(ref hp: int, amount: int): int ===\n\
~ hp = hp + amount\n~ return hp\n\
VAR player_hp = 10\n\
VAR heal_player = #fn(heal, player_hp)\n";
#[test]
fn well_typed_call_through_a_global_fn_value_is_clean_under_strict() {
let (hir, index, res) = build(&format!(
"{HEAL_GLOBAL}=== main ===\n~ temp result: int = heal_player(5)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn arity_mismatch_through_a_global_fn_value_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(5, 6)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E063);
assert!(diags[0].message.contains("2 argument"), "{diags:?}");
}
#[test]
fn argument_type_mismatch_through_a_global_fn_value_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(\"lots\")\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{diags:?}"
);
}
#[test]
fn explicitly_annotated_global_fn_value_wins_over_an_unannotated_target() {
let (hir, index, res) = build(
"=== function identity(x) ===\n~ return x\n\
VAR f: fn(int): int = #fn(identity)\n\
=== main ===\n~ temp r: int = f(\"x\")\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{diags:?}"
);
}
#[test]
fn cross_signature_reassignment_through_globals_is_a_conflicted_escape() {
let (hir, index, res) = build(
"=== function heal(ref hp: int, amount: int): int ===\n\
~ hp = hp + amount\n~ return hp\n\
=== function greet(name: string): string ===\n~ return name\n\
VAR player_hp = 10\n\
VAR heal_fn = #fn(heal, player_hp)\n\
VAR greet_fn = #fn(greet)\n\
=== main ===\n~ temp f = heal_fn\n~ f = greet_fn\n~ temp r = f(1)\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `f`")),
"{diags:?}"
);
}
#[test]
fn global_fn_value_call_checks_never_surface_under_gradual() {
let parsed = brink_syntax::parse(&format!(
"{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(5, 6)\n-> DONE\n"
));
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Gradual),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
!result.diagnostics.iter().any(|d| matches!(
d.code,
DiagnosticCode::E063 | DiagnosticCode::E065 | DiagnosticCode::E066
)),
"gradual must never surface value-call checks: {:?}",
result.diagnostics
);
}
#[test]
fn strict_global_fn_value_mismatch_fires_through_the_real_pipeline() {
let parsed = brink_syntax::parse(&format!(
"{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(\"x\")\n-> DONE\n"
));
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{:?}",
result.diagnostics
);
}
#[test]
fn list_literal_global_var_temp_is_clean_under_strict() {
let (hir, index, res) = build(
"LIST Weathers = sunny, rainy, snowy\n\
VAR weather = (sunny)\n\
=== main ===\n~ temp w = weather\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.is_empty(),
"list-literal VAR's nominal type must flow through, not escape as Unknown: {diags:?}"
);
}
#[test]
fn list_literal_global_var_is_clean_through_the_real_pipeline_under_strict() {
let parsed = brink_syntax::parse(
"LIST Weathers = sunny, rainy, snowy\n\
VAR weather = (sunny)\n\
=== main ===\n~ temp w = weather\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E065),
"list-literal VAR must not escape as Unknown under strict: {:?}",
result.diagnostics
);
}
#[test]
fn well_typed_explicit_call_through_a_fn_value_is_clean_under_strict() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp result: int = call(f, 5)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn explicit_call_arity_mismatch_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp r: int = call(f, 5, 6)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("2 argument")),
"{diags:?}"
);
}
#[test]
fn explicit_call_argument_type_mismatch_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp r: int = call(f, \"lots\")\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{diags:?}"
);
}
#[test]
fn unknown_callee_in_explicit_call_is_an_escape_error() {
let (hir, index, res) = build("=== main(g) ===\n~ temp r = call(g, 1)\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E065
&& d.message.contains("called as a function value")),
"{diags:?}"
);
}
#[test]
fn annotated_fn_typed_param_is_callable_through_explicit_call_under_strict() {
let (hir, index, res) =
build("=== function apply(cb: fn(int): int, x: int): int ===\n~ return call(cb, x)\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn well_typed_bind_consumes_the_head_of_the_param_row() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp g = bind(f, 5)\n~ temp r: int = call(g)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn over_binding_more_than_the_remaining_param_row_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp g = bind(f, 5, 6)\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E063
&& d.message.contains("supplies 2")
&& d.message.contains("1 parameter")),
"{diags:?}"
);
}
#[test]
fn bind_argument_type_mismatch_is_a_typed_mismatch_error() {
let (hir, index, res) = build(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp g = bind(f, \"lots\")\n-> DONE\n"
));
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{diags:?}"
);
}
#[test]
fn unknown_callee_in_bind_is_an_escape_error() {
let (hir, index, res) = build("=== main(g) ===\n~ temp b = bind(g, 1)\n-> DONE\n");
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E065
&& d.message.contains("called as a function value")),
"{diags:?}"
);
}
#[test]
fn conflicted_callee_in_bind_is_a_conflicted_escape_error() {
let (hir, index, res) = build(
"=== main ===\n~ temp f = 1\n{f == \"x\":\n no\n}\n~ temp b = bind(f, 1)\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E066
&& d.message.contains("called as a function value")),
"{diags:?}"
);
}
#[test]
fn explicit_call_and_bind_checks_never_surface_under_gradual() {
let parsed = brink_syntax::parse(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp r: int = call(f, 5, 6)\n~ temp g = bind(f, \"lots\")\n-> DONE\n"
));
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Gradual),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
!result.diagnostics.iter().any(|d| matches!(
d.code,
DiagnosticCode::E063 | DiagnosticCode::E065 | DiagnosticCode::E066
)),
"gradual must never surface call()/bind() value-call checks: {:?}",
result.diagnostics
);
}
#[test]
fn strict_explicit_call_mismatch_fires_through_the_real_pipeline() {
let parsed = brink_syntax::parse(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp r: int = call(f, \"x\")\n-> DONE\n"
));
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"{:?}",
result.diagnostics
);
}
#[test]
fn strict_bind_over_bind_fires_through_the_real_pipeline() {
let parsed = brink_syntax::parse(&format!(
"{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
~ temp g = bind(f, 5, 6)\n-> DONE\n"
));
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("supplies 2")),
"{:?}",
result.diagnostics
);
}
#[test]
fn strict_check_wires_in_struct_construction_errors_through_the_real_pipeline() {
let parsed = brink_syntax::parse(
"STRUCT Point = #{x: float, y: float}\n\
=== main ===\n~ p = Point#{x: 1.0}\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E069),
"missing field must surface through the real strict pipeline: {:?}",
result.diagnostics
);
}
#[test]
fn strict_check_wires_in_ref_projection_segment_errors_through_the_real_pipeline() {
let parsed = brink_syntax::parse(
"STRUCT NPC = #{hp: int}\n\
VAR npc: NPC = NPC#{hp: 10}\n\
=== function heal(ref hp, k) ===\n~ hp = hp + k\n\n\
=== main ===\n~ heal(ref npc.mana, 5)\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E098),
"unknown field segment must surface through the real strict pipeline: {:?}",
result.diagnostics
);
}
#[test]
fn ref_projection_segment_errors_never_surface_under_gradual() {
let parsed = brink_syntax::parse(
"STRUCT NPC = #{hp: int}\n\
VAR npc: NPC = NPC#{hp: 10}\n\
=== function heal(ref hp, k) ===\n~ hp = hp + k\n\n\
=== main ===\n~ heal(ref npc.mana, 5)\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Gradual),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E098),
"gradual must never surface ref-projection segment errors: {:?}",
result.diagnostics
);
}
#[test]
fn struct_construction_errors_never_surface_under_gradual() {
let parsed = brink_syntax::parse(
"STRUCT Point = #{x: float, y: float}\n\
=== main ===\n~ p = Point#{x: 1.0}\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Gradual),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E069
|| d.code == DiagnosticCode::E070
|| d.code == DiagnosticCode::E071),
"gradual must never surface construction errors: {:?}",
result.diagnostics
);
}
#[test]
fn direct_call_ref_param_widening_is_rejected_under_strict() {
let parsed = brink_syntax::parse(
"=== function scale(ref x: float, k: int): float ===\n\
~ x = x * k\n~ return x\n\
VAR i = 3\n\
=== main ===\n~ scale(i, 2)\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a ref argument's int cell must not widen into a declared-float ref \
parameter: {:?}",
result.diagnostics
);
}
#[test]
fn direct_call_by_value_param_is_unaffected_by_ref_invariance() {
let (hir, index, res) = build(
"=== function scale(ref x: float, k: int): float ===\n\
~ x = x * k\n~ return x\n\
VAR f: float = 1.0\n\
=== main ===\n~ scale(f, 2)\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument")),
"a well-typed ref argument plus an exactly-typed by-value argument must stay \
clean: {diags:?}"
);
}
#[test]
fn ufcs_ref_receiver_widening_is_rejected_under_strict() {
let diags = native_strict_diags(
"var i: int = 3;\n\
fn scale(ref x: float): float {\n x = x * 2.0;\n return x;\n}\n\
fn main() {\n let r = i.scale();\n}\n",
);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a UFCS auto-ref receiver's int cell must not widen into a declared-float \
ref parameter either: {diags:?}"
);
}
#[test]
fn direct_call_ref_param_widening_through_a_local_is_rejected_under_strict() {
let diags = native_strict_diags(
"fn scale(ref x: float): float {\n x = x * 2.0;\n return x;\n}\n\
fn main() {\n let i: int = 3;\n scale(i);\n}\n",
);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a bare local int cell must not widen into a declared-float ref parameter \
either, the same as the global-VAR case above: {diags:?}"
);
}
#[test]
fn direct_call_ref_param_widening_through_an_ink_temp_is_rejected_under_strict() {
let parsed = brink_syntax::parse(
"=== function scale(ref x: float, k: int): float ===\n\
~ x = x * k\n~ return x\n\
=== main ===\n~ temp i: int = 3\n~ scale(i, 2)\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a `~ temp` int cell must not widen into a declared-float ref parameter \
either: {:?}",
result.diagnostics
);
}
#[test]
fn fn_literal_ref_param_widening_is_rejected_under_strict() {
let parsed = brink_syntax::parse(
"=== function scale(ref x: float, k: int): float ===\n\
~ x = x * k\n~ return x\n\
VAR i = 3\n\
=== main ===\n~ temp f = #fn(scale, i)\n~ temp r: float = f(2)\n-> DONE\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a #fn-bound int cell must not widen into a declared-float ref parameter \
either: {:?}",
result.diagnostics
);
}
#[test]
fn fn_literal_by_value_param_is_unaffected_by_ref_invariance() {
let (hir, index, res) = build(
"=== function scale(ref x: float, k: int): float ===\n\
~ x = x * k\n~ return x\n\
VAR f: float = 1.0\n\
=== main ===\n~ temp fv = #fn(scale, f, 2)\n-> DONE\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument")),
"a well-typed ref argument plus an exactly-typed by-value argument bound at \
creation must stay clean: {diags:?}"
);
}
#[test]
fn divert_target_ref_param_widening_is_rejected_under_strict() {
let parsed = brink_syntax::parse(
"=== scale(ref x: float, k: int) ===\n\
~ x = x * k\n-> DONE\n\
VAR i = 3\n\
=== main ===\n-> scale(i, 2)\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a divert-with-args int cell must not widen into a declared-float ref \
parameter either: {:?}",
result.diagnostics
);
}
#[test]
fn divert_target_ref_param_widening_through_an_ink_temp_is_rejected_under_strict() {
let parsed = brink_syntax::parse(
"=== scale(ref x: float, k: int) ===\n\
~ x = x * k\n-> DONE\n\
=== main ===\n~ temp i: int = 3\n-> scale(i, 2)\n",
);
let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
let opts = crate::AnalysisOptions {
dialect: crate::Dialect::Brink,
types: Some(TypePolicy::Strict),
..crate::AnalysisOptions::default()
};
let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a `~ temp` int cell must not widen into a declared-float ref parameter \
at a divert target either: {:?}",
result.diagnostics
);
}
#[test]
fn divert_target_by_value_param_is_unaffected_by_ref_invariance() {
let (hir, index, res) = build(
"=== scale(ref x: float, k: float) ===\n\
~ x = x * k\n-> DONE\n\
VAR f: float = 1.0\n\
=== main ===\n-> scale(f, 2)\n",
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
!diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument")),
"a well-typed ref argument plus a covariantly-widened by-value argument at a \
divert target must stay clean: {diags:?}"
);
}
#[test]
fn ink_root_content_divert_target_ref_widening_is_checked() {
let src = "VAR i = 3\n\
-> scale(i, 2)\n\
=== scale(ref x: float, k: int) ===\n\
~ x = x * k\n-> DONE\n";
let (hir, index, res) = build(src);
assert!(
!hir.root_content.stmts.is_empty(),
"fixture precondition: the ink frontend must populate root_content"
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a divert-with-args ref-argument mismatch written at file root must be \
reported, not silently dropped: {diags:?}"
);
}
#[test]
fn divert_target_ref_param_widening_is_rejected_under_strict_on_native() {
let diags = native_strict_diags(
"fn scale(ref x: float, k: int) {\n x = x * k;\n}\n\
var i: int = 3;\n\
flow main() {\n -> scale(i, 2)\n}\n",
);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a divert-with-args int cell must not widen into a declared-float ref \
parameter on native either: {diags:?}"
);
}
#[test]
fn ink_root_content_direct_call_ref_widening_is_checked() {
let src = "VAR i = 3\n\
~ scale(i, 2)\nHello.\n-> END\n\
=== function scale(ref x: float, k: int): float ===\n\
~ x = x * k\n~ return x\n";
let (hir, index, res) = build(src);
assert!(
!hir.root_content.stmts.is_empty(),
"fixture precondition: the ink frontend must populate root_content"
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a direct-call ref-argument mismatch written at file root must be \
reported, not silently dropped: {diags:?}"
);
}
#[test]
fn ink_root_content_fn_literal_ref_widening_is_checked() {
let src = "VAR i = 3\n\
~ temp f = #fn(scale, i)\nHello.\n-> END\n\
=== function scale(ref x: float, k: int): float ===\n\
~ x = x * k\n~ return x\n";
let (hir, index, res) = build(src);
assert!(
!hir.root_content.stmts.is_empty(),
"fixture precondition: the ink frontend must populate root_content"
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags
.iter()
.any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
"a #fn-bound ref-argument mismatch written at file root must be \
reported, not silently dropped: {diags:?}"
);
}
#[test]
fn ink_root_content_declared_temp_init_is_checked() {
let src = "~ temp n: int = \"hello\"\nHello.\n-> END\n";
let (hir, index, res) = build(src);
assert!(
!hir.root_content.stmts.is_empty(),
"fixture precondition: the ink frontend must populate root_content"
);
let inference =
crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E063),
"a declared-type violation at file root must be reported: {diags:?}"
);
}
#[test]
fn native_root_content_holds_no_type_bearing_statements() {
let (hir, _index, _res) = build_native("flow main() {\n Hello.\n}\n");
assert_eq!(hir.root_content.stmts.len(), 1);
assert!(
matches!(hir.root_content.stmts[0], brink_ir::Stmt::Divert(_)),
"native root_content must be the synthesized entry divert, got {:?}",
hir.root_content.stmts[0]
);
}
}