use crate::core::{DebtItem, DebtType, Priority};
use crate::debt::suppression::SuppressionContext;
use std::path::Path;
use syn::visit::Visit;
use syn::{Expr, ExprMethodCall, ExprTry, File, ItemFn};
pub struct ContextLossAnalyzer<'a> {
items: Vec<DebtItem>,
current_file: &'a Path,
suppression: Option<&'a SuppressionContext>,
in_test_function: bool,
question_mark_count: usize,
current_function: Option<usize>,
}
impl<'a> ContextLossAnalyzer<'a> {
pub fn new(file_path: &'a Path, suppression: Option<&'a SuppressionContext>) -> Self {
Self {
items: Vec::new(),
current_file: file_path,
suppression,
in_test_function: false,
question_mark_count: 0,
current_function: None,
}
}
pub fn detect(mut self, file: &File) -> Vec<DebtItem> {
self.visit_file(file);
self.items
}
fn get_line_number(&self, span: proc_macro2::Span) -> usize {
span.start().line
}
fn add_debt_item(&mut self, line: usize, pattern: ContextLossPattern, context: &str) {
let debt_type = DebtType::ErrorSwallowing {
pattern: pattern.to_string(),
context: Some(context.to_string()),
};
if let Some(checker) = self.suppression {
if checker.is_suppressed(line, &debt_type) {
return;
}
}
let priority = self.determine_priority(&pattern);
let message = format!("{}: {}", pattern.description(), pattern.remediation());
self.items.push(DebtItem {
id: format!("context-loss-{}-{}", self.current_file.display(), line),
debt_type,
priority,
file: self.current_file.to_path_buf(),
line,
column: None,
message,
context: Some(context.to_string()),
});
}
fn determine_priority(&self, pattern: &ContextLossPattern) -> Priority {
if self.in_test_function {
return Priority::Low;
}
match pattern {
ContextLossPattern::MapErrDiscardingOriginal => Priority::Medium,
ContextLossPattern::AnyhowWithoutContext => Priority::Medium,
ContextLossPattern::QuestionMarkChain => Priority::Low,
ContextLossPattern::StringErrorConversion => Priority::High,
ContextLossPattern::IntoErrorConversion => Priority::Medium,
}
}
fn check_map_err_patterns(&mut self, method_call: &ExprMethodCall) {
if method_call.method == "map_err" {
let line = self.get_line_number(method_call.method.span());
if let Some(arg) = method_call.args.first() {
let discards_original = match arg {
Expr::Closure(closure) => {
if closure
.inputs
.iter()
.any(|pat| matches!(pat, syn::Pat::Wild(_)))
{
true
} else {
match &*closure.body {
Expr::Lit(_) => true, Expr::Call(call) => {
!format!("{}", quote::quote!(#call)).contains("e")
}
Expr::Path(_) => true, Expr::Macro(mac) => {
let tokens = format!("{}", quote::quote!(#mac));
tokens.contains("format") || !tokens.contains("e")
}
_ => false,
}
}
}
_ => false,
};
if discards_original {
self.add_debt_item(
line,
ContextLossPattern::MapErrDiscardingOriginal,
"map_err discards original error context",
);
}
}
}
}
fn check_context_methods(&mut self, method_call: &ExprMethodCall) {
let method_name = method_call.method.to_string();
if method_name == "with_context" || method_name == "context" {
return;
}
if method_name == "into" {
if !Self::receiver_looks_like_error(&method_call.receiver) {
return;
}
let line = self.get_line_number(method_call.method.span());
self.add_debt_item(
line,
ContextLossPattern::IntoErrorConversion,
"into() conversion may lose error context",
);
}
}
fn check_string_conversions(&mut self, method_call: &ExprMethodCall) {
let method_name = method_call.method.to_string();
if method_name == "to_string" || method_name == "to_owned" {
if !Self::receiver_looks_like_error(&method_call.receiver) {
return;
}
let line = self.get_line_number(method_call.method.span());
self.add_debt_item(
line,
ContextLossPattern::StringErrorConversion,
"Converting error to string loses type information",
);
}
}
fn receiver_looks_like_error(receiver: &Expr) -> bool {
match receiver {
Expr::Path(path) => {
if let Some(ident) = path.path.get_ident() {
let name = ident.to_string().to_lowercase();
matches!(name.as_str(), "err" | "error" | "e")
} else {
false
}
}
Expr::MethodCall(inner) => {
let method = inner.method.to_string();
matches!(method.as_str(), "unwrap_err" | "expect_err")
}
Expr::Try(_) => false,
_ => false,
}
}
}
impl<'a> Visit<'_> for ContextLossAnalyzer<'a> {
fn visit_item_fn(&mut self, node: &ItemFn) {
let was_in_test = self.in_test_function;
let prev_count = self.question_mark_count;
let prev_func = self.current_function;
self.in_test_function = node
.attrs
.iter()
.any(|attr| attr.path().get_ident().map(|i| i.to_string()).as_deref() == Some("test"));
self.question_mark_count = 0;
self.current_function = Some(self.get_line_number(node.sig.fn_token.span));
syn::visit::visit_item_fn(self, node);
self.in_test_function = was_in_test;
self.question_mark_count = prev_count;
self.current_function = prev_func;
}
fn visit_expr_method_call(&mut self, node: &ExprMethodCall) {
self.check_map_err_patterns(node);
self.check_context_methods(node);
self.check_string_conversions(node);
syn::visit::visit_expr_method_call(self, node);
}
fn visit_expr_try(&mut self, node: &ExprTry) {
self.question_mark_count += 1;
if self.question_mark_count > 3 {
let line = self.get_line_number(node.question_token.span);
self.add_debt_item(
line,
ContextLossPattern::QuestionMarkChain,
"Long chain of ? operators without context",
);
}
syn::visit::visit_expr_try(self, node);
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContextLossPattern {
MapErrDiscardingOriginal,
AnyhowWithoutContext,
QuestionMarkChain,
StringErrorConversion,
IntoErrorConversion,
}
impl std::fmt::Display for ContextLossPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description())
}
}
impl ContextLossPattern {
fn description(&self) -> &'static str {
match self {
Self::MapErrDiscardingOriginal => "map_err discards original error",
Self::AnyhowWithoutContext => "anyhow error without context",
Self::QuestionMarkChain => "Long ? operator chain",
Self::StringErrorConversion => "Error converted to string",
Self::IntoErrorConversion => "Generic into() error conversion",
}
}
fn remediation(&self) -> &'static str {
match self {
Self::MapErrDiscardingOriginal => "Include original error as source or in message",
Self::AnyhowWithoutContext => "Use .context() or .with_context() to add information",
Self::QuestionMarkChain => "Add context at key points in the error chain",
Self::StringErrorConversion => "Preserve error type or use structured error types",
Self::IntoErrorConversion => "Use explicit error conversion with context preservation",
}
}
}
pub fn analyze_error_context(
file: &File,
file_path: &Path,
suppression: Option<&SuppressionContext>,
) -> Vec<DebtItem> {
let analyzer = ContextLossAnalyzer::new(file_path, suppression);
analyzer.detect(file)
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_str;
#[test]
fn test_map_err_discarding_original() {
let code = r#"
fn example() -> Result<i32, String> {
some_function()
.map_err(|_| "Something went wrong".to_string())
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
assert!(items[0].message.contains("map_err"));
assert!(items[0].message.contains("discards"));
}
#[test]
fn test_string_error_conversion() {
let code = r#"
fn example() {
let err = std::io::Error::new(std::io::ErrorKind::Other, "test");
let msg = err.to_string();
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
assert!(items[0].message.contains("string"));
}
#[test]
fn test_question_mark_chain() {
let code = r#"
fn example() -> Result<i32, Box<dyn std::error::Error>> {
let a = func1()?;
let b = func2()?;
let c = func3()?;
let d = func4()?;
let e = func5()?;
Ok(e)
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
}
#[test]
fn test_into_conversion() {
let code = r#"
fn example() -> Result<(), Box<dyn std::error::Error>> {
let err = std::io::Error::new(std::io::ErrorKind::Other, "test");
Err(err.into())
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
assert!(items[0].message.contains("into()"));
}
#[test]
fn test_good_context_handling() {
let code = r#"
fn example() -> Result<i32, anyhow::Error> {
some_function()
.with_context(|| "Failed to call some_function")?;
Ok(42)
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let _items = analyze_error_context(&file, Path::new("test.rs"), None);
}
#[test]
fn test_map_err_with_proper_context() {
let code = r#"
fn example() -> Result<i32, String> {
some_function()
.map_err(|e| format!("Failed to process: {}", e))
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
}
#[test]
fn test_map_err_closure_with_wildcard() {
let code = r#"
fn example() -> Result<i32, String> {
some_function()
.map_err(|_| "Generic error".to_string())
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
assert!(items[0].message.contains("map_err"));
assert!(items[0].message.contains("discards"));
}
#[test]
fn test_map_err_simple_constructor() {
let code = r#"
fn example() -> Result<i32, MyError> {
some_function()
.map_err(|e| MyError::Simple)
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
assert!(items[0].message.contains("map_err"));
}
#[test]
fn test_into_conversion_detection() {
let code = r#"
fn example() -> Result<(), Box<dyn std::error::Error>> {
let err = std::io::Error::new(std::io::ErrorKind::Other, "test");
result.map_err(|e| e.into())
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
assert!(!items.is_empty());
let into_items: Vec<_> = items
.iter()
.filter(|item| item.message.contains("into()"))
.collect();
assert!(!into_items.is_empty());
}
#[test]
fn test_context_loss_priority_test_function() {
let code = r#"
#[test]
fn test_example() {
some_function()
.map_err(|_| "test error".to_string())
.unwrap();
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
if !items.is_empty() {
assert_eq!(items[0].priority, Priority::Low);
}
}
#[test]
fn test_long_question_mark_chain() {
let code = r#"
fn example() -> Result<i32, Box<dyn std::error::Error>> {
let a = func1()?;
let b = func2()?;
let c = func3()?;
let d = func4()?;
let e = func5()?; // This should trigger the warning
Ok(e)
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
let question_mark_items: Vec<_> = items
.iter()
.filter(|item| item.message.contains("? operator"))
.collect();
assert!(!question_mark_items.is_empty());
}
#[test]
fn test_error_to_owned_conversion() {
let code = r#"
fn example() {
let err = std::io::Error::new(std::io::ErrorKind::Other, "test");
let owned = err.to_owned();
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
let string_conversion_items: Vec<_> = items
.iter()
.filter(|item| item.message.contains("string"))
.collect();
assert!(!string_conversion_items.is_empty());
}
#[test]
fn test_context_loss_pattern_descriptions() {
use ContextLossPattern::*;
assert_eq!(
MapErrDiscardingOriginal.description(),
"map_err discards original error"
);
assert_eq!(
AnyhowWithoutContext.description(),
"anyhow error without context"
);
assert_eq!(QuestionMarkChain.description(), "Long ? operator chain");
assert_eq!(
StringErrorConversion.description(),
"Error converted to string"
);
assert_eq!(
IntoErrorConversion.description(),
"Generic into() error conversion"
);
}
#[test]
fn test_context_loss_pattern_remediations() {
use ContextLossPattern::*;
assert!(MapErrDiscardingOriginal
.remediation()
.contains("Include original error"));
assert!(AnyhowWithoutContext.remediation().contains("context"));
assert!(QuestionMarkChain.remediation().contains("Add context"));
assert!(StringErrorConversion
.remediation()
.contains("Preserve error type"));
assert!(IntoErrorConversion
.remediation()
.contains("explicit error conversion"));
}
#[test]
fn test_suppression_integration() {
use crate::debt::suppression::SuppressionContext;
let code = r#"
fn example() -> Result<i32, String> {
some_function()
.map_err(|_| "Something went wrong".to_string())
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let suppression = SuppressionContext::new();
let items = analyze_error_context(&file, Path::new("test.rs"), Some(&suppression));
assert!(!items.is_empty()); }
#[test]
fn test_path_to_string_lossy_is_not_error_swallowing() {
let code = r#"
fn example(path: &std::path::Path) -> String {
path.to_string_lossy().to_string()
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
let string_conversion_items: Vec<_> = items
.iter()
.filter(|item| item.message.contains("string"))
.collect();
assert!(
string_conversion_items.is_empty(),
"path.to_string_lossy().to_string() should NOT be flagged as error swallowing. Found: {:?}",
string_conversion_items
);
}
#[test]
fn test_display_to_string_is_not_error_swallowing() {
let code = r#"
fn example(name: &str) -> String {
format!("Hello, {}", name).to_string()
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
let string_conversion_items: Vec<_> = items
.iter()
.filter(|item| item.message.contains("string"))
.collect();
assert!(
string_conversion_items.is_empty(),
"Normal to_string() calls should NOT be flagged. Found: {:?}",
string_conversion_items
);
}
#[test]
fn test_into_for_type_conversion_is_not_error_swallowing() {
let code = r#"
fn example(s: &str) -> String {
s.into()
}
"#;
let file = parse_str::<File>(code).expect("Failed to parse test code");
let items = analyze_error_context(&file, Path::new("test.rs"), None);
let into_items: Vec<_> = items
.iter()
.filter(|item| item.message.contains("into()"))
.collect();
assert!(
into_items.is_empty(),
"Normal into() type conversions should NOT be flagged. Found: {:?}",
into_items
);
}
}