use brink_ir::hir::{Block, Choice, ChoiceSet, HirVisitor, Knot, ReturnKind, Stmt};
use brink_ir::{Diagnostic, DiagnosticCode, FileId, HirFile};
pub fn validate(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for &(file_id, hir) in files {
check_choices_in_inline_context(file_id, hir, &mut diagnostics);
let mut v = StructuralChecks::new(file_id);
brink_ir::hir::visit::visit(hir, &mut v);
diagnostics.extend(v.returns);
diagnostics.extend(v.unreachable);
diagnostics.extend(v.fallbacks);
}
diagnostics
}
fn check_choices_in_inline_context(
file_id: FileId,
hir: &HirFile,
diagnostics: &mut Vec<Diagnostic>,
) {
walk_block(&hir.root_content, false, file_id, diagnostics);
for knot in &hir.knots {
walk_block(&knot.body, false, file_id, diagnostics);
for stitch in &knot.stitches {
walk_block(&stitch.body, false, file_id, diagnostics);
}
}
}
fn walk_block(block: &Block, dead_end: bool, file_id: FileId, diagnostics: &mut Vec<Diagnostic>) {
for (i, stmt) in block.stmts.iter().enumerate() {
match stmt {
Stmt::ChoiceSet(cs) => {
if dead_end {
check_choice_set_diverts(cs, file_id, diagnostics);
}
walk_choice_set(cs, file_id, diagnostics);
}
Stmt::Conditional(cond) => {
let has_continuation = has_meaningful_stmts_after(&block.stmts, i);
for branch in &cond.branches {
walk_block(&branch.body, !has_continuation, file_id, diagnostics);
}
}
Stmt::Sequence(seq) => {
let has_continuation = has_meaningful_stmts_after(&block.stmts, i);
for branch in &seq.branches {
walk_block(&branch.body, !has_continuation, file_id, diagnostics);
}
}
Stmt::LabeledBlock(inner) => {
walk_block(inner, dead_end, file_id, diagnostics);
}
_ => {}
}
}
}
fn has_meaningful_stmts_after(stmts: &[Stmt], i: usize) -> bool {
stmts[i + 1..].iter().any(|s| !matches!(s, Stmt::EndOfLine))
}
fn walk_choice_set(cs: &ChoiceSet, file_id: FileId, diagnostics: &mut Vec<Diagnostic>) {
for choice in &cs.choices {
walk_block(&choice.body, false, file_id, diagnostics);
}
walk_block(&cs.continuation, false, file_id, diagnostics);
}
fn check_choice_set_diverts(cs: &ChoiceSet, file_id: FileId, diagnostics: &mut Vec<Diagnostic>) {
for choice in &cs.choices {
if !choice_has_explicit_divert(choice) {
diagnostics.push(Diagnostic {
file: file_id,
range: choice.ptr.text_range(),
message: "choice in conditional or sequence must explicitly divert".into(),
code: DiagnosticCode::E029,
});
}
}
}
fn choice_has_explicit_divert(choice: &Choice) -> bool {
block_has_divert(&choice.body)
}
fn block_has_divert(block: &Block) -> bool {
block.stmts.iter().any(|stmt| match stmt {
Stmt::Divert(_) | Stmt::TunnelCall(_) | Stmt::ThreadStart(_) => true,
Stmt::Conditional(cond) => cond.branches.iter().all(|b| block_has_divert(&b.body)),
Stmt::LabeledBlock(inner) => block_has_divert(inner),
_ => false,
})
}
#[derive(Default)]
struct UnreachableState {
saw_terminal: bool,
flagged: bool,
}
struct StructuralChecks {
file_id: FileId,
in_function: bool,
unreachable_stack: Vec<UnreachableState>,
returns: Vec<Diagnostic>,
unreachable: Vec<Diagnostic>,
fallbacks: Vec<Diagnostic>,
}
impl StructuralChecks {
fn new(file_id: FileId) -> Self {
Self {
file_id,
in_function: false,
unreachable_stack: Vec::new(),
returns: Vec::new(),
unreachable: Vec::new(),
fallbacks: Vec::new(),
}
}
}
impl HirVisitor for StructuralChecks {
fn enter_knot(&mut self, knot: &Knot) {
self.in_function = knot.is_function;
}
fn exit_knot(&mut self, _knot: &Knot) {
self.in_function = false;
}
fn enter_block(&mut self, _block: &Block) {
self.unreachable_stack.push(UnreachableState::default());
}
fn exit_block(&mut self, _block: &Block) {
self.unreachable_stack.pop();
}
fn enter_stmt(&mut self, stmt: &Stmt) {
let flag_unreachable = self
.unreachable_stack
.last()
.is_some_and(|s| s.saw_terminal && !s.flagged)
&& !matches!(stmt, Stmt::EndOfLine);
if flag_unreachable && let Some(range) = stmt_range(stmt) {
self.unreachable.push(Diagnostic {
file: self.file_id,
range,
message: DiagnosticCode::E033.title().to_string(),
code: DiagnosticCode::E033,
});
if let Some(s) = self.unreachable_stack.last_mut() {
s.flagged = true;
}
}
if matches!(stmt, Stmt::Divert(_) | Stmt::Return(_))
&& let Some(s) = self.unreachable_stack.last_mut()
{
s.saw_terminal = true;
}
if let Stmt::Return(ret) = stmt
&& ret.kind == ReturnKind::Explicit
&& !self.in_function
{
let range = ret
.ptr
.map_or(rowan::TextRange::default(), |p| p.text_range());
self.returns.push(Diagnostic {
file: self.file_id,
range,
message: DiagnosticCode::E032.title().to_string(),
code: DiagnosticCode::E032,
});
}
if let Stmt::ChoiceSet(cs) = stmt
&& !cs.choices.is_empty()
&& cs.choices.iter().all(|c| c.is_fallback)
{
self.fallbacks.push(Diagnostic {
file: self.file_id,
range: cs.choices[0].ptr.text_range(),
message: DiagnosticCode::E034.title().to_string(),
code: DiagnosticCode::E034,
});
}
}
}
fn stmt_range(stmt: &Stmt) -> Option<rowan::TextRange> {
match stmt {
Stmt::Content(c) => c.ptr.as_ref().map(brink_ir::Provenance::text_range),
Stmt::Divert(d) => d.ptr.as_ref().map(brink_ir::Provenance::text_range),
Stmt::TunnelCall(t) => Some(t.ptr.text_range()),
Stmt::ThreadStart(t) => Some(t.ptr.text_range()),
Stmt::TempDecl(t) => Some(t.ptr.text_range()),
Stmt::Assignment(a) => Some(a.ptr.text_range()),
Stmt::Return(r) => r.ptr.as_ref().map(brink_ir::Provenance::text_range),
Stmt::ChoiceSet(cs) => cs.choices.first().map(|c| c.ptr.text_range()),
Stmt::Conditional(c) => Some(c.ptr.text_range()),
Stmt::Sequence(s) => Some(s.ptr.text_range()),
Stmt::LabeledBlock(b) => b.label.as_ref().map(|l| l.range),
Stmt::ExprStmt(_) | Stmt::EndOfLine | Stmt::AttachElement(_) | Stmt::EndElementRun => None,
Stmt::LogicBlock(lb) => Some(lb.ptr.text_range()),
Stmt::Await(a) => Some(a.ptr.text_range()),
}
}
#[cfg(test)]
mod tests {
use brink_ir::hir::*;
use brink_ir::provenance::{NodeClass, Provenance};
use brink_ir::{DiagnosticCode, FileId, HirFile};
use rowan::{TextRange, TextSize};
use super::*;
#[test]
fn inline_branch_diverts_produce_no_spurious_structural_diagnostics() {
let cases = [
"A {cond: -> away} B\n=== away ===\n-> END\n",
"{cond: -> a | -> b}\n=== a ===\n-> END\n=== b ===\n-> END\n",
"{shuffle: -> a | -> b}\n=== a ===\n-> END\n=== b ===\n-> END\n",
"{cond: -> a text after divert}\n=== a ===\n-> END\n",
"Line {cond: -> a} {other: -> b}\n=== a ===\n-> END\n=== b ===\n-> END\n",
];
for src in cases {
let parsed = brink_syntax::parse(src);
let tree = parsed.tree();
let (hir, _, _) = brink_ir::hir::lower(FileId(0), &tree);
let diags = validate(&[(FileId(0), &hir)]);
let structural: Vec<_> = diags
.iter()
.map(|d| d.code)
.filter(|c| {
matches!(
c,
DiagnosticCode::E032 | DiagnosticCode::E033 | DiagnosticCode::E034
)
})
.collect();
assert!(
structural.is_empty(),
"inline-branch diverts must not produce structural diagnostics: {src:?} -> {structural:?}"
);
}
}
fn empty_hir() -> HirFile {
HirFile {
root_content: Block::default(),
knots: Vec::new(),
variables: Vec::new(),
constants: Vec::new(),
lists: Vec::new(),
structs: Vec::new(),
externals: Vec::new(),
includes: Vec::new(),
module: None,
imports: Vec::new(),
visibility: Vec::new(),
was_directives: Vec::new(),
allow_scopes: Vec::new(),
element_matches: Vec::new(),
cue_names: Vec::new(),
native: false,
claim_handlers: Vec::new(),
dispatch_handlers: Vec::new(),
}
}
fn dummy_range() -> TextRange {
TextRange::new(TextSize::new(0), TextSize::new(1))
}
fn dummy_knot_ptr() -> Provenance {
Provenance::synthetic(NodeClass::Knot, dummy_range())
}
fn dummy_choice_ptr() -> Provenance {
Provenance::synthetic(NodeClass::Choice, dummy_range())
}
fn dummy_return_ptr() -> Provenance {
Provenance::synthetic(NodeClass::Return, dummy_range())
}
#[test]
fn return_in_non_function_emits_e032() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "my_knot".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::Return(Return {
ptr: Some(dummy_return_ptr()),
kind: ReturnKind::Explicit,
value: None,
onwards_args: Vec::new(),
})]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E032);
}
#[test]
fn value_carrying_return_in_non_function_still_emits_e032() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "my_flow".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::Return(Return {
ptr: Some(dummy_return_ptr()),
kind: ReturnKind::Explicit,
value: Some(Expr::Int(5)),
onwards_args: Vec::new(),
})]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E032);
}
#[test]
fn return_in_function_no_error() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "my_func".into(),
range: dummy_range(),
},
is_function: true,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::Return(Return {
ptr: Some(dummy_return_ptr()),
kind: ReturnKind::Explicit,
value: Some(Expr::Int(42)),
onwards_args: Vec::new(),
})]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
assert!(
diags.is_empty(),
"return in function should not trigger E032: {diags:?}"
);
}
#[test]
fn tunnel_return_in_non_function_no_error() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "my_knot".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::Return(Return {
ptr: None,
kind: ReturnKind::TunnelRedirect,
value: None,
onwards_args: Vec::new(),
})]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
assert!(
diags.is_empty(),
"tunnel return should not trigger E032: {diags:?}"
);
}
#[test]
fn provenance_carrying_tunnel_return_no_e032() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "my_knot".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::Return(Return {
ptr: Some(dummy_return_ptr()),
kind: ReturnKind::TunnelRedirect,
value: None,
onwards_args: Vec::new(),
})]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
assert!(
diags.is_empty(),
"provenance-carrying tunnel return must not trigger E032: {diags:?}"
);
}
#[test]
fn pointerless_explicit_return_still_emits_e032() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "my_knot".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::Return(Return {
ptr: None,
kind: ReturnKind::Explicit,
value: None,
onwards_args: Vec::new(),
})]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E032);
}
#[test]
fn content_after_divert_emits_e033() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "test".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![
Stmt::Divert(Divert {
ptr: None,
target: DivertTarget {
path: DivertPath::Done,
args: Vec::new(),
},
}),
Stmt::Content(Content {
ptr: Some(Provenance::synthetic(NodeClass::Content, dummy_range())),
parts: vec![ContentPart::Text("unreachable".into())],
tags: Vec::new(),
}),
]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
let e033s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E033)
.collect();
assert_eq!(e033s.len(), 1);
}
#[test]
fn eol_after_divert_no_warning() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "test".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![
Stmt::Divert(Divert {
ptr: None,
target: DivertTarget {
path: DivertPath::Done,
args: Vec::new(),
},
}),
Stmt::EndOfLine,
]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
let e033s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E033)
.collect();
assert!(
e033s.is_empty(),
"EndOfLine after divert should not trigger E033"
);
}
#[test]
fn content_after_thread_start_no_warning() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "test".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![
Stmt::ThreadStart(ThreadStart {
ptr: Provenance::synthetic(NodeClass::ThreadStart, dummy_range()),
target: DivertTarget {
path: DivertPath::Path(Path {
segments: vec![Name {
text: "other".into(),
range: dummy_range(),
}],
range: dummy_range(),
crosses_module_wall: false,
}),
args: Vec::new(),
},
}),
Stmt::Content(Content {
ptr: Some(Provenance::synthetic(NodeClass::Content, dummy_range())),
parts: vec![ContentPart::Text("still reachable".into())],
tags: Vec::new(),
}),
]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
let e033s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E033)
.collect();
assert!(
e033s.is_empty(),
"ThreadStart is not terminal — content after it is reachable"
);
}
#[test]
fn content_after_tunnel_call_no_warning() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "greet".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![
Stmt::TunnelCall(TunnelCall {
ptr: Provenance::synthetic(NodeClass::TunnelCall, dummy_range()),
targets: vec![DivertTarget {
path: DivertPath::Path(Path {
segments: vec![Name {
text: "wave".into(),
range: dummy_range(),
}],
range: dummy_range(),
crosses_module_wall: false,
}),
args: Vec::new(),
}],
}),
Stmt::Content(Content {
ptr: Some(Provenance::synthetic(NodeClass::Content, dummy_range())),
parts: vec![ContentPart::Text("and we're off".into())],
tags: Vec::new(),
}),
]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
let e033s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E033)
.collect();
assert!(
e033s.is_empty(),
"TunnelCall is not terminal — content after it is reachable: {e033s:?}"
);
}
#[test]
fn all_fallback_choice_set_emits_e034() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "test".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::ChoiceSet(Box::new(ChoiceSet {
choices: vec![Choice {
ptr: dummy_choice_ptr(),
is_sticky: false,
is_fallback: true,
label: None,
condition: None,
binding: None,
start_content: None,
bracket_content: None,
inner_content: None,
tags: Vec::new(),
body: Block::default(),
container_id: None,
}],
continuation: Block::default(),
context: ChoiceSetContext::Weave,
depth: 1,
gather_id: None,
}))]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
let e034s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E034)
.collect();
assert_eq!(e034s.len(), 1);
}
#[test]
fn mixed_fallback_and_normal_no_warning() {
let mut hir = empty_hir();
hir.knots.push(Knot {
ptr: dummy_knot_ptr(),
name: Name {
text: "test".into(),
range: dummy_range(),
},
is_function: false,
params: Vec::new(),
body: Block::from_stmts(vec![Stmt::ChoiceSet(Box::new(ChoiceSet {
choices: vec![
Choice {
ptr: dummy_choice_ptr(),
is_sticky: false,
is_fallback: true,
label: None,
condition: None,
binding: None,
start_content: None,
bracket_content: None,
inner_content: None,
tags: Vec::new(),
body: Block::default(),
container_id: None,
},
Choice {
ptr: dummy_choice_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: Block::default(),
container_id: None,
},
],
continuation: Block::default(),
context: ChoiceSetContext::Weave,
depth: 1,
gather_id: None,
}))]),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
});
let files = vec![(FileId(0), &hir)];
let diags = validate(&files);
let e034s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E034)
.collect();
assert!(e034s.is_empty(), "mixed set should not trigger E034");
}
}