use super::*;
pub(crate) fn run_infer(
filename: &str,
function_filter: Option<&str>,
output_path: Option<&str>,
dry_run: bool,
focus: Option<&str>,
) {
let source = fs::read_to_string(filename).unwrap_or_else(|e| {
eprintln!("Error: {filename}: {e}");
process::exit(2);
});
let focus_patterns: Vec<&str> = focus
.map(|f| f.split(',').map(str::trim).collect())
.unwrap_or_default();
if filename.ends_with(".rs") {
run_infer_heuristic(filename, &source, &focus_patterns, dry_run, output_path);
return;
}
let signatures = extract_rust_fn_signatures(&source);
if signatures.is_empty() {
eprintln!("No public function signatures found in {filename}");
process::exit(1);
}
let filtered: Vec<&RustFnSig> = if let Some(name) = function_filter {
let matches: Vec<_> = signatures.iter().filter(|s| s.name == name).collect();
if matches.is_empty() {
eprintln!("Function '{name}' not found in {filename}");
let names: Vec<_> = signatures
.iter()
.filter(|s| s.is_pub)
.map(|s| s.name.as_str())
.collect();
eprintln!("Available public functions: {}", names.join(", "));
process::exit(1);
}
matches
} else {
signatures.iter().filter(|s| s.is_pub).collect()
};
if filtered.is_empty() {
eprintln!("No public function signatures found in {filename}");
process::exit(1);
}
let module_path = derive_rust_module_path(filename);
let mut out_buf = String::new();
out_buf.push_str(&format!(
"// Generated by: assura infer {filename}\n// Review and refine these contracts before use.\n\n"
));
for sig in &filtered {
generate_bind_skeleton(&module_path, sig, &mut out_buf);
}
out_buf.push_str(&format!(
"\n// {} function(s) analyzed from {filename}\n",
filtered.len()
));
if let Some(path) = output_path {
fs::write(path, &out_buf).unwrap_or_else(|e| {
eprintln!("Error: cannot write {path}: {e}");
process::exit(2);
});
eprintln!("Wrote {} contract(s) to {path}", filtered.len());
} else {
print!("{out_buf}");
}
}
pub(crate) struct InferSuggestion {
line: usize,
fn_name: String,
annotations: Vec<String>,
pattern: String,
}
pub(crate) fn run_infer_heuristic(
filename: &str,
source: &str,
focus: &[&str],
dry_run: bool,
output_path: Option<&str>,
) {
let mut suggestions = Vec::new();
let file = match syn::parse_file(source) {
Ok(f) => f,
Err(e) => {
eprintln!("Error parsing {filename}: {e}");
process::exit(1);
}
};
for item in &file.items {
match item {
syn::Item::Fn(func) => {
let suggs = analyze_function_body(func, source, focus);
suggestions.extend(suggs);
}
syn::Item::Impl(imp) => {
for impl_item in &imp.items {
if let syn::ImplItem::Fn(method) = impl_item {
let suggs = analyze_method_body(method, source, focus);
suggestions.extend(suggs);
}
}
}
_ => {}
}
}
if suggestions.is_empty() {
println!("No contract suggestions for {filename}");
if !focus.is_empty() {
println!(" (filtered by: {})", focus.join(", "));
}
return;
}
let mut output = String::new();
output.push_str(&format!(
"// Contract suggestions for {filename}\n// Generated by: assura infer --dry-run\n// Review each suggestion before accepting.\n\n"
));
for s in &suggestions {
output.push_str(&format!(
"// [{pattern}] {fn_name} (line {line})\n",
pattern = s.pattern,
fn_name = s.fn_name,
line = s.line
));
for ann in &s.annotations {
output.push_str(&format!("// {ann}\n"));
}
output.push('\n');
}
output.push_str(&format!(
"// {} suggestion(s) for {} function(s)\n",
suggestions
.iter()
.map(|s| s.annotations.len())
.sum::<usize>(),
suggestions.len()
));
if dry_run || output_path.is_none() {
print!("{output}");
}
if let Some(path) = output_path
&& !dry_run
{
fs::write(path, &output).unwrap_or_else(|e| {
eprintln!("Error writing {path}: {e}");
process::exit(2);
});
eprintln!("Wrote suggestions to {path}");
}
println!(
"\n{} suggestion(s) found across {} function(s)",
suggestions
.iter()
.map(|s| s.annotations.len())
.sum::<usize>(),
suggestions.len()
);
}
pub(crate) fn analyze_function_body(
func: &syn::ItemFn,
source: &str,
focus: &[&str],
) -> Vec<InferSuggestion> {
let name = func.sig.ident.to_string();
let body_str = extract_block_text(&func.block, source);
let line = func.sig.fn_token.span.start().line + 1;
let params = extract_param_names(&func.sig);
analyze_body_text(&name, &body_str, line, ¶ms, focus)
}
pub(crate) fn analyze_method_body(
method: &syn::ImplItemFn,
source: &str,
focus: &[&str],
) -> Vec<InferSuggestion> {
let name = method.sig.ident.to_string();
let body_str = extract_block_text(&method.block, source);
let line = method.sig.fn_token.span.start().line + 1;
let params = extract_param_names(&method.sig);
analyze_body_text(&name, &body_str, line, ¶ms, focus)
}
pub(crate) fn extract_block_text(block: &syn::Block, _source: &str) -> String {
use quote::ToTokens;
block.to_token_stream().to_string()
}
pub(crate) fn extract_param_names(sig: &syn::Signature) -> Vec<String> {
sig.inputs
.iter()
.filter_map(|arg| match arg {
syn::FnArg::Typed(pt) => {
use quote::ToTokens;
Some(pt.pat.to_token_stream().to_string())
}
syn::FnArg::Receiver(_) => None,
})
.collect()
}
pub(crate) fn analyze_body_text(
fn_name: &str,
body: &str,
line: usize,
params: &[String],
focus: &[&str],
) -> Vec<InferSuggestion> {
let mut suggestions = Vec::new();
let focus_all = focus.is_empty();
if (focus_all || focus.contains(&"division")) && body.contains('/') {
for param in params {
if body.contains(&format!("/ {param}"))
|| body.contains(&format!("/{param}"))
|| body.contains(&format!("% {param}"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
annotations: vec![format!("/// @requires {param} != 0")],
pattern: "division".to_string(),
});
}
}
}
if (focus_all || focus.contains(&"unwrap"))
&& (body.contains(".unwrap()") || body.contains(". unwrap ()"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
annotations: vec![
"/// @requires <value>.is_some() // or .is_ok()".to_string(),
"// REVIEW: .unwrap() panics on None/Err".to_string(),
],
pattern: "unwrap".to_string(),
});
}
if (focus_all || focus.contains(&"index")) && body.contains('[') {
for param in params {
if body.contains(&format!("[{param}]"))
|| body.contains(&format!("[ {param} ]"))
|| body.contains(&format!("[{param} ]"))
|| body.contains(&format!("[ {param}]"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
annotations: vec![format!("/// @requires {param} < <collection>.len()")],
pattern: "index".to_string(),
});
}
}
}
if (focus_all || focus.contains(&"unsafe"))
&& (body.contains("unsafe") || body.contains("unsafe {"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
annotations: vec![
"// REVIEW: contains unsafe block, manual contract required".to_string(),
],
pattern: "unsafe".to_string(),
});
}
if (focus_all || focus.contains(&"panic"))
&& (body.contains("panic!")
|| body.contains("panic !")
|| body.contains("todo!")
|| body.contains("todo !")
|| body.contains("unimplemented!")
|| body.contains("unimplemented !"))
{
suggestions.push(InferSuggestion {
line,
fn_name: fn_name.to_string(),
annotations: vec![
"// REVIEW: contains panic!/todo!/unimplemented!, add @requires to prevent"
.to_string(),
],
pattern: "panic".to_string(),
});
}
suggestions
}
pub(crate) struct RustFnSig {
pub(crate) name: String,
pub(crate) params: Vec<(String, String)>, pub(crate) return_type: String,
pub(crate) is_pub: bool,
}
pub(crate) fn extract_rust_fn_signatures(source: &str) -> Vec<RustFnSig> {
let mut sigs = Vec::new();
let lines: Vec<&str> = source.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
let (is_pub, fn_part) = match strip_fn_prefix(line) {
Some(pair) => pair,
None => {
i += 1;
continue;
}
};
let mut full_sig = fn_part.to_string();
let mut j = i + 1;
while !full_sig.contains('{') && !full_sig.contains(';') && j < lines.len() {
full_sig.push(' ');
full_sig.push_str(lines[j].trim());
j += 1;
}
if let Some(sig) = parse_fn_signature(&full_sig, is_pub) {
sigs.push(sig);
}
i = j.max(i + 1);
}
sigs
}
pub(crate) fn strip_fn_prefix(line: &str) -> Option<(bool, &str)> {
let mut rest = line;
let mut is_pub = false;
if let Some(after_pub) = rest.strip_prefix("pub") {
is_pub = true;
rest = after_pub;
let trimmed = rest.trim_start();
if let Some(after_paren) = trimmed.strip_prefix('(') {
if let Some(close) = after_paren.find(')') {
rest = &after_paren[close + 1..];
} else {
return None;
}
} else {
rest = trimmed;
}
}
loop {
let trimmed = rest.trim_start();
if let Some(after) = trimmed.strip_prefix("async ") {
rest = after;
} else if let Some(after) = trimmed.strip_prefix("const ") {
rest = after;
} else if let Some(after) = trimmed.strip_prefix("unsafe ") {
rest = after;
} else {
rest = trimmed;
break;
}
}
let after_fn = rest.strip_prefix("fn ")?;
Some((is_pub, after_fn))
}
pub(crate) fn parse_fn_signature(sig: &str, is_pub: bool) -> Option<RustFnSig> {
let paren_open = sig.find('(')?;
let raw_name = sig[..paren_open].trim();
let name = if let Some(angle) = raw_name.find('<') {
raw_name[..angle].trim().to_string()
} else {
raw_name.to_string()
};
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
let after_open = &sig[paren_open + 1..];
let mut depth = 1i32;
let mut close_offset = 0;
for (i, ch) in after_open.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
close_offset = i;
break;
}
}
_ => {}
}
}
let params_str = &after_open[..close_offset];
let params = parse_param_list(params_str);
let after_close = &after_open[close_offset + 1..];
let return_type = if let Some(arrow_pos) = after_close.find("->") {
let ret = after_close[arrow_pos + 2..].trim();
let ret = ret
.split('{')
.next()
.unwrap_or(ret)
.split("where")
.next()
.unwrap_or(ret)
.trim();
ret.to_string()
} else {
"()".to_string()
};
Some(RustFnSig {
name,
params,
return_type,
is_pub,
})
}
pub(crate) fn parse_param_list(params: &str) -> Vec<(String, String)> {
let params = params.trim();
if params.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut depth = 0i32;
let mut paren_depth = 0i32;
let mut start = 0;
let mut segments = Vec::new();
for (i, ch) in params.char_indices() {
match ch {
'<' => depth += 1,
'>' if depth > 0 => depth -= 1,
'(' => paren_depth += 1,
')' if paren_depth > 0 => paren_depth -= 1,
',' if depth == 0 && paren_depth == 0 => {
segments.push(¶ms[start..i]);
start = i + 1;
}
_ => {}
}
}
segments.push(¶ms[start..]);
for seg in segments {
let seg = seg.trim();
if seg == "self" || seg == "&self" || seg == "&mut self" {
continue;
}
if let Some(colon_pos) = seg.find(':') {
let name = seg[..colon_pos].trim();
let ty = seg[colon_pos + 1..].trim();
if !name.is_empty() {
result.push((name.to_string(), ty.to_string()));
}
}
}
result
}
pub(crate) fn derive_rust_module_path(file_path: &str) -> String {
let path = Path::new(file_path);
let components: Vec<_> = path.components().collect();
let mut crate_name: Option<String> = None;
let mut src_index: Option<usize> = None;
for (i, comp) in components.iter().enumerate() {
if comp.as_os_str() == "src" {
src_index = Some(i);
let crate_root: std::path::PathBuf = if i > 0 {
components[..i].iter().collect()
} else {
std::path::PathBuf::from(".")
};
let cargo_path = crate_root.join("Cargo.toml");
if let Ok(content) = fs::read_to_string(&cargo_path) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("name") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let rest = rest.trim();
let name = rest.trim_matches('"').trim_matches('\'');
crate_name = Some(name.replace('-', "_"));
break;
}
}
}
}
break;
}
}
let crate_segment = crate_name.unwrap_or_else(|| "crate".to_string());
if let Some(si) = src_index {
if si + 1 < components.len() {
let after_src: std::path::PathBuf = components[si + 1..].iter().collect();
let rel_str = after_src
.to_string_lossy()
.trim_end_matches(".rs")
.replace(['/', '\\'], "::");
if rel_str == "lib" || rel_str == "mod" {
crate_segment
} else if rel_str.ends_with("::mod") || rel_str.ends_with("::lib") {
let trimmed = rel_str.trim_end_matches("::mod").trim_end_matches("::lib");
format!("{crate_segment}::{trimmed}")
} else {
format!("{crate_segment}::{rel_str}")
}
} else {
crate_segment
}
} else {
file_path
.trim_end_matches(".rs")
.replace('/', "::")
.replace('-', "_")
}
}
pub(crate) fn generate_bind_skeleton(module_path: &str, sig: &RustFnSig, out: &mut String) {
use assura_codegen::type_map::rust_type_to_assura;
let rust_path = format!("{module_path}::{}", sig.name);
out.push_str(&format!("bind \"{}\" as {} {{\n", rust_path, sig.name));
if !sig.params.is_empty() {
out.push_str(" input(");
let params: Vec<String> = sig
.params
.iter()
.map(|(name, ty)| format!("{}: {}", name, rust_type_to_assura(ty)))
.collect();
out.push_str(¶ms.join(", "));
out.push_str(")\n");
}
let assura_ret = rust_type_to_assura(&sig.return_type);
if assura_ret != "Unit" {
out.push_str(&format!(" output(result: {assura_ret})\n"));
}
let mut has_clause = false;
for (name, rust_ty) in &sig.params {
let param_assura = rust_type_to_assura(rust_ty);
if matches!(param_assura.as_str(), "Int" | "Nat" | "Float") {
out.push_str(&format!(" requires {{ {name} >= 0 }}\n"));
has_clause = true;
}
}
if matches!(assura_ret.as_str(), "Int" | "Nat" | "Float") {
out.push_str(" ensures { result >= 0 }\n");
has_clause = true;
}
if !has_clause {
out.push_str(" // TODO: add requires clauses (preconditions)\n");
out.push_str(" // TODO: add ensures clauses (postconditions)\n");
}
out.push_str("}\n\n");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_pub_fn() {
let (is_pub, rest) = strip_fn_prefix("pub fn foo()").unwrap();
assert!(is_pub);
assert_eq!(rest, "foo()");
}
#[test]
fn strip_plain_fn() {
let (is_pub, rest) = strip_fn_prefix("fn bar()").unwrap();
assert!(!is_pub);
assert_eq!(rest, "bar()");
}
#[test]
fn strip_pub_crate_fn() {
let (is_pub, rest) = strip_fn_prefix("pub(crate) fn baz()").unwrap();
assert!(is_pub);
assert_eq!(rest, "baz()");
}
#[test]
fn strip_pub_async_fn() {
let (is_pub, rest) = strip_fn_prefix("pub async fn fetch()").unwrap();
assert!(is_pub);
assert_eq!(rest, "fetch()");
}
#[test]
fn strip_pub_unsafe_fn() {
let (is_pub, rest) = strip_fn_prefix("pub unsafe fn danger()").unwrap();
assert!(is_pub);
assert_eq!(rest, "danger()");
}
#[test]
fn strip_pub_const_fn() {
let (is_pub, rest) = strip_fn_prefix("pub const fn SIZE()").unwrap();
assert!(is_pub);
assert_eq!(rest, "SIZE()");
}
#[test]
fn strip_non_fn_line() {
assert!(strip_fn_prefix("let x = 5;").is_none());
assert!(strip_fn_prefix("struct Foo {}").is_none());
assert!(strip_fn_prefix("// fn comment").is_none());
}
#[test]
fn parse_empty_params() {
let params = parse_param_list("");
assert!(params.is_empty());
}
#[test]
fn parse_single_param() {
let params = parse_param_list("x: i64");
assert_eq!(params.len(), 1);
assert_eq!(params[0].0, "x");
assert_eq!(params[0].1, "i64");
}
#[test]
fn parse_multiple_params() {
let params = parse_param_list("a: i32, b: &str, c: bool");
assert_eq!(params.len(), 3);
assert_eq!(params[0].0, "a");
assert_eq!(params[1].0, "b");
assert_eq!(params[2].0, "c");
}
#[test]
fn parse_skips_self() {
let params = parse_param_list("&self, x: i64");
assert_eq!(params.len(), 1);
assert_eq!(params[0].0, "x");
}
#[test]
fn parse_generic_params() {
let params = parse_param_list("items: Vec<String>, idx: usize");
assert_eq!(params.len(), 2);
assert_eq!(params[0].1, "Vec<String>");
assert_eq!(params[1].0, "idx");
}
#[test]
fn parse_nested_generic_params() {
let params = parse_param_list("m: HashMap<String, Vec<i32>>");
assert_eq!(params.len(), 1);
assert_eq!(params[0].1, "HashMap<String, Vec<i32>>");
}
#[test]
fn parse_simple_signature() {
let sig = parse_fn_signature("add(a: i32, b: i32) -> i32 {", true).unwrap();
assert_eq!(sig.name, "add");
assert_eq!(sig.params.len(), 2);
assert_eq!(sig.return_type, "i32");
assert!(sig.is_pub);
}
#[test]
fn parse_no_return_type() {
let sig = parse_fn_signature("init() {", false).unwrap();
assert_eq!(sig.name, "init");
assert_eq!(sig.return_type, "()");
}
#[test]
fn parse_generic_fn() {
let sig = parse_fn_signature("encode<T: Serialize>(value: T) -> String {", true).unwrap();
assert_eq!(sig.name, "encode");
assert_eq!(sig.params.len(), 1);
}
#[test]
fn parse_where_clause_stripped() {
let sig = parse_fn_signature(
"process(data: Vec<u8>) -> Result<(), Error> where T: Clone {",
true,
)
.unwrap();
assert_eq!(sig.return_type, "Result<(), Error>");
}
#[test]
fn extract_pub_fns_from_source() {
let source = "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\nfn private_helper() {\n}\n\npub fn greet(name: &str) -> String {\n format!(\"Hello, {name}\")\n}\n";
let sigs = extract_rust_fn_signatures(source);
assert_eq!(sigs.len(), 3);
let pub_sigs: Vec<_> = sigs.iter().filter(|s| s.is_pub).collect();
assert_eq!(pub_sigs.len(), 2);
assert_eq!(pub_sigs[0].name, "add");
assert_eq!(pub_sigs[1].name, "greet");
}
#[test]
fn module_path_no_src() {
let path = derive_rust_module_path("foo/bar.rs");
assert_eq!(path, "foo::bar");
}
#[test]
fn detects_division_pattern() {
let suggs = analyze_body_text(
"divide",
"result = a / b",
10,
&["a".into(), "b".into()],
&[],
);
assert!(suggs.iter().any(|s| s.pattern == "division"));
}
#[test]
fn detects_unwrap_pattern() {
let suggs = analyze_body_text("process", "x.unwrap()", 5, &[], &[]);
assert!(suggs.iter().any(|s| s.pattern == "unwrap"));
}
#[test]
fn detects_index_pattern() {
let suggs = analyze_body_text(
"lookup",
"items[idx]",
1,
&["items".into(), "idx".into()],
&[],
);
assert!(suggs.iter().any(|s| s.pattern == "index"));
}
#[test]
fn detects_unsafe_pattern() {
let suggs = analyze_body_text("raw_op", "unsafe { *ptr }", 1, &[], &[]);
assert!(suggs.iter().any(|s| s.pattern == "unsafe"));
}
#[test]
fn detects_panic_pattern() {
let suggs = analyze_body_text("bail", "panic!(\"oh no\")", 1, &[], &[]);
assert!(suggs.iter().any(|s| s.pattern == "panic"));
}
#[test]
fn focus_filters_patterns() {
let suggs = analyze_body_text(
"mixed",
"x.unwrap(); items[idx]; a / b",
1,
&["a".into(), "b".into(), "idx".into()],
&["division"],
);
assert!(suggs.iter().all(|s| s.pattern == "division"));
}
#[test]
fn no_false_positives_on_clean_body() {
let suggs = analyze_body_text(
"clean",
"a + b * c",
1,
&["a".into(), "b".into(), "c".into()],
&[],
);
assert!(suggs.is_empty());
}
#[test]
fn bind_skeleton_has_input_and_output() {
let sig = RustFnSig {
name: "add".to_string(),
params: vec![
("a".to_string(), "i64".to_string()),
("b".to_string(), "i64".to_string()),
],
return_type: "i64".to_string(),
is_pub: true,
};
let mut out = String::new();
generate_bind_skeleton("my_crate", &sig, &mut out);
assert!(out.contains("bind \"my_crate::add\" as add"));
assert!(out.contains("input(a: Int, b: Int)"));
assert!(out.contains("output(result: Int)"));
}
#[test]
fn bind_skeleton_unit_return_omits_output() {
let sig = RustFnSig {
name: "init".to_string(),
params: vec![],
return_type: "()".to_string(),
is_pub: true,
};
let mut out = String::new();
generate_bind_skeleton("my_crate", &sig, &mut out);
assert!(!out.contains("output"));
}
}