use std::collections::{BTreeMap, BTreeSet, VecDeque};
use brink_format::DefinitionId;
use brink_ir::hir::visit::{self, HirVisitor};
use brink_ir::{
Diagnostic, DiagnosticCode, Expr, ExternalKind, FileId, HirFile, ResolutionMap, SymbolIndex,
SymbolKind,
};
use rowan::TextRange;
use crate::external_check::SymbolMeta;
use crate::infer::{collect_defs, index_resolutions_by_file};
fn range_key(range: TextRange) -> (u32, u32) {
(range.start().into(), range.end().into())
}
#[must_use]
pub fn check(
file: FileId,
hir: &HirFile,
project_files: &[(FileId, &HirFile)],
index: &SymbolIndex,
resolutions: &ResolutionMap,
symbol_meta: &BTreeMap<DefinitionId, SymbolMeta>,
) -> Vec<Diagnostic> {
if hir.claim_handlers.is_empty() {
return Vec::new();
}
let handler_defs: BTreeMap<(u32, u32), DefinitionId> = index
.symbols
.values()
.filter(|s| s.file == file && s.kind == SymbolKind::Knot)
.map(|s| (range_key(s.range), s.id))
.collect();
let defs_by_id: BTreeMap<DefinitionId, _> = collect_defs(project_files, index)
.into_iter()
.map(|d| (d.id, d))
.collect();
let resolutions_by_file = index_resolutions_by_file(resolutions);
let mut out = Vec::new();
let mut diagnosed: BTreeSet<(FileId, (u32, u32))> = BTreeSet::new();
let mut visited: BTreeSet<DefinitionId> = BTreeSet::new();
let mut queue: VecDeque<DefinitionId> = VecDeque::new();
for handler in &hir.claim_handlers {
let Some(&handler_def) = handler_defs.get(&range_key(handler.name.range)) else {
continue;
};
if !visited.insert(handler_def) {
continue;
}
queue.push_back(handler_def);
while let Some(def) = queue.pop_front() {
let Some(d) = defs_by_id.get(&def) else {
continue;
};
let def_file = d.file;
let Some(by_range) = resolutions_by_file.get(&def_file) else {
continue;
};
let mut collector = CallSiteCollector {
by_range,
sites: Vec::new(),
};
visit::walk_block(d.body, &mut collector);
for (call_range, target) in collector.sites {
match index.symbols.get(&target).map(|s| s.kind) {
Some(SymbolKind::External) => {
let kind = symbol_meta.get(&target).map(|m| m.kind).unwrap_or_default();
if !matches!(kind, ExternalKind::Query | ExternalKind::Plain) {
continue;
}
if !diagnosed.insert((def_file, range_key(call_range))) {
continue;
}
let ext_name = index.symbols.get(&target).map_or("?", |s| s.name.as_str());
let (what, fix) = match kind {
ExternalKind::Query => (
"a `Query`-kind external (a world read)",
"call sites that must read world state belong outside \
`@[convention]` handlers",
),
_ => (
"an unclassified (`Plain`-kind) external — unprovable is not \
passable",
"classify it with an inline `@kind` doc tag or a registered \
host manifest entry",
),
};
out.push(Diagnostic {
file: def_file,
range: call_range,
code: DiagnosticCode::E182,
message: format!(
"{}: `{}`'s call to `{ext_name}` reaches {what} — \
`@[convention]` handlers may call pure functions and \
commands, but must never read world state ({fix})",
DiagnosticCode::E182.title(),
handler.name.text,
),
});
}
Some(SymbolKind::Knot | SymbolKind::Stitch) if visited.insert(target) => {
queue.push_back(target);
}
_ => {}
}
}
}
}
out
}
struct CallSiteCollector<'a> {
by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
sites: Vec<(TextRange, DefinitionId)>,
}
impl HirVisitor for CallSiteCollector<'_> {
fn visit_exprs(&self) -> bool {
true
}
fn enter_expr(&mut self, expr: &Expr) {
if let Expr::Call(path, _args) = expr
&& let Some(&target) = self.by_range.get(&range_key(path.range))
{
self.sites.push((path.range, target));
}
}
}
#[cfg(test)]
mod tests {
use brink_ir::hir::lower_native;
use brink_ir::{DiagnosticCode, FileId, HostManifest};
use crate::{AnalysisOptions, AnalysisResult, analyze_with_options};
fn analyze(src: &str, host_manifest: Option<HostManifest>) -> AnalysisResult {
let parsed = brink_syntax_native::parse(src);
assert!(parsed.errors().is_empty(), "{:?}", parsed.errors());
let (hir, manifest, diags) = lower_native::lower(FileId(0), &parsed.tree());
assert!(diags.is_empty(), "{diags:?}");
let opts = AnalysisOptions {
host_manifest,
..AnalysisOptions::default()
};
analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts)
}
fn e182(result: &AnalysisResult) -> Vec<&brink_ir::Diagnostic> {
result
.diagnostics
.iter()
.filter(|d| d.code == DiagnosticCode::E182)
.collect()
}
#[test]
fn handler_calling_a_pure_fn_is_legal() {
let src = "@[convention(claims = \"^X$\", order = 10)]\n\
fn handler() {\n return helper();\n}\n\
fn helper() {\n return 1;\n}\n\
flow main() {\n hi\n}\n";
let result = analyze(src, None);
assert!(e182(&result).is_empty(), "{:?}", result.diagnostics);
}
#[test]
fn handler_calling_an_effect_external_is_legal() {
let src = "/// @kind effect\n\
extern play_sound(name)\n\n\
@[convention(claims = \"^X$\", order = 10)]\n\
fn handler() {\n play_sound(\"ding\");\n return \"ok\";\n}\n\
flow main() {\n hi\n}\n";
let result = analyze(src, None);
assert!(e182(&result).is_empty(), "{:?}", result.diagnostics);
}
#[test]
fn handler_calling_a_query_external_directly_is_diagnosed() {
let src = "/// @kind query\n\
extern get_health()\n\n\
@[convention(claims = \"^X$\", order = 10)]\n\
fn handler() {\n return get_health();\n}\n\
flow main() {\n hi\n}\n";
let result = analyze(src, None);
let found = e182(&result);
assert_eq!(found.len(), 1, "{:?}", result.diagnostics);
assert!(found[0].message.contains("get_health"), "{found:?}");
assert!(found[0].message.contains("handler"), "{found:?}");
}
#[test]
fn handler_calling_an_unclassified_external_directly_is_diagnosed() {
let src = "extern mystery()\n\n\
@[convention(claims = \"^X$\", order = 10)]\n\
fn handler() {\n return mystery();\n}\n\
flow main() {\n hi\n}\n";
let result = analyze(src, None);
let found = e182(&result);
assert_eq!(found.len(), 1, "{:?}", result.diagnostics);
assert!(found[0].message.contains("mystery"), "{found:?}");
}
#[test]
fn transitive_query_through_a_helper_fn_is_diagnosed_at_the_real_call_site() {
let src = "/// @kind query\n\
extern get_health()\n\n\
@[convention(claims = \"^X$\", order = 10)]\n\
fn handler() {\n return helper();\n}\n\
fn helper() {\n return get_health();\n}\n\
flow main() {\n hi\n}\n";
let result = analyze(src, None);
let found = e182(&result);
assert_eq!(found.len(), 1, "{:?}", result.diagnostics);
let call_start =
u32::try_from(src.find("return get_health()").expect("call site") + "return ".len())
.expect("offset fits u32");
assert_eq!(found[0].range.start(), call_start.into(), "{found:?}");
}
}