use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolKind};
use ryo_source::pure::{PureBlock, PureExpr, PureImplItem, PureItem, PureStmt, PureType};
use super::SafetySuggest;
use crate::{
LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory,
SuggestLocation, SuggestOpportunity, SuggestResult, SymbolScope,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReceiverTypeHint {
LikelyOption,
LikelyResult,
KnownNonOptionResult,
Unknown,
}
pub struct UnwrapToExpect {
min_confidence: f32,
}
impl UnwrapToExpect {
pub fn new() -> Self {
Self {
min_confidence: 0.5,
}
}
pub fn with_min_confidence(mut self, threshold: f32) -> Self {
self.min_confidence = threshold.clamp(0.0, 1.0);
self
}
fn returns_option_or_result(ret: &Option<PureType>) -> bool {
match ret {
Some(PureType::Path(path)) => {
path.starts_with("Option<")
|| path.starts_with("Result<")
|| path == "Option"
|| path == "Result"
}
_ => false,
}
}
fn infer_receiver_type_hint(receiver: &PureExpr) -> ReceiverTypeHint {
match receiver {
PureExpr::MethodCall { method, .. } => Self::classify_method_name(method),
PureExpr::Call { func, .. } => {
if let PureExpr::Path(name) = func.as_ref() {
match name.as_str() {
"Some" | "Ok" | "Err" => ReceiverTypeHint::LikelyOption,
_ => ReceiverTypeHint::Unknown,
}
} else {
ReceiverTypeHint::Unknown
}
}
PureExpr::Path(_) | PureExpr::Field { .. } => ReceiverTypeHint::Unknown,
_ => ReceiverTypeHint::Unknown,
}
}
fn classify_method_name(method: &str) -> ReceiverTypeHint {
const OPTION_METHODS: &[&str] = &[
"get",
"get_mut",
"first",
"first_mut",
"last",
"last_mut",
"find",
"find_map",
"position",
"rposition",
"next",
"next_back",
"peek",
"pop",
"pop_front",
"pop_back",
"front",
"front_mut",
"back",
"back_mut",
"remove", "strip_prefix",
"strip_suffix",
"checked_add",
"checked_sub",
"checked_mul",
"checked_div",
"checked_rem",
"checked_neg",
"checked_shl",
"checked_shr",
"parent",
"file_name",
"file_stem",
"extension",
"to_str",
"as_ref",
"as_mut",
"as_deref",
"as_deref_mut",
"take",
"replace",
"map",
"and_then",
"or",
"or_else",
"filter",
"zip",
"ok",
"err",
"min",
"max", ];
const RESULT_METHODS: &[&str] = &[
"parse",
"try_into",
"try_from",
"read",
"read_to_string",
"read_to_end",
"read_line",
"read_exact",
"write",
"write_all",
"write_fmt",
"flush",
"lock",
"try_lock",
"try_read", "send",
"try_send",
"recv",
"try_recv",
"recv_timeout",
"connect",
"bind",
"accept",
"open",
"create",
"join",
];
const NON_OPTION_RESULT_METHODS: &[&str] = &[
"entry",
"or_insert",
"or_insert_with",
"or_default",
"iter",
"iter_mut",
"into_iter",
"keys",
"values",
"values_mut",
"enumerate",
"chain",
"filter_map",
"flat_map",
"flatten",
"skip",
"skip_while",
"take_while",
"collect",
"fold",
"for_each",
"clone",
"clone_from",
"to_string",
"to_owned",
"into",
"from",
"as_str",
"as_bytes",
"as_slice",
"as_mut_slice",
"len",
"is_empty",
"capacity",
"contains",
"contains_key",
"sort",
"sort_by",
"sort_by_key",
"reverse",
"dedup",
"display",
"fmt",
];
if OPTION_METHODS.contains(&method) {
return ReceiverTypeHint::LikelyOption;
}
if RESULT_METHODS.contains(&method) {
return ReceiverTypeHint::LikelyResult;
}
if NON_OPTION_RESULT_METHODS.contains(&method) {
return ReceiverTypeHint::KnownNonOptionResult;
}
ReceiverTypeHint::Unknown
}
fn find_unwrap_calls(&self, block: &PureBlock) -> Vec<UnwrapCallInfo> {
let mut calls = Vec::new();
for stmt in &block.stmts {
self.find_unwrap_in_stmt(stmt, &mut calls);
}
calls
}
fn find_unwrap_in_stmt(&self, stmt: &PureStmt, calls: &mut Vec<UnwrapCallInfo>) {
match stmt {
PureStmt::Local { init: Some(e), .. } => self.find_unwrap_in_expr(e, calls),
PureStmt::Semi(e) | PureStmt::Expr(e) => self.find_unwrap_in_expr(e, calls),
_ => {}
}
}
fn find_unwrap_in_expr(&self, expr: &PureExpr, calls: &mut Vec<UnwrapCallInfo>) {
if let PureExpr::MethodCall {
receiver,
method,
args,
..
} = expr
{
if method == "unwrap" && args.is_empty() {
let type_hint = Self::infer_receiver_type_hint(receiver);
if type_hint == ReceiverTypeHint::KnownNonOptionResult {
} else {
let context = self.extract_context(receiver);
let message = self.generate_message(&context, type_hint);
calls.push(UnwrapCallInfo {
context,
type_hint,
suggested_message: message,
confidence: self.calculate_confidence(receiver, type_hint),
});
}
}
}
match expr {
PureExpr::Binary { left, right, .. } => {
self.find_unwrap_in_expr(left, calls);
self.find_unwrap_in_expr(right, calls);
}
PureExpr::Unary { expr: inner, .. } => {
self.find_unwrap_in_expr(inner, calls);
}
PureExpr::Call { func, args } => {
self.find_unwrap_in_expr(func, calls);
for arg in args {
self.find_unwrap_in_expr(arg, calls);
}
}
PureExpr::MethodCall { receiver, args, .. } => {
self.find_unwrap_in_expr(receiver, calls);
for arg in args {
self.find_unwrap_in_expr(arg, calls);
}
}
PureExpr::Field { expr: inner, .. } => {
self.find_unwrap_in_expr(inner, calls);
}
PureExpr::Index { expr: inner, index } => {
self.find_unwrap_in_expr(inner, calls);
self.find_unwrap_in_expr(index, calls);
}
PureExpr::Block { block, .. } => {
for stmt in &block.stmts {
self.find_unwrap_in_stmt(stmt, calls);
}
}
PureExpr::If {
cond,
then_branch,
else_branch,
} => {
self.find_unwrap_in_expr(cond, calls);
for stmt in &then_branch.stmts {
self.find_unwrap_in_stmt(stmt, calls);
}
if let Some(else_expr) = else_branch {
self.find_unwrap_in_expr(else_expr, calls);
}
}
PureExpr::Match { expr: e, arms } => {
self.find_unwrap_in_expr(e, calls);
for arm in arms {
self.find_unwrap_in_expr(&arm.body, calls);
}
}
PureExpr::Loop { body: block, .. } | PureExpr::While { body: block, .. } => {
for stmt in &block.stmts {
self.find_unwrap_in_stmt(stmt, calls);
}
}
PureExpr::For {
expr: iter_expr,
body,
..
} => {
self.find_unwrap_in_expr(iter_expr, calls);
for stmt in &body.stmts {
self.find_unwrap_in_stmt(stmt, calls);
}
}
PureExpr::Closure { body, .. } => {
self.find_unwrap_in_expr(body, calls);
}
PureExpr::Tuple(exprs) | PureExpr::Array(exprs) => {
for e in exprs {
self.find_unwrap_in_expr(e, calls);
}
}
PureExpr::Struct { fields, .. } => {
for (_, e) in fields {
self.find_unwrap_in_expr(e, calls);
}
}
PureExpr::Ref { expr: inner, .. } => {
self.find_unwrap_in_expr(inner, calls);
}
PureExpr::Return(Some(inner)) => {
self.find_unwrap_in_expr(inner, calls);
}
PureExpr::Try(inner) => {
self.find_unwrap_in_expr(inner, calls);
}
PureExpr::Await(inner) => {
self.find_unwrap_in_expr(inner, calls);
}
_ => {}
}
}
fn extract_context(&self, receiver: &PureExpr) -> UnwrapContext {
match receiver {
PureExpr::Path(name) => UnwrapContext::Variable(name.clone()),
PureExpr::Field { expr, field } => {
let base = self.extract_base_name(expr);
UnwrapContext::Field {
base,
field: field.clone(),
}
}
PureExpr::MethodCall { method, .. } => UnwrapContext::MethodCall(method.clone()),
PureExpr::Call { func, .. } => {
let func_name = self.extract_func_name(func);
UnwrapContext::FunctionCall(func_name)
}
_ => UnwrapContext::Unknown,
}
}
fn extract_base_name(&self, expr: &PureExpr) -> String {
match expr {
PureExpr::Path(name) => name.clone(),
PureExpr::Field { expr, field } => {
format!("{}.{}", self.extract_base_name(expr), field)
}
_ => "value".to_string(),
}
}
fn extract_func_name(&self, expr: &PureExpr) -> String {
match expr {
PureExpr::Path(name) => name.clone(),
_ => "function".to_string(),
}
}
fn generate_message(&self, context: &UnwrapContext, type_hint: ReceiverTypeHint) -> String {
let base_msg = match context {
UnwrapContext::Variable(name) => format!("{} should be Some/Ok", name),
UnwrapContext::Field { base, field } => {
format!("{}.{} should be initialized", base, field)
}
UnwrapContext::MethodCall(method) => match type_hint {
ReceiverTypeHint::LikelyOption => {
format!("{}() should return Some", method)
}
ReceiverTypeHint::LikelyResult => {
format!("{}() should succeed", method)
}
_ => format!("{}() should return Some/Ok", method),
},
UnwrapContext::FunctionCall(func) => format!("{}() should return Some/Ok", func),
UnwrapContext::Unknown => "value should be Some/Ok".to_string(),
};
base_msg
}
fn calculate_confidence(&self, receiver: &PureExpr, type_hint: ReceiverTypeHint) -> f32 {
let base = match receiver {
PureExpr::Path(_) => 0.8,
PureExpr::Field { .. } => 0.75,
PureExpr::MethodCall { .. } => 0.7,
PureExpr::Call { .. } => 0.7,
_ => 0.5,
};
match type_hint {
ReceiverTypeHint::LikelyOption | ReceiverTypeHint::LikelyResult => {
(base + 0.1_f32).min(1.0)
}
ReceiverTypeHint::Unknown => base,
ReceiverTypeHint::KnownNonOptionResult => 0.0,
}
}
fn format_suggestion(call: &UnwrapCallInfo) -> String {
let action = match call.type_hint {
ReceiverTypeHint::LikelyOption => {
format!(
"Replace `.unwrap()` with `.expect(\"{}\")`, or handle None with match/if-let",
call.suggested_message,
)
}
ReceiverTypeHint::LikelyResult => {
format!(
"Replace `.unwrap()` with `.expect(\"{}\")`, or propagate error with `?`",
call.suggested_message,
)
}
_ => {
format!(
"Replace `.unwrap()` with `.expect(\"{}\")`",
call.suggested_message,
)
}
};
let caveat = match call.type_hint {
ReceiverTypeHint::LikelyOption | ReceiverTypeHint::LikelyResult => String::new(),
ReceiverTypeHint::Unknown => {
"\n\n[Type constraint] RS101 は AST 上の式レベル型情報を持たないため、\
receiver が Option/Result であることを確認できていません。\
もし receiver が Option/Result 以外の型(例: カスタム型の .unwrap() メソッド)\
であれば、この検知は誤検知です。その場合はこの検知を Ignore に設定してください。"
.to_string()
}
ReceiverTypeHint::KnownNonOptionResult => String::new(),
};
format!("{action}{caveat}")
}
}
impl Default for UnwrapToExpect {
fn default() -> Self {
Self::new()
}
}
impl SafetySuggest for UnwrapToExpect {
fn code(&self) -> &'static str {
"RS101"
}
fn default_severity(&self) -> LintSeverity {
LintSeverity::Warning
}
}
impl Suggest for UnwrapToExpect {
fn name(&self) -> &'static str {
"unwrap-to-expect"
}
fn description(&self) -> &str {
"Converts unwrap() to expect() with descriptive error message for better debugging"
}
fn category(&self) -> SuggestCategory {
SuggestCategory::Safety
}
fn safety_level(&self) -> SafetyLevel {
SafetyLevel::Confirm }
fn priority_weight(&self) -> f32 {
1.0
}
fn target_scopes(&self) -> Vec<SymbolScope> {
vec![SymbolScope::Lib, SymbolScope::Bin]
}
fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
let mut opportunities = Vec::new();
let mut next_id = 0u32;
let fn_symbols: Vec<SymbolId> = if symbols.is_empty() {
ctx.registry.iter_by_kind(SymbolKind::Function).collect()
} else {
symbols
.iter()
.copied()
.filter(|id| ctx.registry.kind(*id) == Some(SymbolKind::Function))
.collect()
};
for symbol_id in &fn_symbols {
if let Some(PureItem::Fn(f)) = ctx.ast_registry.get(*symbol_id) {
if Self::returns_option_or_result(&f.ret) {
continue;
}
let unwrap_calls = self.find_unwrap_calls(&f.body);
for call in unwrap_calls {
if call.confidence < self.min_confidence {
continue;
}
let Some(location) = SuggestLocation::from_context(ctx, *symbol_id) else {
continue;
};
let message = format!("unwrap() without descriptive message in `{}`", f.name);
let suggestion = Self::format_suggestion(&call);
let opp = self.create_safety_opportunity(
OpportunityId::new(next_id),
vec![*symbol_id],
location,
message,
suggestion,
call.confidence,
);
opportunities.push(opp);
next_id += 1;
}
}
}
let impl_symbols: Vec<SymbolId> = ctx.registry.iter_by_kind(SymbolKind::Impl).collect();
for impl_id in impl_symbols {
if let Some(PureItem::Impl(imp)) = ctx.ast_registry.get(impl_id) {
for impl_item in &imp.items {
if let PureImplItem::Fn(f) = impl_item {
if Self::returns_option_or_result(&f.ret) {
continue;
}
let unwrap_calls = self.find_unwrap_calls(&f.body);
for call in unwrap_calls {
if call.confidence < self.min_confidence {
continue;
}
let Some(location) = SuggestLocation::from_context(ctx, impl_id) else {
continue;
};
let message = format!(
"unwrap() without descriptive message in `{}::{}`",
imp.self_ty, f.name
);
let suggestion = Self::format_suggestion(&call);
let opp = self.create_safety_opportunity(
OpportunityId::new(next_id),
vec![impl_id],
location,
message,
suggestion,
call.confidence,
);
opportunities.push(opp);
next_id += 1;
}
}
}
}
}
opportunities
}
fn to_mutation_specs(
&self,
_ctx: &AnalysisContext,
_opportunity: &SuggestOpportunity,
) -> SuggestResult<Vec<MutationSpec>> {
Ok(Vec::new())
}
}
#[derive(Debug, Clone)]
enum UnwrapContext {
Variable(String),
Field { base: String, field: String },
MethodCall(String),
FunctionCall(String),
Unknown,
}
struct UnwrapCallInfo {
#[allow(dead_code)]
context: UnwrapContext,
type_hint: ReceiverTypeHint,
suggested_message: String,
confidence: f32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rule_metadata() {
let rule = UnwrapToExpect::new();
assert_eq!(rule.code(), "RS101");
assert_eq!(rule.name(), "unwrap-to-expect");
assert_eq!(rule.category(), SuggestCategory::Safety);
assert_eq!(rule.safety_level(), SafetyLevel::Confirm);
}
#[test]
fn test_confidence_threshold() {
let rule = UnwrapToExpect::new().with_min_confidence(0.8);
assert!((rule.min_confidence - 0.8).abs() < f32::EPSILON);
let rule = UnwrapToExpect::new().with_min_confidence(1.5);
assert!((rule.min_confidence - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_returns_option_or_result() {
assert!(UnwrapToExpect::returns_option_or_result(&Some(
PureType::Path("Option<i32>".to_string())
)));
assert!(UnwrapToExpect::returns_option_or_result(&Some(
PureType::Path("Result<String, Error>".to_string())
)));
assert!(!UnwrapToExpect::returns_option_or_result(&Some(
PureType::Path("i32".to_string())
)));
assert!(!UnwrapToExpect::returns_option_or_result(&None));
}
#[test]
fn test_classify_known_option_methods() {
let option_methods = [
"get",
"find",
"first",
"last",
"next",
"pop",
"peek",
"checked_add",
"parent",
"file_name",
"as_ref",
"take",
"ok",
"err",
];
for method in option_methods {
assert_eq!(
UnwrapToExpect::classify_method_name(method),
ReceiverTypeHint::LikelyOption,
"expected LikelyOption for method: {method}"
);
}
}
#[test]
fn test_classify_known_result_methods() {
let result_methods = [
"parse",
"lock",
"try_lock",
"read_to_string",
"write_all",
"send",
"recv",
"connect",
"open",
"join",
];
for method in result_methods {
assert_eq!(
UnwrapToExpect::classify_method_name(method),
ReceiverTypeHint::LikelyResult,
"expected LikelyResult for method: {method}"
);
}
}
#[test]
fn test_classify_known_non_option_result_methods() {
let safe_methods = [
"entry",
"iter",
"into_iter",
"clone",
"to_string",
"to_owned",
"len",
"is_empty",
"contains",
"sort",
"collect",
];
for method in safe_methods {
assert_eq!(
UnwrapToExpect::classify_method_name(method),
ReceiverTypeHint::KnownNonOptionResult,
"expected KnownNonOptionResult for method: {method}"
);
}
}
#[test]
fn test_classify_unknown_methods() {
let unknown_methods = ["custom_method", "do_something", "process"];
for method in unknown_methods {
assert_eq!(
UnwrapToExpect::classify_method_name(method),
ReceiverTypeHint::Unknown,
"expected Unknown for method: {method}"
);
}
}
#[test]
fn test_infer_variable_receiver_is_unknown() {
let expr = PureExpr::Path("config".to_string());
assert_eq!(
UnwrapToExpect::infer_receiver_type_hint(&expr),
ReceiverTypeHint::Unknown,
);
}
#[test]
fn test_infer_field_receiver_is_unknown() {
let expr = PureExpr::Field {
expr: Box::new(PureExpr::Path("self".to_string())),
field: "data".to_string(),
};
assert_eq!(
UnwrapToExpect::infer_receiver_type_hint(&expr),
ReceiverTypeHint::Unknown,
);
}
#[test]
fn test_infer_method_call_get_is_option() {
let expr = PureExpr::MethodCall {
receiver: Box::new(PureExpr::Path("map".to_string())),
method: "get".to_string(),
turbofish: None,
args: vec![PureExpr::Path("key".to_string())],
};
assert_eq!(
UnwrapToExpect::infer_receiver_type_hint(&expr),
ReceiverTypeHint::LikelyOption,
);
}
#[test]
fn test_infer_method_call_entry_is_non_option() {
let expr = PureExpr::MethodCall {
receiver: Box::new(PureExpr::Path("map".to_string())),
method: "entry".to_string(),
turbofish: None,
args: vec![PureExpr::Path("key".to_string())],
};
assert_eq!(
UnwrapToExpect::infer_receiver_type_hint(&expr),
ReceiverTypeHint::KnownNonOptionResult,
);
}
#[test]
fn test_infer_method_call_lock_is_result() {
let expr = PureExpr::MethodCall {
receiver: Box::new(PureExpr::Path("mutex".to_string())),
method: "lock".to_string(),
turbofish: None,
args: vec![],
};
assert_eq!(
UnwrapToExpect::infer_receiver_type_hint(&expr),
ReceiverTypeHint::LikelyResult,
);
}
#[test]
fn test_confidence_higher_for_known_types() {
let rule = UnwrapToExpect::new();
let option_receiver = PureExpr::MethodCall {
receiver: Box::new(PureExpr::Path("map".to_string())),
method: "get".to_string(),
turbofish: None,
args: vec![],
};
let option_conf =
rule.calculate_confidence(&option_receiver, ReceiverTypeHint::LikelyOption);
let unknown_receiver = PureExpr::MethodCall {
receiver: Box::new(PureExpr::Path("x".to_string())),
method: "custom".to_string(),
turbofish: None,
args: vec![],
};
let unknown_conf = rule.calculate_confidence(&unknown_receiver, ReceiverTypeHint::Unknown);
assert!(
option_conf > unknown_conf,
"known Option confidence ({option_conf}) should be > unknown ({unknown_conf})"
);
}
#[test]
fn test_generate_message_with_type_hint() {
let rule = UnwrapToExpect::new();
let msg_opt = rule.generate_message(
&UnwrapContext::MethodCall("get".to_string()),
ReceiverTypeHint::LikelyOption,
);
assert!(msg_opt.contains("return Some"), "Option hint: {msg_opt}");
let msg_res = rule.generate_message(
&UnwrapContext::MethodCall("lock".to_string()),
ReceiverTypeHint::LikelyResult,
);
assert!(msg_res.contains("succeed"), "Result hint: {msg_res}");
}
#[test]
fn test_format_suggestion_includes_caveat_for_unknown() {
let call = UnwrapCallInfo {
context: UnwrapContext::Variable("x".to_string()),
type_hint: ReceiverTypeHint::Unknown,
suggested_message: "x should be Some/Ok".to_string(),
confidence: 0.8,
};
let suggestion = UnwrapToExpect::format_suggestion(&call);
assert!(
suggestion.contains("Ignore"),
"Unknown type should mention Ignore: {suggestion}"
);
assert!(
suggestion.contains("RS101"),
"Should reference rule code: {suggestion}"
);
}
#[test]
fn test_format_suggestion_no_caveat_for_known_option() {
let call = UnwrapCallInfo {
context: UnwrapContext::MethodCall("get".to_string()),
type_hint: ReceiverTypeHint::LikelyOption,
suggested_message: "get() should return Some".to_string(),
confidence: 0.9,
};
let suggestion = UnwrapToExpect::format_suggestion(&call);
assert!(
!suggestion.contains("Ignore"),
"Known Option should NOT mention Ignore: {suggestion}"
);
assert!(
suggestion.contains("match/if-let"),
"Option suggestion should mention alternatives: {suggestion}"
);
}
#[test]
fn test_format_suggestion_no_caveat_for_known_result() {
let call = UnwrapCallInfo {
context: UnwrapContext::MethodCall("lock".to_string()),
type_hint: ReceiverTypeHint::LikelyResult,
suggested_message: "lock() should succeed".to_string(),
confidence: 0.9,
};
let suggestion = UnwrapToExpect::format_suggestion(&call);
assert!(
!suggestion.contains("Ignore"),
"Known Result should NOT mention Ignore: {suggestion}"
);
assert!(
suggestion.contains("propagate error"),
"Result suggestion should mention ? operator: {suggestion}"
);
}
}