use brink_ir::hir::{Block, ChoiceSet, Stmt};
use brink_ir::{Diagnostic, DiagnosticCode, FileId, HirFile};
fn diag(file: FileId, range: rowan::TextRange) -> Diagnostic {
Diagnostic {
file,
range,
message: DiagnosticCode::E151.title().to_string(),
code: DiagnosticCode::E151,
}
}
#[must_use]
pub fn check(file_id: FileId, hir: &HirFile) -> Vec<Diagnostic> {
let mut diags = Vec::new();
walk_block(file_id, &hir.root_content, &mut diags);
for knot in &hir.knots {
walk_block(file_id, &knot.body, &mut diags);
for stitch in &knot.stitches {
walk_block(file_id, &stitch.body, &mut diags);
}
}
diags
}
fn walk_block(file_id: FileId, block: &Block, diags: &mut Vec<Diagnostic>) {
for stmt in &block.stmts {
match stmt {
Stmt::ChoiceSet(cs) => {
check_choice_set(file_id, cs, diags);
walk_block(file_id, &cs.continuation, diags);
for choice in &cs.choices {
walk_block(file_id, &choice.body, diags);
}
}
Stmt::LabeledBlock(b) => walk_block(file_id, b, diags),
Stmt::Conditional(c) => {
for branch in &c.branches {
walk_block(file_id, &branch.body, diags);
}
}
Stmt::Sequence(s) => {
for branch in &s.branches {
walk_block(file_id, &branch.body, diags);
}
}
Stmt::Content(_)
| Stmt::Divert(_)
| Stmt::TunnelCall(_)
| Stmt::ThreadStart(_)
| Stmt::TempDecl(_)
| Stmt::Assignment(_)
| Stmt::Return(_)
| Stmt::ExprStmt(_)
| Stmt::EndOfLine
| Stmt::LogicBlock(_)
| Stmt::Await(_)
| Stmt::AttachElement(_)
| Stmt::EndElementRun => {}
}
}
}
fn diverges(body: &Block) -> bool {
terminates(&body.stmts)
}
fn terminates(stmts: &[Stmt]) -> bool {
if stmts
.iter()
.any(|s| matches!(s, Stmt::Divert(_) | Stmt::Return(_)))
{
return true;
}
match stmts.iter().rev().find(|s| !matches!(s, Stmt::EndOfLine)) {
Some(Stmt::LabeledBlock(inner)) => terminates(&inner.stmts),
Some(Stmt::Conditional(cond)) => {
cond.branches.iter().any(|b| b.condition.is_none())
&& cond.branches.iter().all(|b| terminates(&b.body.stmts))
}
Some(Stmt::ChoiceSet(_)) => true,
_ => false,
}
}
fn check_choice_set(file_id: FileId, cs: &ChoiceSet, diags: &mut Vec<Diagnostic>) {
if !cs.continuation.stmts.is_empty() {
return;
}
let any_diverges = cs.choices.iter().any(|c| diverges(&c.body));
let any_falls_through = cs.choices.iter().any(|c| !diverges(&c.body));
if !any_diverges || !any_falls_through {
return;
}
for choice in &cs.choices {
if !diverges(&choice.body) {
diags.push(diag(file_id, choice.ptr.text_range()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use brink_ir::Provenance;
use brink_ir::hir::{
Choice, ChoiceSetContext, Divert, DivertPath, DivertTarget, Expr, Name, Path,
};
use brink_ir::provenance::NodeClass;
fn synthetic_range() -> rowan::TextRange {
rowan::TextRange::new(0.into(), 1.into())
}
fn ptr() -> Provenance {
Provenance::synthetic(NodeClass::Choice, synthetic_range())
}
fn base_choice(body: Block) -> Choice {
Choice {
ptr: ptr(),
is_sticky: false,
is_fallback: false,
label: None,
condition: None,
binding: None,
start_content: None,
bracket_content: None,
inner_content: None,
tags: Vec::new(),
body,
container_id: None,
}
}
fn divert_stmt() -> Stmt {
let range = synthetic_range();
Stmt::Divert(Divert {
ptr: Some(Provenance::synthetic(NodeClass::Divert, range)),
target: DivertTarget {
path: DivertPath::Path(Path {
segments: vec![Name {
text: "elsewhere".to_string(),
range,
}],
range,
crosses_module_wall: false,
}),
args: Vec::new(),
},
})
}
fn idiomatic_diverting_choice() -> Choice {
base_choice(Block::from_stmts(vec![divert_stmt(), Stmt::EndOfLine]))
}
fn falling_through_choice() -> Choice {
base_choice(Block::from_stmts(vec![Stmt::EndOfLine]))
}
fn choice_set(choices: Vec<Choice>, continuation: Block) -> ChoiceSet {
ChoiceSet {
choices,
continuation,
context: ChoiceSetContext::Inline,
depth: 0,
gather_id: None,
}
}
#[test]
fn diverges_sees_through_the_choice_preamble_endofline() {
assert!(diverges(&idiomatic_diverting_choice().body));
assert!(!diverges(&falling_through_choice().body));
}
#[test]
fn mixed_tail_with_no_continuation_is_flagged() {
let cs = choice_set(
vec![idiomatic_diverting_choice(), falling_through_choice()],
Block::from_stmts(Vec::new()),
);
let mut diags = Vec::new();
check_choice_set(FileId(0), &cs, &mut diags);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, DiagnosticCode::E151);
}
#[test]
fn all_diverge_is_clean() {
let cs = choice_set(
vec![idiomatic_diverting_choice(), idiomatic_diverting_choice()],
Block::from_stmts(Vec::new()),
);
let mut diags = Vec::new();
check_choice_set(FileId(0), &cs, &mut diags);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn all_fall_through_is_clean() {
let cs = choice_set(
vec![falling_through_choice(), falling_through_choice()],
Block::from_stmts(Vec::new()),
);
let mut diags = Vec::new();
check_choice_set(FileId(0), &cs, &mut diags);
assert!(diags.is_empty(), "{diags:?}");
}
#[test]
fn non_empty_continuation_is_the_dissolved_gather_exclusion() {
let cs = choice_set(
vec![idiomatic_diverting_choice(), falling_through_choice()],
Block::from_stmts(vec![Stmt::Content(brink_ir::hir::Content {
ptr: None,
parts: Vec::new(),
tags: Vec::new(),
})]),
);
let mut diags = Vec::new();
check_choice_set(FileId(0), &cs, &mut diags);
assert!(
diags.is_empty(),
"non-empty continuation is legitimate reconvergence, not a dead end: {diags:?}"
);
}
#[test]
fn single_choice_never_flags_by_construction() {
let cs = choice_set(
vec![falling_through_choice()],
Block::from_stmts(Vec::new()),
);
let mut diags = Vec::new();
check_choice_set(FileId(0), &cs, &mut diags);
assert!(diags.is_empty(), "{diags:?}");
}
fn return_stmt() -> Stmt {
Stmt::Return(brink_ir::hir::Return {
ptr: Some(Provenance::synthetic(NodeClass::Return, synthetic_range())),
kind: brink_ir::hir::ReturnKind::Explicit,
value: None,
onwards_args: Vec::new(),
})
}
#[test]
fn diverges_recurses_into_a_trailing_label_absorbed_block() {
let labeled = Stmt::LabeledBlock(Box::new(Block::from_stmts(vec![
Stmt::Content(brink_ir::hir::Content {
ptr: None,
parts: Vec::new(),
tags: Vec::new(),
}),
Stmt::EndOfLine,
divert_stmt(),
])));
let body = Block::from_stmts(vec![labeled]);
assert!(
diverges(&body),
"a divert wrapped in a trailing label-absorbed LabeledBlock must count as diverging"
);
}
#[test]
fn diverges_sees_a_leading_divert_before_a_braced_body() {
let body = Block::from_stmts(vec![
divert_stmt(),
Stmt::EndOfLine,
Stmt::Content(brink_ir::hir::Content {
ptr: None,
parts: Vec::new(),
tags: Vec::new(),
}),
]);
assert!(
diverges(&body),
"a divert preceding a braced body is still an unconditional terminator"
);
}
fn cond_branch(condition: Option<Expr>, stmts: Vec<Stmt>) -> brink_ir::hir::CondBranch {
brink_ir::hir::CondBranch {
ptr: Provenance::synthetic(NodeClass::ConditionalBranch, synthetic_range()),
condition,
binding: None,
body: Block::from_stmts(stmts),
container_id: None,
}
}
#[test]
fn diverges_recognizes_an_all_arms_diverging_conditional_with_else() {
let body = Block::from_stmts(vec![Stmt::Conditional(brink_ir::hir::Conditional {
ptr: Provenance::synthetic(NodeClass::Conditional, synthetic_range()),
kind: brink_ir::hir::CondKind::IfElse,
branches: vec![
cond_branch(Some(Expr::Bool(true)), vec![divert_stmt()]),
cond_branch(None, vec![divert_stmt()]),
],
})]);
assert!(
diverges(&body),
"every arm diverges and there's an explicit else — this is a terminator"
);
}
#[test]
fn diverges_rejects_a_conditional_without_an_else_arm() {
let body = Block::from_stmts(vec![Stmt::Conditional(brink_ir::hir::Conditional {
ptr: Provenance::synthetic(NodeClass::Conditional, synthetic_range()),
kind: brink_ir::hir::CondKind::InitialCondition,
branches: vec![cond_branch(Some(Expr::Bool(true)), vec![divert_stmt()])],
})]);
assert!(
!diverges(&body),
"no else arm means an implicit fall-through path this checker can't see a terminator for"
);
}
#[test]
fn diverges_treats_a_trailing_nested_choice_set_as_not_a_dead_end() {
let nested = choice_set(
vec![idiomatic_diverting_choice(), idiomatic_diverting_choice()],
Block::from_stmts(Vec::new()),
);
let body = Block::from_stmts(vec![
Stmt::Content(brink_ir::hir::Content {
ptr: None,
parts: Vec::new(),
tags: Vec::new(),
}),
Stmt::EndOfLine,
Stmt::ChoiceSet(Box::new(nested)),
]);
assert!(
diverges(&body),
"a trailing nested choice point must not make the outer choice look like a dead end"
);
}
#[test]
fn diverges_does_not_treat_a_trailing_tunnel_call_as_a_terminator() {
let body = Block::from_stmts(vec![Stmt::TunnelCall(brink_ir::hir::TunnelCall {
ptr: Provenance::synthetic(NodeClass::TunnelCall, synthetic_range()),
targets: vec![DivertTarget {
path: DivertPath::Path(Path {
segments: vec![Name {
text: "combat".to_string(),
range: synthetic_range(),
}],
range: synthetic_range(),
crosses_module_wall: false,
}),
args: Vec::new(),
}],
})]);
assert!(
!diverges(&body),
"a tunnel call returns control to whatever follows once it pops — still falls through"
);
}
#[test]
fn diverges_treats_return_as_an_interchangeable_terminator() {
let body = Block::from_stmts(vec![return_stmt()]);
assert!(diverges(&body));
}
}