#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use brink_analyzer::{AnalysisOptions, Dialect};
use brink_db::ProjectDb;
use brink_ir::DiagnosticCode;
fn brink_opts() -> AnalysisOptions {
AnalysisOptions {
dialect: Dialect::Brink,
..AnalysisOptions::default()
}
}
fn analyze(source: &str) -> Vec<brink_ir::Diagnostic> {
let mut db = ProjectDb::new();
db.set_analysis_options(brink_opts());
db.set_file("main.ink", source.to_owned());
db.analysis().diagnostics.clone()
}
fn codes(diags: &[brink_ir::Diagnostic]) -> Vec<DiagnosticCode> {
diags.iter().map(|d| d.code).collect()
}
#[test]
fn exceedance_on_an_extra_read() {
let diags = analyze(
"VAR gold = 0\n\
=== function spend() ===\n@[effects(pure)]\n~ return gold\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("reads gold"), "{diags:?}");
}
#[test]
fn exceedance_on_an_extra_write() {
let diags = analyze(
"VAR gold = 0\n\
=== function spend(cost) ===\n@[effects(reads(gold))]\n~ gold = gold - cost\n~ return gold\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("writes gold"), "{diags:?}");
}
#[test]
fn exceedance_on_an_extra_call() {
let diags = analyze(
"VAR gold = 0\nEXTERNAL play_sfx(x)\n\
=== function spend(cost: int) ===\n@[effects(reads(gold))]\n\
~ temp before = gold\n~ play_sfx(cost)\n~ return before\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("calls play_sfx"), "{diags:?}");
}
#[test]
fn exceedance_via_ref_param_indirect_write() {
let diags = analyze(
"VAR val = 5\n\
=== knot ===\n@[effects(reads(val))]\n~ inc(val)\n{val}\n->->\n\
=== function inc(ref x) ===\n~ x = x + 1\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("writes val"), "{diags:?}");
}
#[test]
fn exceedance_via_a_fn_creation_site_ref_binding() {
let diags = analyze(
"VAR player_hp = 10\n\
=== knot ===\n@[effects(reads(player_hp))]\n\
~ temp f: fn(int): int = #fn(heal, player_hp)\n\
~ temp x: int = f(5)\n{player_hp}\n->->\n\
=== function heal(ref hp: int, amount: int): int ===\n\
~ hp = hp + amount\n~ return hp\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("writes player_hp"), "{diags:?}");
}
#[test]
fn pure_sugar_is_satisfied_by_a_genuinely_pure_body() {
let diags = analyze("=== function double(x) ===\n@[effects(pure)]\n~ return x * 2\n");
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn assertion_satisfied_is_silent_even_when_strictly_wider_than_inferred() {
let diags = analyze(
"VAR gold = 0\nVAR hp = 10\n\
=== function spend(cost) ===\n@[effects(reads(gold), writes(gold), writes(hp))]\n\
~ gold = gold - cost\n~ return gold\n",
);
assert!(
diags.is_empty(),
"over-declaring must stay silent: {diags:?}"
);
}
#[test]
fn unknown_cell_name_in_assertion_is_e102() {
let diags = analyze(
"VAR gold = 0\n=== function spend() ===\n@[effects(reads(nonexistent))]\n~ return gold\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E102], "{diags:?}");
}
#[test]
fn unknown_external_name_in_assertion_is_e102() {
let diags = analyze(
"VAR gold = 0\n=== function spend() ===\n@[effects(calls(nonexistent))]\n~ return gold\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E102], "{diags:?}");
}
#[test]
fn strict_ink_never_runs_the_exceedance_check() {
let mut db = ProjectDb::new();
db.set_file(
"main.ink",
"VAR gold = 0\n=== function spend() ===\n@[effects(pure)]\n~ return gold\n".to_owned(),
);
let diags = db.analysis().diagnostics.clone();
assert_eq!(codes(&diags), vec![DiagnosticCode::E051], "{diags:?}");
}
#[test]
fn unannotated_project_produces_no_effects_diagnostics() {
let diags = analyze(
"VAR gold = 0\nEXTERNAL play_sfx(x)\n\
=== function spend(cost) ===\n~ gold = gold - cost\n~ play_sfx(cost)\n~ return gold\n",
);
assert!(diags.is_empty(), "{diags:?}");
}
fn analyze_files(files: &[(&str, &str)]) -> Vec<brink_ir::Diagnostic> {
let mut db = ProjectDb::new();
db.set_analysis_options(brink_opts());
for &(path, source) in files {
db.set_file(path, source.to_owned());
}
db.analysis().diagnostics.clone()
}
const QUEST_A: &str = "#@module(quest_a)\n#@public\nVAR gold = 100\n";
const QUEST_B: &str = "#@module(quest_b)\n#@public\nVAR gold = 200\n";
#[test]
fn effects_row_attributes_the_assertion_to_the_actually_imported_modules_cell() {
let diags = analyze_files(&[
("quest_a.ink", QUEST_A),
("quest_b.ink", QUEST_B),
(
"main.ink",
"IMPORT { gold } FROM quest_a\n\
=== function spend() ===\n@[effects(reads(gold))]\n~ return gold\n",
),
]);
assert!(
diags
.iter()
.all(|d| d.code != DiagnosticCode::E103 && d.code != DiagnosticCode::E102),
"importing quest_a's `gold` must satisfy `reads: gold` with no spurious \
exceedance or unknown-name diagnostic: {diags:?}"
);
}
#[test]
fn effects_row_attributes_the_assertion_to_the_other_importers_cell() {
let diags = analyze_files(&[
("quest_a.ink", QUEST_A),
("quest_b.ink", QUEST_B),
(
"main.ink",
"IMPORT { gold } FROM quest_b\n\
=== function spend() ===\n@[effects(reads(gold))]\n~ return gold\n",
),
]);
assert!(
diags
.iter()
.all(|d| d.code != DiagnosticCode::E103 && d.code != DiagnosticCode::E102),
"importing quest_b's `gold` must satisfy `reads: gold` with no spurious \
exceedance or unknown-name diagnostic: {diags:?}"
);
}
#[test]
fn unimported_cross_module_reference_attributes_consistently_with_resolution() {
let diags = analyze_files(&[
("quest_a.ink", QUEST_A),
("quest_b.ink", QUEST_B),
(
"main.ink",
"=== function spend() ===\n@[effects(reads(gold))]\n~ return gold\n",
),
]);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E025),
"the un-imported bare cross-module reference must still be gated: {diags:?}"
);
assert!(
diags
.iter()
.all(|d| d.code != DiagnosticCode::E103 && d.code != DiagnosticCode::E102),
"the assertion and the body reference must resolve to the same cell \
(both via the same import-blind fallback), so no exceedance or \
unknown-name diagnostic can fire: {diags:?}"
);
}
#[test]
fn silent_exceedance_on_a_content_line_is_e108() {
let diags = analyze("-> talker\n\n=== talker ===\n@[effects(silent)]\nHello there.\n-> END\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E108], "{diags:?}");
}
#[test]
fn silent_exceedance_through_a_transitive_callee_is_e108() {
let diags = analyze(
"=== function outer() ===\n@[effects(silent)]\n~ return speak()\n\n\
=== function speak() ===\nDialogue!\n~ return 1\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E108], "{diags:?}");
}
#[test]
fn tag_only_line_does_not_exceed_silent() {
let diags = analyze("-> marker\n\n=== marker ===\n@[effects(silent)]\n# checkpoint\n-> END\n");
assert_eq!(codes(&diags), Vec::<DiagnosticCode>::new(), "{diags:?}");
}
#[test]
fn total_exceedance_on_an_indexing_construct_is_e109() {
let diags = analyze(
"=== function pick_first(a: Array<int>): int ===\n@[effects(total)]\n~ return a[0]\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E109], "{diags:?}");
}
#[test]
fn total_exceedance_on_division_is_e109() {
let diags =
analyze("=== function ratio(a: int, b: int): int ===\n@[effects(total)]\n~ return a / b\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E109], "{diags:?}");
}
#[test]
fn total_exceedance_on_a_faulting_stdlib_verb_is_e109() {
let diags = analyze(
"=== function lowest(a: Array<int>) ===\n@[effects(total)]\n~ return min(a) or 0\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E109], "{diags:?}");
}
#[test]
fn satisfied_silent_and_total_are_silent() {
let diags = analyze(
"=== function add(a: int, b: int): int ===\n@[effects(pure, silent, total)]\n~ return a + b\n",
);
assert_eq!(codes(&diags), Vec::<DiagnosticCode>::new(), "{diags:?}");
}
#[test]
fn emitting_def_without_silent_assertion_is_legal() {
let diags = analyze("-> talker\n\n=== talker ===\n@[effects(total)]\nHello.\n-> END\n");
assert_eq!(codes(&diags), Vec::<DiagnosticCode>::new(), "{diags:?}");
}
#[test]
fn opaque_row_exceeds_both_silent_and_total() {
let diags = analyze(
"=== function apply(cb: fn(): int) ===\n@[effects(silent, total)]\n~ return cb()\n",
);
assert_eq!(
codes(&diags),
vec![DiagnosticCode::E108, DiagnosticCode::E109],
"{diags:?}"
);
}
#[test]
fn two_known_fn_origins_collapse_to_the_joined_row_instead_of_the_opaque_floor() {
let diags = analyze(
"VAR total = 0\nVAR extra = 0\n\
=== function bar(): int ===\n~ total = total + 1\n~ return total\n\
=== function baz(): int ===\n~ extra = extra + 100\n~ return extra\n\
=== function user(cond: int): int ===\n\
@[effects(reads(total), reads(extra), writes(total), writes(extra))]\n\
~ temp f = #fn(bar)\n{cond:\n ~ f = #fn(baz)\n}\n~ return f()\n",
);
assert_eq!(codes(&diags), Vec::<DiagnosticCode>::new(), "{diags:?}");
}
#[test]
fn the_joined_row_still_reports_a_bound_that_names_only_one_origin() {
let diags = analyze(
"VAR total = 0\nVAR extra = 0\n\
=== function bar(): int ===\n~ total = total + 1\n~ return total\n\
=== function baz(): int ===\n~ extra = extra + 100\n~ return extra\n\
=== function user(cond: int): int ===\n\
@[effects(reads(total), writes(total))]\n\
~ temp f = #fn(bar)\n{cond:\n ~ f = #fn(baz)\n}\n~ return f()\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("extra"), "{diags:?}");
}
#[test]
fn one_untraced_write_keeps_the_opaque_floor_end_to_end() {
let diags = analyze(
"VAR total = 0\n\
=== function bar(): int ===\n~ total = total + 1\n~ return total\n\
=== function user(cond: int, cb: fn(): int): int ===\n\
@[effects(silent, total)]\n\
~ temp f = #fn(bar)\n{cond:\n ~ f = cb\n}\n~ return f()\n",
);
assert_eq!(
codes(&diags),
vec![DiagnosticCode::E108, DiagnosticCode::E109],
"{diags:?}"
);
}
#[test]
fn deprecated_hash_spelling_reaches_per_file_diagnostics_as_e110_warning() {
let mut db = ProjectDb::new();
db.set_analysis_options(brink_opts());
let id = db.set_file(
"main.ink",
"=== function add(a: int, b: int): int ===\n#@effects(pure)\n~ return a + b\n".to_owned(),
);
let diags = db.diagnostics(id).expect("file known").to_vec();
assert_eq!(codes(&diags), vec![DiagnosticCode::E110], "{diags:?}");
assert_eq!(
diags[0].code.severity(),
brink_ir::Severity::Warning,
"the alias is a warning, not an error"
);
}
#[test]
fn pure_assertion_exceeded_by_a_draw() {
let diags = analyze("=== function coin() ===\n@[effects(pure)]\n~ return chance(0.5)\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(
diags[0].message.contains("rng"),
"the exceedance names the rng cell: {diags:?}"
);
}
#[test]
fn writes_rng_clause_covers_a_draw_bearing_def() {
let diags = analyze("=== function coin() ===\n@[effects(writes(rng))]\n~ return chance(0.5)\n");
assert_eq!(codes(&diags), Vec::<DiagnosticCode>::new(), "{diags:?}");
}
#[test]
fn pure_assertion_exceeded_by_the_frozen_ink_spelling_too() {
let diags = analyze("=== function d6(): int ===\n@[effects(pure)]\n~ return RANDOM(1, 6)\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("rng"), "{diags:?}");
}
#[test]
fn user_var_named_rng_shadows_the_cell_name_in_clauses() {
let diags = analyze(
"VAR rng = 0\n\
=== function bump() ===\n@[effects(reads(rng), writes(rng))]\n~ rng = rng + 1\n~ return rng\n",
);
assert_eq!(codes(&diags), Vec::<DiagnosticCode>::new(), "{diags:?}");
}
fn analyze_native(source: &str) -> Vec<brink_ir::Diagnostic> {
let mut db = ProjectDb::new();
db.set_analysis_options(brink_opts());
db.set_file("main.brink", source.to_owned());
db.analysis().diagnostics.clone()
}
#[test]
fn native_pure_assertion_is_exceeded_by_a_global_read() {
let diags =
analyze_native("var gold = 0\n\n@[effects(pure)]\nfn spend() {\n return gold;\n}\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("reads gold"), "{diags:?}");
}
#[test]
fn native_assertion_that_covers_the_body_is_silent() {
let diags = analyze_native(
"var gold = 0\n\n@[effects(reads(gold))]\nfn spend() {\n return gold;\n}\n",
);
assert_eq!(codes(&diags), Vec::<DiagnosticCode>::new(), "{diags:?}");
}
#[test]
fn native_writes_clause_exceedance_names_the_written_cell() {
let diags = analyze_native(
"var gold = 0\n\n@[effects(reads(gold))]\nfn spend(cost) {\n gold = gold - cost;\n return gold;\n}\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E103], "{diags:?}");
assert!(diags[0].message.contains("writes gold"), "{diags:?}");
}
#[test]
fn native_unknown_cell_name_in_an_assertion_is_e102() {
let diags = analyze_native(
"var gold = 0\n\n@[effects(reads(nonexistent))]\nfn spend() {\n return gold;\n}\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E102], "{diags:?}");
}
#[test]
fn native_stitch_silent_assertion_is_exceeded_by_an_emitting_nested_flow() {
let diags = analyze_native(
"flow main() {\n @[effects(silent)]\n flow tally() {\n Gold falls.\n }\n -> tally\n}\n",
);
assert_eq!(codes(&diags), vec![DiagnosticCode::E108], "{diags:?}");
}
#[test]
fn native_silent_assertion_is_exceeded_by_a_flow_that_emits() {
let diags = analyze_native("@[effects(silent)]\nflow garden() {\n Petals fall.\n}\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E108], "{diags:?}");
}