use crate::analyzers::get_analyzer;
use crate::config::{BatchAnalysisConfig, ParallelConfig};
use crate::core::ast::Ast;
use crate::core::{DebtItem, FileMetrics, Language};
use crate::effects::{
AnalysisEffect, AnalysisValidation, validation_failure, validation_failures, validation_success,
};
use crate::env::{AnalysisEnv, RealEnv};
use crate::errors::AnalysisError;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use stillwater::effect::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAnalysisResult {
pub path: PathBuf,
pub metrics: FileMetrics,
pub debt_items: Vec<DebtItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis_time: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package_name: Option<String>,
}
impl FileAnalysisResult {
pub fn new(path: PathBuf, metrics: FileMetrics, debt_items: Vec<DebtItem>) -> Self {
Self {
path,
metrics,
debt_items,
analysis_time: None,
package_name: None,
}
}
pub fn with_timing(
path: PathBuf,
metrics: FileMetrics,
debt_items: Vec<DebtItem>,
analysis_time: Duration,
) -> Self {
Self {
path,
metrics,
debt_items,
analysis_time: Some(analysis_time),
package_name: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ValidatedFile {
pub path: PathBuf,
pub content: String,
pub language: Language,
}
impl ValidatedFile {
pub fn new(path: PathBuf, content: String, language: Language) -> Self {
Self {
path,
content,
language,
}
}
}
pub fn analyze_files_effect(paths: Vec<PathBuf>) -> AnalysisEffect<Vec<FileAnalysisResult>> {
from_fn(move |env: &RealEnv| {
let config = env.config();
let parallel_config = config
.batch_analysis
.as_ref()
.map(|b| &b.parallelism)
.cloned()
.unwrap_or_default();
let collect_timing = config
.batch_analysis
.as_ref()
.map(|b| b.collect_timing)
.unwrap_or(false);
analyze_files_parallel(&paths, ¶llel_config, collect_timing)
})
.boxed()
}
pub fn analyze_single_file_effect(path: PathBuf) -> AnalysisEffect<FileAnalysisResult> {
let path_display = path.display().to_string();
from_fn(move |env: &RealEnv| {
let content = env.file_system().read_to_string(&path).map_err(|e| {
AnalysisError::io_with_path(format!("Failed to read file: {}", e.message()), &path)
})?;
analyze_file_content(&path, &content, false).map_err(|e| {
AnalysisError::analysis(format!("Analysis failed for '{}': {}", path_display, e))
})
})
.boxed()
}
pub fn analyze_files_with_config_effect(
paths: Vec<PathBuf>,
_config: BatchAnalysisConfig,
) -> AnalysisEffect<Vec<FileAnalysisResult>> {
analyze_files_effect(paths)
}
pub fn validate_files(paths: &[PathBuf]) -> AnalysisValidation<Vec<ValidatedFile>> {
let validations: Vec<AnalysisValidation<ValidatedFile>> =
paths.iter().map(|p| validate_single_file(p)).collect();
combine_validations_preserve_successes(validations)
}
pub fn validate_single_file(path: &Path) -> AnalysisValidation<ValidatedFile> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
return validation_failure(AnalysisError::io_with_path(
format!("Failed to read file: {}", e),
path,
));
}
};
let language = Language::from_path(path);
if language == Language::Unknown {
return validation_failure(AnalysisError::validation_with_path(
"Unsupported file type",
path,
));
}
if let Err(e) = validate_syntax(&content, language, path) {
return validation_failure(e);
}
validation_success(ValidatedFile::new(path.to_path_buf(), content, language))
}
fn validate_syntax(content: &str, language: Language, path: &Path) -> Result<(), AnalysisError> {
match language {
Language::Rust => {
crate::extraction::UnifiedFileExtractor::extract(path, content).map_err(|e| {
AnalysisError::parse_with_path(format!("Rust syntax error: {}", e), path)
})?;
Ok(())
}
Language::Python => {
if content.contains("def ") || content.contains("class ") || content.trim().is_empty() {
Ok(())
} else {
Ok(())
}
}
Language::JavaScript | Language::TypeScript => {
use crate::analyzers::typescript::parser::{detect_variant, parse_source};
let variant = detect_variant(path);
parse_source(content, path, variant).map_err(|e| {
AnalysisError::parse_with_path(
format!("JavaScript/TypeScript syntax error: {}", e),
path,
)
})?;
Ok(())
}
Language::Go => {
crate::analyzers::go::parser::parse_source(content, path).map_err(|e| {
AnalysisError::parse_with_path(format!("Go syntax error: {}", e), path)
})?;
Ok(())
}
Language::Unknown => Err(AnalysisError::validation_with_path(
"Cannot validate unknown language",
path,
)),
}
}
pub fn validate_and_analyze_files(
paths: &[PathBuf],
) -> AnalysisValidation<Vec<FileAnalysisResult>> {
let validated = validate_files(paths);
match validated {
stillwater::Validation::Success(files) => {
let results: Vec<Result<FileAnalysisResult, AnalysisError>> = files
.into_iter()
.map(|f| analyze_validated_file(&f))
.collect();
let mut successes = Vec::new();
let mut failures = Vec::new();
for result in results {
match result {
Ok(r) => successes.push(r),
Err(e) => failures.push(e),
}
}
if failures.is_empty() {
validation_success(successes)
} else {
validation_failures(failures)
}
}
stillwater::Validation::Failure(errors) => stillwater::Validation::Failure(errors),
}
}
fn analyze_files_parallel(
paths: &[PathBuf],
config: &ParallelConfig,
collect_timing: bool,
) -> Result<Vec<FileAnalysisResult>, AnalysisError> {
let results: Result<Vec<FileAnalysisResult>, AnalysisError> =
if !config.enabled || paths.len() <= 1 {
paths
.iter()
.map(|path| analyze_file_from_path(path, collect_timing))
.collect()
} else {
let batch_size = config.effective_batch_size();
if paths.len() <= batch_size {
paths
.par_iter()
.map(|path| analyze_file_from_path(path, collect_timing))
.collect()
} else {
paths
.chunks(batch_size)
.flat_map(|chunk| {
chunk
.par_iter()
.map(|path| analyze_file_from_path(path, collect_timing))
.collect::<Vec<_>>()
})
.collect()
}
};
results.map(resolve_go_cross_file_calls)
}
fn analyze_file_from_path(
path: &Path,
collect_timing: bool,
) -> Result<FileAnalysisResult, AnalysisError> {
let content = std::fs::read_to_string(path)
.map_err(|e| AnalysisError::io_with_path(format!("Failed to read file: {}", e), path))?;
analyze_file_content(path, &content, collect_timing)
}
fn analyze_file_content(
path: &Path,
content: &str,
collect_timing: bool,
) -> Result<FileAnalysisResult, AnalysisError> {
let start = if collect_timing {
Some(Instant::now())
} else {
None
};
let language = Language::from_path(path);
let analyzer = get_analyzer(language);
let ast = analyzer
.parse(content, path.to_path_buf())
.map_err(|e| AnalysisError::parse_with_path(format!("Parse failed: {}", e), path))?;
let package_name = package_name_for_ast(&ast);
let metrics = analyzer.analyze(&ast);
let debt_items = metrics.debt_items.clone();
crate::core::parsing::reset_span_locations();
let analysis_time = start.map(|s| s.elapsed());
Ok(FileAnalysisResult {
path: path.to_path_buf(),
metrics,
debt_items,
analysis_time,
package_name,
})
}
fn package_name_for_ast(ast: &Ast) -> Option<String> {
match ast {
Ast::Go(go_ast) => crate::analyzers::go::visitor::package_name_from_ast(go_ast),
_ => None,
}
}
fn analyze_validated_file(file: &ValidatedFile) -> Result<FileAnalysisResult, AnalysisError> {
analyze_file_content(&file.path, &file.content, false)
}
fn resolve_go_cross_file_calls(mut results: Vec<FileAnalysisResult>) -> Vec<FileAnalysisResult> {
let symbol_index = go_symbol_index(&results);
for result in results.iter_mut().filter(|result| is_go_result(result)) {
let package = go_package_key(result);
let symbols = symbol_index.get(&package).cloned().unwrap_or_default();
for function in &mut result.metrics.complexity.functions {
let resolved = function
.call_dependencies
.clone()
.unwrap_or_default()
.into_iter()
.filter(|call| symbols.contains(call))
.collect::<Vec<_>>();
function.call_dependencies = (!resolved.is_empty()).then_some(resolved.clone());
function.downstream_callees = (!resolved.is_empty()).then_some(resolved);
}
}
let upstream = go_upstream_callers(&results);
for result in results.iter_mut().filter(|result| is_go_result(result)) {
for function in &mut result.metrics.complexity.functions {
function.upstream_callers = upstream.get(&function.name).cloned();
}
}
results
}
#[derive(Debug, Clone, Default)]
struct GoPackageSymbols {
free_functions: HashSet<String>,
methods: HashSet<String>,
}
impl GoPackageSymbols {
fn insert(&mut self, name: &str) {
if name.contains('.') {
self.methods.insert(name.to_string());
} else {
self.free_functions.insert(name.to_string());
}
}
fn contains(&self, call: &str) -> bool {
if call.contains('.') {
self.methods.contains(call)
} else {
self.free_functions.contains(call)
}
}
}
fn go_symbol_index(results: &[FileAnalysisResult]) -> HashMap<GoPackageKey, GoPackageSymbols> {
results.iter().filter(|result| is_go_result(result)).fold(
HashMap::new(),
|mut index, result| {
let package = go_package_key(result);
let package_symbols = index.entry(package).or_default();
for function in &result.metrics.complexity.functions {
package_symbols.insert(&function.name);
}
index
},
)
}
fn go_upstream_callers(results: &[FileAnalysisResult]) -> HashMap<String, Vec<String>> {
results
.iter()
.filter(|result| is_go_result(result))
.flat_map(|result| result.metrics.complexity.functions.iter())
.fold(HashMap::new(), |mut upstream, caller| {
for callee in caller.downstream_callees.clone().unwrap_or_default() {
upstream
.entry(callee)
.or_default()
.push(caller.name.clone());
}
upstream
})
}
fn is_go_result(result: &FileAnalysisResult) -> bool {
result.metrics.language == Language::Go
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct GoPackageKey {
directory: PathBuf,
package_name: Option<String>,
}
fn go_package_key(result: &FileAnalysisResult) -> GoPackageKey {
GoPackageKey {
directory: result
.path
.parent()
.unwrap_or_else(|| Path::new(""))
.to_path_buf(),
package_name: result.package_name.clone(),
}
}
fn combine_validations_preserve_successes<T>(
validations: Vec<AnalysisValidation<T>>,
) -> AnalysisValidation<Vec<T>> {
let mut successes = Vec::new();
let mut failures: Vec<AnalysisError> = Vec::new();
for v in validations {
match v {
stillwater::Validation::Success(value) => successes.push(value),
stillwater::Validation::Failure(errors) => {
for err in errors {
failures.push(err);
}
}
}
}
if failures.is_empty() {
validation_success(successes)
} else {
validation_failures(failures)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::effects::run_validation;
use std::fs;
use tempfile::TempDir;
fn create_test_dir() -> TempDir {
TempDir::new().unwrap()
}
#[test]
fn test_file_analysis_result_new() {
let path = PathBuf::from("test.rs");
let metrics = FileMetrics {
path: path.clone(),
language: Language::Rust,
complexity: Default::default(),
debt_items: vec![],
dependencies: vec![],
duplications: vec![],
total_lines: 0,
module_scope: None,
classes: None,
};
let result = FileAnalysisResult::new(path.clone(), metrics, vec![]);
assert_eq!(result.path, path);
assert!(result.analysis_time.is_none());
}
#[test]
fn test_file_analysis_result_with_timing() {
let path = PathBuf::from("test.rs");
let metrics = FileMetrics {
path: path.clone(),
language: Language::Rust,
complexity: Default::default(),
debt_items: vec![],
dependencies: vec![],
duplications: vec![],
total_lines: 0,
module_scope: None,
classes: None,
};
let duration = Duration::from_millis(100);
let result = FileAnalysisResult::with_timing(path.clone(), metrics, vec![], duration);
assert_eq!(result.path, path);
assert_eq!(result.analysis_time, Some(duration));
}
#[test]
fn test_validated_file_new() {
let file = ValidatedFile::new(
PathBuf::from("test.rs"),
"fn main() {}".to_string(),
Language::Rust,
);
assert_eq!(file.path, PathBuf::from("test.rs"));
assert_eq!(file.content, "fn main() {}");
assert_eq!(file.language, Language::Rust);
}
#[test]
fn test_validate_single_file_success() {
let temp_dir = create_test_dir();
let file_path = temp_dir.path().join("test.rs");
fs::write(&file_path, "fn main() {}").unwrap();
let result = validate_single_file(&file_path);
assert!(result.is_success());
match result {
stillwater::Validation::Success(file) => {
assert_eq!(file.path, file_path);
assert_eq!(file.language, Language::Rust);
}
_ => panic!("Expected success"),
}
}
#[test]
fn test_validate_single_file_not_found() {
let result = validate_single_file(Path::new("/nonexistent/file.rs"));
assert!(result.is_failure());
}
#[test]
fn test_validate_single_file_unknown_language() {
let temp_dir = create_test_dir();
let file_path = temp_dir.path().join("test.unknown");
fs::write(&file_path, "some content").unwrap();
let result = validate_single_file(&file_path);
assert!(result.is_failure());
}
#[test]
fn test_validate_single_file_syntax_error() {
let temp_dir = create_test_dir();
let file_path = temp_dir.path().join("test.rs");
fs::write(&file_path, "fn main( { }").unwrap();
let result = validate_single_file(&file_path);
assert!(result.is_failure());
}
#[test]
fn test_validate_files_all_success() {
let temp_dir = create_test_dir();
let file1 = temp_dir.path().join("a.rs");
let file2 = temp_dir.path().join("b.rs");
fs::write(&file1, "fn a() {}").unwrap();
fs::write(&file2, "fn b() {}").unwrap();
let paths = vec![file1, file2];
let result = run_validation(validate_files(&paths));
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 2);
}
#[test]
fn test_validate_files_accumulates_errors() {
let temp_dir = create_test_dir();
let valid_file = temp_dir.path().join("valid.rs");
fs::write(&valid_file, "fn valid() {}").unwrap();
let paths = vec![
valid_file,
PathBuf::from("/nonexistent/a.rs"),
PathBuf::from("/nonexistent/b.rs"),
];
let result = validate_files(&paths);
match result {
stillwater::Validation::Failure(errors) => {
let errors_vec: Vec<_> = errors.into_iter().collect();
assert_eq!(errors_vec.len(), 2);
}
_ => panic!("Expected failure with accumulated errors"),
}
}
#[test]
fn test_analyze_file_from_path() {
let temp_dir = create_test_dir();
let file_path = temp_dir.path().join("test.rs");
fs::write(&file_path, "fn main() { let x = 1; }").unwrap();
let result = analyze_file_from_path(&file_path, false);
assert!(result.is_ok());
let analysis = result.unwrap();
assert_eq!(analysis.path, file_path);
assert!(analysis.analysis_time.is_none());
}
#[test]
fn test_analyze_file_from_path_with_timing() {
let temp_dir = create_test_dir();
let file_path = temp_dir.path().join("test.rs");
fs::write(&file_path, "fn main() {}").unwrap();
let result = analyze_file_from_path(&file_path, true);
assert!(result.is_ok());
let analysis = result.unwrap();
assert!(analysis.analysis_time.is_some());
}
#[test]
fn test_analyze_files_parallel_sequential() {
let temp_dir = create_test_dir();
let file1 = temp_dir.path().join("a.rs");
let file2 = temp_dir.path().join("b.rs");
fs::write(&file1, "fn a() {}").unwrap();
fs::write(&file2, "fn b() {}").unwrap();
let config = ParallelConfig::sequential();
let result = analyze_files_parallel(&[file1, file2], &config, false);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 2);
}
#[test]
fn test_analyze_files_parallel_enabled() {
let temp_dir = create_test_dir();
let file1 = temp_dir.path().join("a.rs");
let file2 = temp_dir.path().join("b.rs");
let file3 = temp_dir.path().join("c.rs");
fs::write(&file1, "fn a() {}").unwrap();
fs::write(&file2, "fn b() {}").unwrap();
fs::write(&file3, "fn c() {}").unwrap();
let config = ParallelConfig::default();
let result = analyze_files_parallel(&[file1, file2, file3], &config, false);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 3);
}
#[test]
fn test_validate_and_analyze_files_success() {
let temp_dir = create_test_dir();
let file_path = temp_dir.path().join("test.rs");
fs::write(&file_path, "fn main() {}").unwrap();
let result = run_validation(validate_and_analyze_files(std::slice::from_ref(&file_path)));
assert!(result.is_ok());
let results = result.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, file_path);
}
#[test]
fn test_validate_and_analyze_files_validation_failure() {
let paths = vec![PathBuf::from("/nonexistent/file.rs")];
let result = run_validation(validate_and_analyze_files(&paths));
assert!(result.is_err());
}
#[test]
fn test_validate_syntax_rust_valid() {
let content = "fn main() {}";
let result = validate_syntax(content, Language::Rust, Path::new("test.rs"));
assert!(result.is_ok());
}
#[test]
fn test_validate_syntax_rust_invalid() {
let content = "fn main( { }"; let result = validate_syntax(content, Language::Rust, Path::new("test.rs"));
assert!(result.is_err());
}
#[test]
fn test_validate_syntax_python() {
let content = "def hello():\n pass";
let result = validate_syntax(content, Language::Python, Path::new("test.py"));
assert!(result.is_ok());
}
#[test]
fn test_validate_syntax_go_valid() {
let content = "package main\n\nfunc main() {}";
let result = validate_syntax(content, Language::Go, Path::new("main.go"));
assert!(result.is_ok());
}
#[test]
fn test_validate_syntax_go_invalid() {
let content = "package main\n\nfunc main( {}";
let result = validate_syntax(content, Language::Go, Path::new("main.go"));
assert!(result.is_err());
}
#[test]
fn test_combine_validations_preserve_successes() {
let validations: Vec<AnalysisValidation<i32>> = vec![
validation_success(1),
validation_success(2),
validation_success(3),
];
let result = combine_validations_preserve_successes(validations);
match result {
stillwater::Validation::Success(values) => {
assert_eq!(values, vec![1, 2, 3]);
}
_ => panic!("Expected success"),
}
}
#[test]
fn test_combine_validations_with_failures() {
let validations: Vec<AnalysisValidation<i32>> = vec![
validation_success(1),
validation_failure(AnalysisError::validation("Error 1")),
validation_success(3),
validation_failure(AnalysisError::validation("Error 2")),
];
let result = combine_validations_preserve_successes(validations);
match result {
stillwater::Validation::Failure(errors) => {
let errors_vec: Vec<_> = errors.into_iter().collect();
assert_eq!(errors_vec.len(), 2);
}
_ => panic!("Expected failure"),
}
}
}