use crate::code_lint::ast::tree::{AstNode, ParsedFile, span_from_ruff_range};
use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, walk_expr};
use ruff_python_ast::{Expr, ExprCall};
use ruff_text_size::{Ranged as _, TextRange};
use std::collections::HashSet;
const LOGGER_RECEIVERS: &[&str] = &["logging", "logger", "log", "_logger", "_log"];
const LOGGER_ATTRIBUTES: &[&str] = &["logger", "log", "_logger", "_log"];
const PRINTF_LOGGER_METHODS: &[&str] = &[
"debug",
"info",
"warning",
"warn",
"error",
"critical",
"fatal",
"exception",
"log",
];
const LOGURU_ONLY_METHODS: &[&str] = &["trace", "success"];
pub struct PythonLoggerCall<'a> {
pub node: AstNode<'a>,
pub callee: String,
pub message: Option<String>,
pub has_trailing_positional_args: bool,
pub has_keyword_splat: bool,
pub keyword_names: HashSet<String>,
pub(super) uses_printf: bool,
pub(super) message_range: TextRange,
}
#[must_use]
pub fn collect_logger_calls(file: &ParsedFile) -> Vec<PythonLoggerCall<'_>> {
struct LoggerCallVisitor<'a> {
file: &'a ParsedFile,
out: Vec<PythonLoggerCall<'a>>,
}
impl<'a> SourceOrderVisitor<'a> for LoggerCallVisitor<'a> {
fn visit_expr(&mut self, expr: &'a Expr) {
if let Expr::Call(call) = expr
&& let Some(logger_call) = extract_logger_call(call, self.file)
{
self.out.push(logger_call);
}
walk_expr(self, expr);
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let mut visitor = LoggerCallVisitor {
file,
out: Vec::new(),
};
visitor.visit_body(&parsed.syntax().body);
visitor.out
}
pub(super) fn extract_logger_call<'a>(
call: &ExprCall,
file: &'a ParsedFile,
) -> Option<PythonLoggerCall<'a>> {
let Expr::Attribute(function) = call.func.as_ref() else {
return None;
};
if !is_logger_receiver(&function.value) {
return None;
}
let method = function.attr.as_str();
let uses_printf = if PRINTF_LOGGER_METHODS.contains(&method) {
true
} else if LOGURU_ONLY_METHODS.contains(&method) {
false
} else {
return None;
};
let mut keyword_names = HashSet::new();
let mut has_keyword_splat = false;
for keyword in &call.arguments.keywords {
if let Some(arg_name) = &keyword.arg {
keyword_names.insert(arg_name.id.to_string());
} else {
has_keyword_splat = true;
}
}
let message_index = usize::from(method == "log");
let message_expr = call.arguments.args.get(message_index)?;
if matches!(message_expr, Expr::Starred(_)) {
return None;
}
let func_span = span_from_ruff_range(function.range());
Some(PythonLoggerCall {
node: AstNode::from_span(file, span_from_ruff_range(call.range())),
callee: file.source[func_span.start..func_span.end].to_owned(),
message: static_message_text(message_expr),
has_trailing_positional_args: call.arguments.args.len() > message_index + 1,
has_keyword_splat,
keyword_names,
uses_printf,
message_range: message_expr.range(),
})
}
fn static_message_text(expr: &Expr) -> Option<String> {
let Expr::StringLiteral(string_literal) = expr else {
return None;
};
let text = string_literal.value.to_str();
(!text.is_empty()).then(|| text.to_owned())
}
fn is_logger_receiver(receiver: &Expr) -> bool {
match receiver {
Expr::Name(name) => LOGGER_RECEIVERS.contains(&name.id.as_str()),
Expr::Attribute(attribute) => LOGGER_ATTRIBUTES.contains(&attribute.attr.as_str()),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::Language;
#[test]
fn test_collect_logger_calls_reads_callee_message_and_arguments() {
let source = indoc::indoc! {r#"
logger.info("Order {order_id} filled", order_id)
self.log.error("Peer " "{peer.id} failed", **extra)
logging.log(20, f"User {user_id}", user_id=1)
print("not a logger", value)
"#};
let file = ParsedFile::new(source, Language::Python);
let calls: Vec<_> = collect_logger_calls(&file)
.into_iter()
.map(|call| {
let mut keyword_names: Vec<String> = call.keyword_names.into_iter().collect();
keyword_names.sort();
(
call.callee,
call.message,
call.has_trailing_positional_args,
call.has_keyword_splat,
keyword_names,
)
})
.collect();
assert_eq!(
calls,
vec![
(
"logger.info".to_owned(),
Some("Order {order_id} filled".to_owned()),
true,
false,
vec![],
),
(
"self.log.error".to_owned(),
Some("Peer {peer.id} failed".to_owned()),
false,
true,
vec![],
),
(
"logging.log".to_owned(),
None,
false,
false,
vec!["user_id".to_owned()]
),
]
);
}
}