architecture_component!(CodeLintAst);
macro_rules! dispatch_lang {
($lang:expr, $func:ident ( $($arg:expr),* $(,)? )) => {
match $lang {
$crate::diagnostic::Language::Python => {
$crate::code_lint::ast::python::$func($($arg),*)
}
$crate::diagnostic::Language::Rust => {
$crate::code_lint::ast::rust::$func($($arg),*)
}
}
};
}
mod imports;
pub mod python;
pub mod rust;
pub mod statements;
pub use imports::{ResolvedName, resolve_name};
use crate::diagnostic::{Language, LineColumn, LineIndex, SourceLocation, SourceSpan};
use ra_ap_syntax::AstNode as _;
use ruff_text_size::Ranged;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnclosingFunction {
pub name: String,
pub is_top_level: bool,
}
pub(in crate::code_lint::ast) fn with_innermost_function(
function: EnclosingFunction,
outer: &[EnclosingFunction],
) -> Arc<[EnclosingFunction]> {
std::iter::once(function)
.chain(outer.iter().cloned())
.collect()
}
#[derive(Clone)]
pub(in crate::code_lint::ast) struct QualifiedName {
pub(in crate::code_lint::ast) path: String,
pub(in crate::code_lint::ast) terminal: String,
}
pub(in crate::code_lint::ast) struct CachedCallCandidate {
span: SourceSpan,
callee: String,
method_name: Option<String>,
receiver_call_callee: Option<String>,
argument_spans: Vec<SourceSpan>,
is_with_context_manager: bool,
is_in_except_clause: bool,
enclosing_functions: Arc<[EnclosingFunction]>,
}
#[derive(Clone)]
pub(in crate::code_lint::ast) struct CachedCallOrderFinding {
span: SourceSpan,
function: String,
caller: String,
}
#[derive(Default)]
pub(in crate::code_lint::ast) struct CachedCallClusterFindings {
uncolocated_helpers: Vec<CachedCallOrderFinding>,
private_before_public: Vec<CachedCallOrderFinding>,
callee_before_caller: Vec<CachedCallOrderFinding>,
}
pub(in crate::code_lint::ast) enum CodeLintAst {
Python(
Result<
ruff_python_parser::Parsed<ruff_python_ast::ModModule>,
ruff_python_parser::ParseError,
>,
),
Rust(ra_ap_syntax::Parse<ra_ap_syntax::ast::SourceFile>),
}
pub struct ParsedFile {
pub(in crate::code_lint::ast) source: String,
pub(in crate::code_lint::ast) line_index: LineIndex,
pub(in crate::code_lint::ast) ast: CodeLintAst,
lang: Language,
pub(in crate::code_lint::ast) comment_spans: OnceLock<Vec<SourceSpan>>,
pub(in crate::code_lint::ast) bindings: OnceLock<Vec<(SourceSpan, BindingKind)>>,
pub(in crate::code_lint::ast) call_candidates: OnceLock<Vec<CachedCallCandidate>>,
pub(in crate::code_lint::ast) rust_inline_test_ranges: OnceLock<Vec<std::ops::Range<usize>>>,
pub(in crate::code_lint::ast) imports: OnceLock<imports::ImportMap>,
pub(in crate::code_lint::ast) locally_mutated_return_functions: OnceLock<HashSet<String>>,
pub(in crate::code_lint::ast) call_cluster_findings: OnceLock<CachedCallClusterFindings>,
}
impl ParsedFile {
#[must_use]
pub fn new(source: &str, lang: Language) -> Self {
let ast = match lang {
Language::Python => CodeLintAst::Python(ruff_python_parser::parse_module(source)),
Language::Rust => CodeLintAst::Rust(ra_ap_syntax::SourceFile::parse(
source,
ra_ap_syntax::Edition::Edition2024,
)),
};
Self {
source: source.to_string(),
line_index: LineIndex::new(source),
ast,
lang,
comment_spans: OnceLock::new(),
bindings: OnceLock::new(),
call_candidates: OnceLock::new(),
rust_inline_test_ranges: OnceLock::new(),
imports: OnceLock::new(),
locally_mutated_return_functions: OnceLock::new(),
call_cluster_findings: OnceLock::new(),
}
}
#[must_use]
pub fn rust(source: &str) -> Self {
Self::new(source, Language::Rust)
}
#[must_use]
pub const fn lang(&self) -> Language {
self.lang
}
#[must_use]
pub fn source_text(&self) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(&self.source)
}
#[must_use]
pub fn has_syntax_error(&self) -> bool {
match &self.ast {
CodeLintAst::Python(Ok(parsed)) => !parsed.errors().is_empty(),
CodeLintAst::Python(Err(_)) => true,
CodeLintAst::Rust(parsed) => !parsed.errors().is_empty(),
}
}
#[must_use]
pub fn is_in_rust_inline_test(&self, offset: usize) -> bool {
self.lang == Language::Rust
&& rust::collect_inline_test_ranges(self)
.iter()
.any(|range| range.contains(&offset))
}
#[must_use]
pub(in crate::code_lint::ast) const fn py_module(
&self,
) -> Option<&ruff_python_parser::Parsed<ruff_python_ast::ModModule>> {
match &self.ast {
CodeLintAst::Python(Ok(parsed)) => Some(parsed),
CodeLintAst::Python(Err(_)) | CodeLintAst::Rust(_) => None,
}
}
#[must_use]
pub(in crate::code_lint::ast) const fn rs_parsed(
&self,
) -> Option<&ra_ap_syntax::Parse<ra_ap_syntax::ast::SourceFile>> {
match &self.ast {
CodeLintAst::Rust(parsed) => Some(parsed),
CodeLintAst::Python(_) => None,
}
}
}
#[derive(Clone, Copy)]
pub struct AstNode<'a> {
pub(in crate::code_lint::ast) file: &'a ParsedFile,
pub(in crate::code_lint::ast) span: SourceSpan,
}
impl<'a> AstNode<'a> {
#[must_use]
pub(in crate::code_lint::ast) const fn from_span(
file: &'a ParsedFile,
span: SourceSpan,
) -> Self {
Self { file, span }
}
#[must_use]
pub fn text(&self) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(&self.file.source[self.span.start..self.span.end])
}
#[must_use]
pub const fn lang(&self) -> Language {
self.file.lang()
}
#[must_use]
pub const fn span(&self) -> SourceSpan {
self.span
}
#[must_use]
pub fn start_line(&self) -> usize {
self.file.line_index.line(self.span.start)
}
#[must_use]
pub fn end_line(&self) -> usize {
self.file.line_index.line(self.span.end)
}
#[must_use]
pub fn is_in_rust_inline_test(&self) -> bool {
self.file.is_in_rust_inline_test(self.span.start)
}
#[must_use]
pub fn to_source_location(&self, path: impl Into<PathBuf>) -> SourceLocation {
SourceLocation::file_span(path, self.span, self.start_coordinate())
}
#[must_use]
pub fn start_coordinate(&self) -> LineColumn {
self.file.line_index.lookup(self.span.start)
}
}
#[must_use]
pub fn collect_comment_nodes(file: &ParsedFile) -> Vec<AstNode<'_>> {
let spans = file.comment_spans.get_or_init(|| match &file.ast {
CodeLintAst::Python(Ok(parsed)) => parsed
.tokens()
.iter()
.filter(|token| token.kind() == ruff_python_ast::token::TokenKind::Comment)
.map(|token| span_from_ruff_range(token.range()))
.collect(),
CodeLintAst::Python(Err(_)) => Vec::new(),
CodeLintAst::Rust(parsed) => parsed
.tree()
.syntax()
.descendants_with_tokens()
.filter_map(|element| {
let token = element.into_token()?;
is_rust_comment_kind(token.kind())
.then(|| span_from_rowan_range(token.text_range()))
})
.collect(),
});
spans
.iter()
.map(|&span| AstNode::from_span(file, span))
.collect()
}
#[must_use]
pub(in crate::code_lint::ast) const fn span_from_ruff_range(
range: ruff_text_size::TextRange,
) -> SourceSpan {
SourceSpan::new(range.start().to_usize(), range.end().to_usize())
}
#[must_use]
pub(in crate::code_lint::ast) fn span_from_rowan_range(
range: ra_ap_syntax::TextRange,
) -> SourceSpan {
SourceSpan::new(range.start().into(), range.end().into())
}
#[must_use]
pub(in crate::code_lint::ast) const fn is_rust_comment_kind(
kind: ra_ap_syntax::SyntaxKind,
) -> bool {
use ra_ap_syntax::SyntaxKind;
matches!(
kind,
SyntaxKind::COMMENT | SyntaxKind::OUTER_DOC_COMMENT | SyntaxKind::INNER_DOC_COMMENT
)
}
#[must_use]
pub fn is_rust_doc_comment(text: &str) -> bool {
(text.starts_with("///") && !text.starts_with("////"))
|| text.starts_with("//!")
|| (text.starts_with("/**") && !text.starts_with("/***") && text != "/**/")
|| text.starts_with("/*!")
}
pub struct AstCallCandidate<'a> {
pub node: AstNode<'a>,
pub callee: String,
pub method_name: Option<String>,
pub receiver_call_callee: Option<String>,
pub arguments: Vec<AstNode<'a>>,
pub is_with_context_manager: bool,
pub is_in_except_clause: bool,
pub enclosing_functions: Arc<[EnclosingFunction]>,
}
#[must_use]
pub fn collect_call_candidates(file: &ParsedFile) -> Vec<AstCallCandidate<'_>> {
let cached = file.call_candidates.get_or_init(|| match file.lang() {
Language::Python => collect_python_call_candidates(file),
Language::Rust => collect_rust_call_candidates(file),
});
cached
.iter()
.map(|entry| AstCallCandidate {
node: AstNode::from_span(file, entry.span),
callee: entry.callee.clone(),
method_name: entry.method_name.clone(),
receiver_call_callee: entry.receiver_call_callee.clone(),
arguments: entry
.argument_spans
.iter()
.map(|&span| AstNode::from_span(file, span))
.collect(),
is_with_context_manager: entry.is_with_context_manager,
is_in_except_clause: entry.is_in_except_clause,
enclosing_functions: Arc::clone(&entry.enclosing_functions),
})
.collect()
}
fn collect_python_call_candidates(file: &ParsedFile) -> Vec<CachedCallCandidate> {
use ruff_python_ast::visitor::source_order::{
SourceOrderVisitor, walk_except_handler, walk_expr, walk_stmt, walk_with_item,
};
use ruff_python_ast::{ExceptHandler, Expr, ExprCall, Stmt, WithItem};
struct CallVisitor<'a> {
file: &'a ParsedFile,
with_item_context_span: Option<SourceSpan>,
in_except_clause: bool,
enclosing_functions: python::EnclosingFunctionTracker,
out: Vec<CachedCallCandidate>,
}
impl<'a> SourceOrderVisitor<'a> for CallVisitor<'a> {
fn visit_stmt(&mut self, statement: &'a Stmt) {
let enclosing = self.in_except_clause;
if matches!(statement, Stmt::FunctionDef(_) | Stmt::ClassDef(_)) {
self.in_except_clause = false;
}
let outer_functions = self.enclosing_functions.enter(statement);
walk_stmt(self, statement);
self.enclosing_functions.exit(outer_functions);
self.in_except_clause = enclosing;
}
fn visit_expr(&mut self, expr: &'a Expr) {
let enclosing = self.in_except_clause;
match expr {
Expr::Call(call) => self.record_call(call),
Expr::Lambda(_) => self.in_except_clause = false,
_ => {}
}
walk_expr(self, expr);
self.in_except_clause = enclosing;
}
fn visit_with_item(&mut self, with_item: &'a WithItem) {
let enclosing = self.with_item_context_span;
self.with_item_context_span =
Some(span_from_ruff_range(with_item.context_expr.range()));
walk_with_item(self, with_item);
self.with_item_context_span = enclosing;
}
fn visit_except_handler(&mut self, except_handler: &'a ExceptHandler) {
let enclosing = self.in_except_clause;
self.in_except_clause = true;
walk_except_handler(self, except_handler);
self.in_except_clause = enclosing;
}
}
impl CallVisitor<'_> {
fn record_call(&mut self, call: &ExprCall) {
let span = span_from_ruff_range(call.range());
let func_span = span_from_ruff_range(call.func.range());
let callee = self.file.source[func_span.start..func_span.end].to_string();
let (method_name, receiver_call_callee) =
if let Expr::Attribute(attr) = call.func.as_ref() {
let receiver_callee = if let Expr::Call(inner_call) = attr.value.as_ref() {
let inner_span = span_from_ruff_range(inner_call.func.range());
Some(self.file.source[inner_span.start..inner_span.end].to_string())
} else {
None
};
(Some(attr.attr.to_string()), receiver_callee)
} else {
(None, None)
};
let mut argument_spans: Vec<SourceSpan> = call
.arguments
.args
.iter()
.map(|arg| span_from_ruff_range(arg.range()))
.chain(
call.arguments
.keywords
.iter()
.map(|kw| span_from_ruff_range(kw.range())),
)
.collect();
argument_spans.sort_by_key(|span| (span.start, span.end));
self.out.push(CachedCallCandidate {
span,
callee,
method_name,
receiver_call_callee,
argument_spans,
is_with_context_manager: self.with_item_context_span == Some(span),
is_in_except_clause: self.in_except_clause,
enclosing_functions: Arc::clone(&self.enclosing_functions.functions),
});
}
}
let Some(parsed) = file.py_module() else {
return Vec::new();
};
let mut visitor = CallVisitor {
file,
with_item_context_span: None,
in_except_clause: false,
enclosing_functions: python::EnclosingFunctionTracker::default(),
out: Vec::new(),
};
visitor.visit_body(&parsed.syntax().body);
visitor.out
}
fn collect_rust_call_candidates(file: &ParsedFile) -> Vec<CachedCallCandidate> {
use ra_ap_syntax::ast::{self, HasArgList as _};
let Some(parsed) = file.rs_parsed() else {
return Vec::new();
};
let mut out = Vec::new();
let mut enclosing_functions: Arc<[EnclosingFunction]> = Arc::default();
let mut outer_functions = Vec::new();
for event in parsed.tree().syntax().preorder() {
let syntax_node = match event {
ra_ap_syntax::WalkEvent::Enter(syntax_node) => syntax_node,
ra_ap_syntax::WalkEvent::Leave(syntax_node) => {
if rust::named_function(&syntax_node).is_some()
&& let Some(outer) = outer_functions.pop()
{
enclosing_functions = outer;
}
continue;
}
};
if let Some(function) = rust::named_function(&syntax_node) {
let inner = with_innermost_function(function, &enclosing_functions);
outer_functions.push(std::mem::replace(&mut enclosing_functions, inner));
}
if let Some(call) = ast::CallExpr::cast(syntax_node.clone()) {
let Some(func) = call.expr() else {
continue;
};
let func_span = span_from_rowan_range(func.syntax().text_range());
let callee = file.source[func_span.start..func_span.end].to_string();
let argument_spans = call.arg_list().map_or_else(Vec::new, |arguments| {
arguments
.args()
.map(|arg| span_from_rowan_range(arg.syntax().text_range()))
.collect()
});
out.push(CachedCallCandidate {
span: span_from_rowan_range(call.syntax().text_range()),
callee,
method_name: None,
receiver_call_callee: None,
argument_spans,
is_with_context_manager: false,
is_in_except_clause: false,
enclosing_functions: Arc::clone(&enclosing_functions),
});
} else if let Some(method_call) = ast::MethodCallExpr::cast(syntax_node) {
let (Some(receiver), Some(name_ref)) = (method_call.receiver(), method_call.name_ref())
else {
continue;
};
let start: usize = receiver.syntax().text_range().start().into();
let end: usize = name_ref.syntax().text_range().end().into();
let callee = file.source[start..end].to_string();
let method_name = Some(name_ref.text().to_string());
let receiver_call_callee = rust_call_callee_text(&receiver, &file.source);
let argument_spans = method_call.arg_list().map_or_else(Vec::new, |arguments| {
arguments
.args()
.map(|arg| span_from_rowan_range(arg.syntax().text_range()))
.collect()
});
out.push(CachedCallCandidate {
span: span_from_rowan_range(method_call.syntax().text_range()),
callee,
method_name,
receiver_call_callee,
argument_spans,
is_with_context_manager: false,
is_in_except_clause: false,
enclosing_functions: Arc::clone(&enclosing_functions),
});
}
}
out
}
fn rust_call_callee_text(expr: &ra_ap_syntax::ast::Expr, source: &str) -> Option<String> {
match expr {
ra_ap_syntax::ast::Expr::CallExpr(call) => {
let func = call.expr()?;
let span = span_from_rowan_range(func.syntax().text_range());
Some(source[span.start..span.end].to_string())
}
ra_ap_syntax::ast::Expr::MethodCallExpr(method_call) => {
let receiver = method_call.receiver()?;
let name_ref = method_call.name_ref()?;
let start: usize = receiver.syntax().text_range().start().into();
let end: usize = name_ref.syntax().text_range().end().into();
Some(source[start..end].to_string())
}
_ => None,
}
}
pub struct PositionalRead<'a> {
pub node: AstNode<'a>,
pub receiver: String,
pub position: i64,
}
#[derive(Default)]
pub struct ScopePositionalReads<'a> {
pub reads: Vec<PositionalRead<'a>>,
pub exempt_receivers: HashSet<String>,
}
#[must_use]
pub fn collect_positional_reads(file: &ParsedFile) -> Vec<ScopePositionalReads<'_>> {
dispatch_lang!(file.lang(), collect_positional_reads(file))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum LiteralValue {
Str(String),
Bytes(String),
Int(i128),
Float(u64),
}
impl LiteralValue {
pub(in crate::code_lint::ast) fn negated(&self) -> Option<Self> {
match self {
Self::Int(value) => value.checked_neg().map(Self::Int),
Self::Float(bits) => Some(Self::float(-f64::from_bits(*bits))),
Self::Str(_) | Self::Bytes(_) => None,
}
}
fn float(value: f64) -> Self {
Self::Float(if value == 0.0 { 0.0_f64 } else { value }.to_bits())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiteralRole {
ConstantDefinition,
Inline,
}
pub struct LiteralOccurrence<'a> {
pub node: AstNode<'a>,
pub value: LiteralValue,
pub role: LiteralRole,
}
#[must_use]
pub fn collect_literal_occurrences(file: &ParsedFile) -> Vec<LiteralOccurrence<'_>> {
dispatch_lang!(file.lang(), collect_literal_occurrences(file))
}
pub(in crate::code_lint::ast) fn parse_integer_literal(text: &str) -> Option<LiteralValue> {
let digits = text.replace('_', "");
let (radix, digits) = match digits.get(..2) {
Some("0x" | "0X") => (16, &digits[2..]),
Some("0o" | "0O") => (8, &digits[2..]),
Some("0b" | "0B") => (2, &digits[2..]),
_ => (10, digits.as_str()),
};
i128::from_str_radix(digits, radix)
.ok()
.map(LiteralValue::Int)
}
pub(in crate::code_lint::ast) fn parse_float_literal(text: &str) -> Option<LiteralValue> {
text.replace('_', "").parse().ok().map(LiteralValue::float)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BindingKind {
Import,
ContractMember,
StructuralDefinition,
Value,
}
#[derive(Clone, Copy)]
pub struct Binding<'a> {
pub node: AstNode<'a>,
pub kind: BindingKind,
}
pub(in crate::code_lint::ast) fn push_bindings<'a>(
bindings: &mut Vec<Binding<'a>>,
kind: BindingKind,
extract: impl FnOnce(&mut Vec<AstNode<'a>>),
) {
let mut nodes = Vec::new();
extract(&mut nodes);
bindings.extend(nodes.into_iter().map(|node| Binding { node, kind }));
}
#[must_use]
pub fn collect_bindings(file: &ParsedFile) -> Vec<Binding<'_>> {
let cached = file.bindings.get_or_init(|| {
dispatch_lang!(file.lang(), collect_bindings(file))
.into_iter()
.map(|binding| (binding.node.span(), binding.kind))
.collect()
});
cached
.iter()
.map(|&(span, kind)| Binding {
node: AstNode::from_span(file, span),
kind,
})
.collect()
}
#[must_use]
pub fn collect_test_function_assertion_counts(
file: &ParsedFile,
) -> Vec<(AstNode<'_>, String, usize)> {
dispatch_lang!(file.lang(), collect_test_function_assertion_counts(file))
}
#[must_use]
pub fn collect_test_logic_constructs(
file: &ParsedFile,
) -> Vec<(AstNode<'_>, String, &'static str)> {
dispatch_lang!(file.lang(), collect_test_logic_constructs(file))
}
#[must_use]
pub fn collect_multi_phase_test_functions(file: &ParsedFile) -> Vec<(AstNode<'_>, String)> {
dispatch_lang!(file.lang(), collect_multi_phase_test_functions(file))
}
pub struct TestFunctionSetup<'a> {
pub name_node: AstNode<'a>,
pub name: String,
pub leading_setup_statements: Vec<String>,
}
#[must_use]
pub fn collect_test_setup_scopes(file: &ParsedFile) -> Vec<Vec<TestFunctionSetup<'_>>> {
dispatch_lang!(file.lang(), collect_test_setup_scopes(file))
}
pub(in crate::code_lint::ast) fn normalize_statement_text(raw: &str) -> String {
raw.lines().map(str::trim).collect::<Vec<_>>().join("\n")
}
#[must_use]
pub fn find_unwrapped_multiline_strings(
file: &ParsedFile,
is_allowed_wrapper: impl Fn(&str) -> bool,
) -> Vec<AstNode<'_>> {
dispatch_lang!(
file.lang(),
find_unwrapped_multiline_strings(file, is_allowed_wrapper)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReturnShape {
Unit,
Boolean,
PositionalTuple {
arity: usize,
},
Unknown,
Other,
}
#[derive(Clone)]
pub struct FunctionReturn<'a> {
pub name: String,
pub name_node: AstNode<'a>,
pub declared_type: Option<AstNode<'a>>,
pub shape: ReturnShape,
}
impl FunctionReturn<'_> {
#[must_use]
pub fn declared_type_text(&self) -> String {
self.declared_type.map_or_else(
|| {
match self.name_node.lang() {
Language::Python => "None",
Language::Rust => "()",
}
.to_owned()
},
|declared_type| declared_type.text().into_owned(),
)
}
}
#[must_use]
pub fn collect_function_returns(file: &ParsedFile) -> Vec<FunctionReturn<'_>> {
dispatch_lang!(file.lang(), collect_function_returns(file))
}
const PREDICATE_VERBS: &[&str] = &["contains", "starts_with", "ends_with", "matches"];
const PREDICATE_PREFIXES: &[&str] = &[
"is", "has", "can", "should", "supports", "allows", "requires", "needs",
];
#[must_use]
pub fn is_predicate_name(name: &str) -> bool {
let bare = name.trim_start_matches('_');
PREDICATE_VERBS.contains(&bare)
|| PREDICATE_VERBS
.iter()
.chain(PREDICATE_PREFIXES)
.any(|prefix| {
bare.strip_prefix(prefix)
.and_then(|rest| rest.strip_prefix('_'))
.is_some_and(|rest| !rest.is_empty())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MethodVisibility {
Public,
Private,
}
#[derive(Clone)]
pub struct TypeMethod<'a> {
pub name_node: AstNode<'a>,
pub name: String,
pub visibility: MethodVisibility,
pub is_constructor: bool,
}
pub struct TypeMethodScope<'a> {
pub type_name: String,
pub methods: Vec<TypeMethod<'a>>,
}
#[must_use]
pub fn collect_type_method_scopes(file: &ParsedFile) -> Vec<TypeMethodScope<'_>> {
dispatch_lang!(file.lang(), collect_type_method_scopes(file))
}
#[derive(Clone)]
pub struct CallableItem<'a> {
pub name_node: AstNode<'a>,
pub name: String,
pub visibility: MethodVisibility,
pub is_constructor: bool,
pub callees: Vec<usize>,
}
pub struct CallableScope<'a> {
pub callables: Vec<CallableItem<'a>>,
}
pub struct CallOrderFinding<'a> {
pub name_node: AstNode<'a>,
pub function: String,
pub caller: String,
}
pub struct CallClusterFindings<'a> {
pub uncolocated_helpers: Vec<CallOrderFinding<'a>>,
pub private_before_public: Vec<CallOrderFinding<'a>>,
pub callee_before_caller: Vec<CallOrderFinding<'a>>,
}
#[must_use]
pub fn collect_call_cluster_findings(file: &ParsedFile) -> CallClusterFindings<'_> {
let cached = file.call_cluster_findings.get_or_init(|| {
let scopes = dispatch_lang!(file.lang(), collect_callable_scopes(file));
let mut out = CachedCallClusterFindings::default();
for scope in &scopes {
analyze_callable_scope(scope, &mut out);
}
out
});
let materialize = |items: &[CachedCallOrderFinding]| -> Vec<CallOrderFinding<'_>> {
items
.iter()
.map(|entry| CallOrderFinding {
name_node: AstNode::from_span(file, entry.span),
function: entry.function.clone(),
caller: entry.caller.clone(),
})
.collect()
};
CallClusterFindings {
uncolocated_helpers: materialize(&cached.uncolocated_helpers),
private_before_public: materialize(&cached.private_before_public),
callee_before_caller: materialize(&cached.callee_before_caller),
}
}
fn analyze_callable_scope(scope: &CallableScope<'_>, out: &mut CachedCallClusterFindings) {
let callables = &scope.callables;
let count = callables.len();
if count < 2 {
return;
}
let mut callers_of = vec![Vec::new(); count];
for (caller_idx, callable) in callables.iter().enumerate() {
for &callee_idx in &callable.callees {
if caller_idx != callee_idx
&& callee_idx < count
&& !callers_of[callee_idx].contains(&caller_idx)
{
callers_of[callee_idx].push(caller_idx);
}
}
}
let scc_id = compute_tarjan_scc(callables);
let roots_of = compute_public_roots(callables, &callers_of);
let last_pub = (0..count)
.rev()
.find(|&idx| callables[idx].visibility == MethodVisibility::Public);
let mut flagged_p1_or_p2 = vec![false; count];
for pos in 0..count {
if callables[pos].visibility == MethodVisibility::Public {
continue;
}
let roots = &roots_of[pos];
let Some(&max_root) = roots.last() else {
if let Some(last_pub_idx) = last_pub
&& pos < last_pub_idx
{
out.private_before_public.push(CachedCallOrderFinding {
span: callables[pos].name_node.span(),
function: callables[pos].name.clone(),
caller: callables[last_pub_idx].name.clone(),
});
flagged_p1_or_p2[pos] = true;
}
continue;
};
if pos < max_root {
out.private_before_public.push(CachedCallOrderFinding {
span: callables[pos].name_node.span(),
function: callables[pos].name.clone(),
caller: callables[max_root].name.clone(),
});
flagged_p1_or_p2[pos] = true;
} else if let Some(last_pub_idx) = last_pub
&& !is_valid_helper_placement(callables, &roots_of, pos, last_pub_idx)
{
out.uncolocated_helpers.push(CachedCallOrderFinding {
span: callables[pos].name_node.span(),
function: callables[pos].name.clone(),
caller: callables[max_root].name.clone(),
});
flagged_p1_or_p2[pos] = true;
}
}
for callee in 0..count {
if flagged_p1_or_p2[callee] || callables[callee].visibility == MethodVisibility::Public {
continue;
}
let last_caller = callers_of[callee]
.iter()
.copied()
.filter(|&caller| {
caller > callee
&& !flagged_p1_or_p2[caller]
&& callables[caller].visibility == MethodVisibility::Private
&& scc_id[caller] != scc_id[callee]
&& (!roots_of[caller].is_empty() || roots_of[callee].is_empty())
})
.max();
if let Some(caller_idx) = last_caller {
out.callee_before_caller.push(CachedCallOrderFinding {
span: callables[callee].name_node.span(),
function: callables[callee].name.clone(),
caller: callables[caller_idx].name.clone(),
});
}
}
}
fn is_valid_helper_placement(
callables: &[CallableItem<'_>],
roots_of: &[Vec<usize>],
pos: usize,
last_pub_idx: usize,
) -> bool {
let roots = &roots_of[pos];
if roots.len() != 1 {
return pos > last_pub_idx;
}
let owner = roots[0];
let in_owner_cluster = |idx: usize| -> bool {
roots_of[idx] == *roots
|| (callables[owner].is_constructor
&& (callables[idx].is_constructor
|| (roots_of[idx].len() == 1 && callables[roots_of[idx][0]].is_constructor)))
};
let is_just_after_owner = ((owner + 1)..pos).all(in_owner_cluster);
let is_at_scope_end =
pos > last_pub_idx && ((owner + 1)..last_pub_idx).all(|mid| roots_of[mid] != *roots);
is_just_after_owner || is_at_scope_end
}
fn compute_public_roots(
callables: &[CallableItem<'_>],
callers_of: &[Vec<usize>],
) -> Vec<Vec<usize>> {
let count = callables.len();
let mut roots_of = vec![Vec::new(); count];
for idx in 0..count {
if callables[idx].visibility == MethodVisibility::Public {
roots_of[idx] = vec![idx];
} else {
let mut visited = vec![false; count];
let mut stack = vec![idx];
let mut pub_roots = Vec::new();
while let Some(cur) = stack.pop() {
if visited[cur] {
continue;
}
visited[cur] = true;
for &caller in &callers_of[cur] {
if callables[caller].visibility == MethodVisibility::Public {
pub_roots.push(caller);
} else {
stack.push(caller);
}
}
}
pub_roots.sort_unstable();
pub_roots.dedup();
roots_of[idx] = pub_roots;
}
}
roots_of
}
fn compute_tarjan_scc(callables: &[CallableItem<'_>]) -> Vec<usize> {
struct TarjanState {
next_index: usize,
next_scc: usize,
indices: Vec<Option<usize>>,
lowlink: Vec<usize>,
on_stack: Vec<bool>,
stack: Vec<usize>,
scc_id: Vec<usize>,
}
impl TarjanState {
fn strongconnect(&mut self, node: usize, callables: &[CallableItem<'_>]) {
let current_index = self.next_index;
self.next_index += 1;
self.indices[node] = Some(current_index);
self.lowlink[node] = current_index;
self.stack.push(node);
self.on_stack[node] = true;
for &callee in &callables[node].callees {
if callee >= callables.len() {
continue;
}
if self.indices[callee].is_none() {
self.strongconnect(callee, callables);
self.lowlink[node] = self.lowlink[node].min(self.lowlink[callee]);
} else if self.on_stack[callee]
&& let Some(callee_index) = self.indices[callee]
{
self.lowlink[node] = self.lowlink[node].min(callee_index);
}
}
if Some(self.lowlink[node]) == self.indices[node] {
let id = self.next_scc;
self.next_scc += 1;
while let Some(member) = self.stack.pop() {
self.on_stack[member] = false;
self.scc_id[member] = id;
if member == node {
break;
}
}
}
}
}
let count = callables.len();
let mut state = TarjanState {
next_index: 0,
next_scc: 0,
indices: vec![None; count],
lowlink: vec![0; count],
on_stack: vec![false; count],
stack: Vec::new(),
scc_id: vec![0; count],
};
for node in 0..count {
if state.indices[node].is_none() {
state.strongconnect(node, callables);
}
}
state.scc_id
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_location_from_node() {
let source = "fn main() {\n let value = 42;\n}\n";
let file = ParsedFile::new(source, Language::Rust);
let node = AstNode::from_span(&file, SourceSpan::new(16, 31));
assert_eq!(
node.to_source_location("src/main.rs"),
SourceLocation::file_span(
"src/main.rs",
SourceSpan::new(16, 31),
LineColumn { line: 2, column: 5 },
)
);
}
#[rstest::rstest]
#[case::python(
indoc::indoc! {r"
sleep(
# comment before arg
0, # inline comment
)
"},
Language::Python,
"0"
)]
#[case::rust(
indoc::indoc! {r"
fn f() {
sleep(
/* block comment */
Duration::ZERO, // line comment
);
}
"},
Language::Rust,
"Duration::ZERO"
)]
fn test_call_argument_nodes_excludes_comments_and_trivia(
#[case] source: &str,
#[case] lang: Language,
#[case] expected_argument: &str,
) {
let file = ParsedFile::new(source, lang);
let argument_texts: Vec<Vec<String>> = collect_call_candidates(&file)
.iter()
.map(|call| {
call.arguments
.iter()
.map(|argument| argument.text().into_owned())
.collect()
})
.collect();
assert_eq!(argument_texts, vec![vec![expected_argument.to_owned()]]);
}
}