use ast_grep_core::AstGrep;
use ast_grep_language::Python;
use r2x_logger as logger;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::{DirEntry, WalkDir};
#[derive(Debug, Clone)]
pub(crate) struct ClassDef {
pub(crate) name: String,
pub(crate) generic_param: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct DecoratedFunc {
pub(crate) function_name: String,
}
#[derive(Debug)]
pub(crate) struct ParsedPyFile {
pub(crate) content: String,
pub(crate) classes: Vec<ClassDef>,
decorated_functions: Vec<DecoratedFunc>,
}
pub(crate) struct PackageAstCache {
files: HashMap<PathBuf, ParsedPyFile>,
class_index: HashMap<String, PathBuf>,
}
impl PackageAstCache {
fn is_ignored_dir(entry: &DirEntry) -> bool {
if !entry.file_type().is_dir() {
return false;
}
matches!(
entry.file_name().to_string_lossy().as_ref(),
".git"
| ".hg"
| ".svn"
| ".venv"
| "venv"
| "env"
| "node_modules"
| "target"
| "dist"
| "build"
| ".tox"
| ".mypy_cache"
| ".pytest_cache"
| "__pycache__"
| "htmlcov"
)
}
pub(crate) fn build(package_root: &Path) -> Self {
let start = Instant::now();
let mut files = HashMap::new();
let mut class_index = HashMap::new();
let mut file_count = 0;
for entry in WalkDir::new(package_root)
.into_iter()
.filter_entry(|entry| !Self::is_ignored_dir(entry))
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "py"))
{
let path = entry.path().to_path_buf();
if let Ok(content) = std::fs::read_to_string(&path) {
let parsed = Self::parse_file(&path, content);
for class in &parsed.classes {
class_index.insert(class.name.clone(), path.clone());
}
files.insert(path, parsed);
file_count += 1;
}
}
logger::debug(&format!(
"PackageAstCache::build: parsed {} files in {:.2}ms",
file_count,
start.elapsed().as_secs_f64() * 1000.0
));
PackageAstCache { files, class_index }
}
fn parse_file(path: &Path, content: String) -> ParsedPyFile {
let start = Instant::now();
let sg = AstGrep::new(&content, Python);
let root = sg.root();
let classes = Self::extract_all_classes(&root);
let decorated_functions = Self::extract_all_decorated_functions(&root);
logger::debug(&format!(
"parse_file {:?}: {} classes, {} decorators in {:.3}ms",
path.file_name().unwrap_or_default(),
classes.len(),
decorated_functions.len(),
start.elapsed().as_secs_f64() * 1000.0
));
ParsedPyFile {
content,
classes,
decorated_functions,
}
}
fn extract_all_classes(
root: &ast_grep_core::Node<'_, ast_grep_core::source::StrDoc<Python>>,
) -> Vec<ClassDef> {
let mut classes = Vec::new();
let pattern_with_bases = "class $NAME($$$BASES): $$$BODY";
for node in root.find_all(pattern_with_bases) {
let env = node.get_env();
let name = env
.get_match("$NAME")
.map(|n| n.text().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| Self::extract_class_name_from_text(&node.text()));
if name.is_empty() {
continue;
}
let bases_text: String = {
let from_env: String = env
.get_multiple_matches("$$$BASES")
.iter()
.map(|b| b.text().to_string())
.collect::<Vec<_>>()
.join(", ");
if from_env.is_empty() {
Self::extract_bases_from_text(&node.text())
} else {
from_env
}
};
let (_, generic_param) = Self::parse_bases(&bases_text);
classes.push(ClassDef {
name,
generic_param,
});
}
let pattern_no_bases = "class $NAME: $$$BODY";
for node in root.find_all(pattern_no_bases) {
let env = node.get_env();
let name = env
.get_match("$NAME")
.map(|n| n.text().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| Self::extract_class_name_from_text(&node.text()));
if name.is_empty() {
continue;
}
if classes.iter().any(|c| c.name == name) {
continue;
}
classes.push(ClassDef {
name,
generic_param: None,
});
}
classes
}
fn parse_bases(bases_text: &str) -> (Vec<String>, Option<String>) {
let mut bases = Vec::new();
let mut generic_param = None;
let mut current = String::new();
let mut depth = 0;
for ch in bases_text.chars() {
match ch {
'[' | '(' | '{' => {
depth += 1;
current.push(ch);
}
']' | ')' | '}' => {
depth -= 1;
current.push(ch);
}
',' if depth == 0 => {
let base = current.trim().to_string();
if !base.is_empty() {
if let Some(param) = Self::extract_generic_param(&base, "Plugin") {
generic_param = Some(param);
}
bases.push(base);
}
current.clear();
}
_ => current.push(ch),
}
}
let base = current.trim().to_string();
if !base.is_empty() {
if let Some(param) = Self::extract_generic_param(&base, "Plugin") {
generic_param = Some(param);
}
bases.push(base);
}
(bases, generic_param)
}
fn extract_generic_param(base: &str, type_name: &str) -> Option<String> {
let prefix = format!("{}[", type_name);
if base.starts_with(&prefix) && base.ends_with(']') {
let inner = &base[prefix.len()..base.len() - 1];
Some(inner.trim().to_string())
} else {
None
}
}
fn extract_class_name_from_text(text: &str) -> String {
if let Some(after_class) = text.strip_prefix("class ") {
let end = after_class
.find(|c| ['(', ':'].contains(&c))
.unwrap_or(after_class.len());
return after_class[..end].trim().to_string();
}
String::new()
}
fn extract_bases_from_text(text: &str) -> String {
if let Some(open_paren) = text.find('(') {
if let Some(close_paren) = text.find("):") {
if close_paren > open_paren {
return text[open_paren + 1..close_paren].trim().to_string();
}
}
}
String::new()
}
fn extract_all_decorated_functions(
root: &ast_grep_core::Node<'_, ast_grep_core::source::StrDoc<Python>>,
) -> Vec<DecoratedFunc> {
let mut results = Vec::new();
let patterns = [
"@expose_plugin\ndef $FUNC($$$PARAMS): $$$BODY",
"@expose_plugin()\ndef $FUNC($$$PARAMS): $$$BODY",
"@expose_plugin($$$ARGS)\ndef $FUNC($$$PARAMS): $$$BODY",
];
for pattern in patterns {
for node in root.find_all(pattern) {
let env = node.get_env();
let function_name = env
.get_match("$FUNC")
.map(|n| n.text().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| Self::extract_function_name_from_text(&node.text()));
if !function_name.is_empty() {
if !results
.iter()
.any(|r: &DecoratedFunc| r.function_name == function_name)
{
results.push(DecoratedFunc { function_name });
}
}
}
}
results
}
fn extract_function_name_from_text(text: &str) -> String {
if let Some(def_pos) = text.find("def ") {
let after_def = &text[def_pos + 4..];
if let Some(paren_pos) = after_def.find('(') {
return after_def[..paren_pos].trim().to_string();
}
}
String::new()
}
#[cfg(test)]
fn find_class(&self, name: &str) -> Option<&ClassDef> {
if let Some(path) = self.class_index.get(name) {
if let Some(file) = self.files.get(path) {
return file.classes.iter().find(|c| c.name == name);
}
}
None
}
pub(crate) fn find_class_with_path(&self, name: &str) -> Option<(&PathBuf, &ClassDef)> {
if let Some(path) = self.class_index.get(name) {
if let Some(file) = self.files.get(path) {
if let Some(class) = file.classes.iter().find(|c| c.name == name) {
return Some((path, class));
}
}
}
None
}
pub(crate) fn find_config_class_content(&self, name: &str) -> Option<(&PathBuf, &str)> {
if let Some(path) = self.class_index.get(name) {
if let Some(file) = self.files.get(path) {
return Some((path, &file.content));
}
}
for (path, file) in &self.files {
let class_def = format!("class {}(", name);
let class_def_no_base = format!("class {}:", name);
if file.content.contains(&class_def) || file.content.contains(&class_def_no_base) {
return Some((path, &file.content));
}
}
None
}
pub(crate) fn get_all_decorated_functions(&self) -> Vec<(&PathBuf, &DecoratedFunc)> {
self.files
.iter()
.flat_map(|(path, file)| {
file.decorated_functions
.iter()
.map(move |func| (path, func))
})
.collect()
}
pub(crate) fn files(&self) -> &HashMap<PathBuf, ParsedPyFile> {
&self.files
}
pub(crate) fn file_count(&self) -> usize {
self.files.len()
}
}
#[cfg(test)]
mod tests {
use crate::package_cache::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_build_cache() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let py_content = r#"
from r2x_core import Plugin, expose_plugin
class MyConfig:
name: str = "test"
class MyParser(Plugin[MyConfig]):
def __init__(self):
pass
def on_build(self, system):
return system
@expose_plugin
def my_transform(system):
return system
"#;
fs::write(temp_dir.path().join("plugin.py"), py_content)?;
let cache = PackageAstCache::build(temp_dir.path());
assert_eq!(cache.file_count(), 1);
let class = cache.find_class("MyParser");
assert!(class.is_some());
assert!(class.is_some_and(|c| c.generic_param == Some("MyConfig".to_string())));
let decorated = cache.get_all_decorated_functions();
assert_eq!(decorated.len(), 1);
assert_eq!(decorated[0].1.function_name, "my_transform");
Ok(())
}
#[test]
fn test_find_config_class() -> anyhow::Result<()> {
let temp_dir = TempDir::new()?;
let py_content = r#"
class PCMDefaultsConfig:
path: str = "default.json"
override: bool = False
"#;
fs::write(temp_dir.path().join("config.py"), py_content)?;
let cache = PackageAstCache::build(temp_dir.path());
let config = cache.find_config_class_content("PCMDefaultsConfig");
assert!(config.is_some());
assert!(config.is_some_and(|(_, content)| content.contains("class PCMDefaultsConfig")));
Ok(())
}
}