use crate::domain::concepts::{ConceptInstance, ConceptKind};
use crate::domain::types::{SourceFile, SourceLocation};
use proc_macro2::Span;
use std::path::Path;
use syn::spanned::Spanned;
use syn::visit::Visit;
use syn::{
Expr, ExprCall, ExprClosure, ExprForLoop, ExprLoop, ExprMacro, ExprMatch, ExprMethodCall,
ExprTry, File, Item, Local, Pat, Stmt,
};
pub fn extract_concepts(source: &SourceFile) -> Result<Vec<ConceptInstance>, String> {
let file: File = syn::parse_file(&source.content).map_err(|e| e.to_string())?;
let mut visitor = ConceptVisitor {
path: source.path.as_path(),
source: &source.content,
concepts: Vec::new(),
in_error_arm: false,
};
visitor.visit_file(&file);
visitor.scan_todo_fixme_comments();
visitor.concepts.extend(extract_cleanup_concepts(source));
Ok(visitor.concepts)
}
struct ConceptVisitor<'a> {
path: &'a Path,
source: &'a str,
concepts: Vec<ConceptInstance>,
in_error_arm: bool,
}
impl ConceptVisitor<'_> {
fn loc(&self, span: Span) -> SourceLocation {
let start = span.start();
let end = span.end();
SourceLocation {
file: self.path.to_path_buf(),
start_line: start.line as u32,
start_column: start.column as u32 + 1,
end_line: end.line as u32,
end_column: end.column as u32 + 1,
}
}
fn push(&mut self, kind: ConceptKind, tag: &str, span: Span, surface: impl Into<String>) {
let mut c = ConceptInstance::new(kind, self.loc(span), tag, surface);
if self.in_error_arm {
c = c.with_critical_path(true);
}
self.concepts.push(c);
}
fn scan_todo_fixme_comments(&mut self) {
for (idx, line) in self.source.lines().enumerate() {
let lower = line.to_ascii_lowercase();
if !(lower.contains("todo") || lower.contains("fixme")) {
continue;
}
if !(line.contains("//") || line.contains("/*")) {
continue;
}
if lower.contains("error")
|| lower.contains("err")
|| lower.contains("fail")
|| lower.contains("catch")
|| lower.contains("recover")
|| lower.contains("retry")
{
let line_no = (idx + 1) as u32;
self.concepts.push(
ConceptInstance::new(
ConceptKind::Placeholder,
SourceLocation::single_line(self.path, line_no, 1),
"todo_fixme_comment",
line.trim().to_string(),
)
.with_critical_path(true),
);
}
}
}
fn method_name(mc: &ExprMethodCall) -> String {
mc.method.to_string()
}
fn path_tail(path: &syn::Path) -> String {
path.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default()
}
}
impl<'ast> Visit<'ast> for ConceptVisitor<'_> {
fn visit_expr_method_call(&mut self, mc: &'ast ExprMethodCall) {
let name = Self::method_name(mc);
match name.as_str() {
"unwrap" => {
self.push(
ConceptKind::Termination,
"unwrap",
mc.span(),
format!("{}.unwrap()", expr_surface(&mc.receiver)),
);
}
"expect" => {
self.push(
ConceptKind::Termination,
"expect",
mc.span(),
format!("{}.expect(..)", expr_surface(&mc.receiver)),
);
}
"unwrap_err" => {
self.push(
ConceptKind::Termination,
"unwrap_err",
mc.span(),
"unwrap_err()",
);
}
"ok" => {
self.concepts.push(
ConceptInstance::new(
ConceptKind::ContextLoss,
self.loc(mc.span()),
"result_ok_drop_err",
format!("{}.ok()", expr_surface(&mc.receiver)),
)
.with_drops_context(true),
);
}
"unwrap_or_default" | "unwrap_or" | "unwrap_or_else" => {
self.push(
ConceptKind::Recovery,
name.as_str(),
mc.span(),
format!(".{}(..)", name),
);
}
"map_err" => {
if let Some(Expr::Closure(cl)) = mc.args.first() {
if is_context_dropping_closure(cl) {
self.concepts.push(
ConceptInstance::new(
ConceptKind::ContextLoss,
self.loc(mc.span()),
"map_err_drop",
"map_err drops original error",
)
.with_drops_context(true),
);
}
}
}
_ => {}
}
syn::visit::visit_expr_method_call(self, mc);
}
fn visit_expr_macro(&mut self, mac: &'ast ExprMacro) {
let name = Self::path_tail(&mac.mac.path);
match name.as_str() {
"panic" => {
self.push(
ConceptKind::Termination,
"panic",
mac.span(),
"panic!(..)",
);
}
"todo" => {
self.push(
ConceptKind::Placeholder,
"todo",
mac.span(),
"todo!()",
);
}
"unimplemented" => {
self.push(
ConceptKind::Placeholder,
"unimplemented",
mac.span(),
"unimplemented!()",
);
}
"unreachable" => {
self.push(
ConceptKind::Termination,
"unreachable",
mac.span(),
"unreachable!()",
);
}
"assert" | "assert_eq" | "assert_ne" | "debug_assert" => {
self.push(
ConceptKind::Termination,
"assert",
mac.span(),
format!("{name}!(..)"),
);
}
_ => {}
}
syn::visit::visit_expr_macro(self, mac);
}
fn visit_expr_call(&mut self, call: &'ast ExprCall) {
if let Expr::Path(p) = call.func.as_ref() {
let tail = Self::path_tail(&p.path);
let full = p
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
if tail == "spawn" || full.ends_with("task::spawn") || full.contains("tokio::spawn") {
self.push(
ConceptKind::UnobservedTask,
"spawn",
call.span(),
format!("{full}(..)"),
);
}
if full.contains("thread::spawn") {
self.push(
ConceptKind::UnobservedTask,
"thread_spawn",
call.span(),
"thread::spawn(..)",
);
}
if matches!(
tail.as_str(),
"charge" | "transfer" | "debit" | "publish_once" | "create_payment"
) {
self.push(
ConceptKind::StateMutation,
"non_idempotent_name",
call.span(),
full,
);
}
}
syn::visit::visit_expr_call(self, call);
}
fn visit_local(&mut self, local: &'ast Local) {
if let Pat::Wild(_) = &local.pat {
if let Some(init) = &local.init {
if expr_looks_fallible(&init.expr) {
self.push(
ConceptKind::IgnoredFailure,
"let_underscore",
local.span(),
format!("let _ = {};", expr_surface(&init.expr)),
);
}
}
}
syn::visit::visit_local(self, local);
}
fn visit_stmt(&mut self, stmt: &'ast Stmt) {
if let Stmt::Macro(m) = stmt {
let name = m
.mac
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
match name.as_str() {
"panic" => self.push(
ConceptKind::Termination,
"panic",
m.span(),
"panic!(..)",
),
"todo" => self.push(
ConceptKind::Placeholder,
"todo",
m.span(),
"todo!()",
),
"unimplemented" => self.push(
ConceptKind::Placeholder,
"unimplemented",
m.span(),
"unimplemented!()",
),
"unreachable" => self.push(
ConceptKind::Termination,
"unreachable",
m.span(),
"unreachable!()",
),
_ => {}
}
}
if let Stmt::Expr(expr, Some(_)) = stmt {
if expr_looks_fallible(expr) && !matches!(expr, Expr::Try(_)) && !is_termination_method(expr)
{
self.push(
ConceptKind::IgnoredFailure,
"discarded_expr",
expr.span(),
format!("{};", expr_surface(expr)),
);
}
}
syn::visit::visit_stmt(self, stmt);
}
fn visit_expr_match(&mut self, m: &'ast ExprMatch) {
for arm in &m.arms {
let is_err = pat_is_err_like(&arm.pat);
let prev = self.in_error_arm;
if is_err {
self.in_error_arm = true;
}
let body = arm.body.as_ref();
if is_err && is_empty_handler(body) {
self.push(
ConceptKind::EmptyHandler,
"empty_err_arm",
arm.span(),
"match arm Err => {}",
);
}
if is_err && is_logging_only(body) {
self.concepts.push(
ConceptInstance::new(
ConceptKind::LoggingOnly,
self.loc(arm.span()),
"log_only_err_arm",
"Err arm only logs",
)
.with_logs_only(true),
);
}
if is_err && is_ok_unit(body) {
self.concepts.push(
ConceptInstance::new(
ConceptKind::IgnoredFailure,
self.loc(arm.span()),
"err_to_ok",
"Err mapped to Ok(())",
)
.with_drops_context(true),
);
}
syn::visit::visit_arm(self, arm);
self.in_error_arm = prev;
}
}
fn visit_expr_try(&mut self, node: &'ast ExprTry) {
self.push(
ConceptKind::Propagation,
"question_mark",
node.span(),
"?",
);
syn::visit::visit_expr_try(self, node);
}
fn visit_expr_loop(&mut self, node: &'ast ExprLoop) {
if loop_looks_like_retry(node) {
let bounded = loop_has_bound(node);
let mut c = ConceptInstance::new(
ConceptKind::Retry,
self.loc(node.span()),
"loop_retry",
"loop { ... }",
)
.with_bounded(bounded);
let body_str = format!("{:?}", node.body);
if body_str.contains("charge")
|| body_str.contains("transfer")
|| body_str.contains("insert")
|| body_str.contains("write")
{
c = c.with_idempotent(false);
}
self.concepts.push(c);
}
syn::visit::visit_expr_loop(self, node);
}
fn visit_expr_for_loop(&mut self, node: &'ast ExprForLoop) {
if for_looks_like_retry(node) {
self.concepts.push(
ConceptInstance::new(
ConceptKind::Retry,
self.loc(node.span()),
"for_retry",
"for _ in .. { retry }",
)
.with_bounded(true),
);
}
syn::visit::visit_expr_for_loop(self, node);
}
fn visit_item(&mut self, item: &'ast Item) {
syn::visit::visit_item(self, item);
}
}
fn expr_surface(expr: &Expr) -> String {
let s = quote::quote!(#expr).to_string();
if s.len() > 80 {
format!("{}…", &s[..80])
} else {
s
}
}
fn expr_looks_fallible(expr: &Expr) -> bool {
match expr {
Expr::Try(_) => true,
Expr::MethodCall(mc) => {
let n = mc.method.to_string();
matches!(
n.as_str(),
"unwrap"
| "expect"
| "map_err"
| "and_then"
| "or_else"
| "ok"
| "err"
| "transpose"
| "context"
| "with_context"
) || expr_looks_fallible(&mc.receiver)
}
Expr::Call(c) => {
if let Expr::Path(p) = c.func.as_ref() {
let t = p
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
t.starts_with("try_")
|| t.ends_with("_result")
|| matches!(
t.as_str(),
"read"
| "write"
| "open"
| "create"
| "remove_file"
| "copy"
| "rename"
| "metadata"
| "parse"
| "from_str"
| "connect"
| "send"
| "recv"
| "execute"
| "query"
| "commit"
| "rollback"
| "flush"
| "sync_all"
| "lock"
| "try_lock"
)
} else {
false
}
}
Expr::Await(a) => expr_looks_fallible(&a.base),
Expr::TryBlock(_) => true,
_ => false,
}
}
fn is_termination_method(expr: &Expr) -> bool {
if let Expr::MethodCall(mc) = expr {
matches!(
mc.method.to_string().as_str(),
"unwrap" | "expect" | "unwrap_err"
)
} else {
false
}
}
fn pat_is_err_like(pat: &Pat) -> bool {
match pat {
Pat::TupleStruct(ts) => {
let id = ts
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
id == "Err" || id == "Error"
}
Pat::Ident(i) => {
let n = i.ident.to_string();
n == "err" || n == "e" || n.starts_with("err")
}
Pat::Or(o) => o.cases.iter().any(pat_is_err_like),
_ => false,
}
}
fn is_empty_handler(expr: &Expr) -> bool {
match expr {
Expr::Block(b) => b.block.stmts.is_empty(),
Expr::Tuple(t) => t.elems.is_empty(),
_ => false,
}
}
fn is_logging_only(expr: &Expr) -> bool {
match expr {
Expr::Block(b) => {
if b.block.stmts.is_empty() {
return false;
}
b.block.stmts.iter().all(|s| match s {
Stmt::Expr(e, _) => is_log_expr(e),
Stmt::Macro(m) => {
let n = m
.mac
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
matches!(
n.as_str(),
"error" | "warn" | "info" | "debug" | "trace" | "eprintln" | "println"
)
}
_ => false,
})
}
Expr::Macro(m) => {
let n = m
.mac
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
matches!(n.as_str(), "error" | "warn" | "eprintln" | "println")
}
_ => is_log_expr(expr),
}
}
fn is_log_expr(expr: &Expr) -> bool {
match expr {
Expr::Macro(m) => {
let n = m
.mac
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
matches!(
n.as_str(),
"error" | "warn" | "info" | "debug" | "trace" | "eprintln" | "println"
)
}
Expr::Call(c) => {
if let Expr::Path(p) = c.func.as_ref() {
let full = p
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
full.contains("log") || full.contains("tracing")
} else {
false
}
}
_ => false,
}
}
fn is_ok_unit(expr: &Expr) -> bool {
match expr {
Expr::Call(c) => {
if let Expr::Path(p) = c.func.as_ref() {
let tail = p
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default();
tail == "Ok"
&& c.args.len() == 1
&& matches!(c.args.first(), Some(Expr::Tuple(t)) if t.elems.is_empty())
} else {
false
}
}
Expr::Block(b) if b.block.stmts.len() == 1 => {
if let Stmt::Expr(e, _) = &b.block.stmts[0] {
is_ok_unit(e)
} else {
false
}
}
_ => false,
}
}
fn is_context_dropping_closure(cl: &ExprClosure) -> bool {
if cl.inputs.len() != 1 {
return false;
}
if !matches!(&cl.inputs[0], Pat::Wild(_)) {
return false;
}
match cl.body.as_ref() {
Expr::Lit(_) => true,
Expr::Tuple(t) if t.elems.is_empty() => true,
Expr::Path(_) => true,
_ => false,
}
}
fn loop_looks_like_retry(node: &ExprLoop) -> bool {
let s = quote::quote!(#node).to_string().to_ascii_lowercase();
s.contains("retry")
|| s.contains("again")
|| s.contains("sleep")
|| s.contains("backoff")
|| (s.contains("continue") && (s.contains("err") || s.contains("error")))
}
fn loop_has_bound(node: &ExprLoop) -> bool {
let s = quote::quote!(#node).to_string().to_ascii_lowercase();
s.contains("max_")
|| s.contains("attempts")
|| s.contains("retries")
|| s.contains("budget")
|| (s.contains("break")
&& (s.contains("count") || s.contains("attempt") || s.contains("n ") || s.contains("i ")))
}
fn for_looks_like_retry(node: &ExprForLoop) -> bool {
let s = quote::quote!(#node).to_string().to_ascii_lowercase();
s.contains("retry") || s.contains("attempt")
}
fn extract_cleanup_concepts(source: &SourceFile) -> Vec<ConceptInstance> {
let mut out = Vec::new();
for (idx, line) in source.content.lines().enumerate() {
let l = line.to_ascii_lowercase();
if (l.contains("unlock")
|| l.contains(".close()")
|| l.contains("rollback")
|| l.contains("drop("))
&& (l.contains("unwrap") || l.contains("expect") || l.contains("let _ ="))
{
out.push(ConceptInstance::new(
ConceptKind::ResourceCleanup,
SourceLocation::single_line(&source.path, (idx + 1) as u32, 1),
"cleanup_may_fail",
line.trim().to_string(),
));
}
if (l.contains("quorum")
|| l.contains("replicate")
|| l.contains("partial")
|| l.contains("commit()")
|| l.contains("cache.put")
|| l.contains("write_all"))
&& (l.contains("let _ =") || l.contains("ok();") || l.contains("unwrap_or"))
{
out.push(ConceptInstance::new(
ConceptKind::PartialCommit,
SourceLocation::single_line(&source.path, (idx + 1) as u32, 1),
"partial_commit_ignore",
line.trim().to_string(),
));
}
}
out
}