diff --git a/Cargo.lock b/Cargo.lock index bc4be19..993a24c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,7 @@ checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" name = "complexipy" version = "0.3.0" dependencies = [ + "csv", "env_logger", "ignore", "log", @@ -167,6 +168,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -319,6 +341,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + [[package]] name = "lalrpop-util" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index f320169..25ed569 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ name = "complexipy" crate-type = ["cdylib"] [dependencies] +csv = "1.3.0" env_logger = "0.11.1" ignore = "0.4.22" log = "0.4.20" diff --git a/README.md b/README.md index 4195180..53da1f4 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ $ complexipy https://github.com/rohaquinlop/complexipy -o ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file +This project is licensed under the MIT License - see the [LICENSE](https://github.com/rohaquinlop/complexipy/blob/main/LICENSE) file for details. ## Acknowledgments diff --git a/complexipy/main.py b/complexipy/main.py index 8c67e53..35553db 100644 --- a/complexipy/main.py +++ b/complexipy/main.py @@ -1,11 +1,28 @@ -from pathlib import Path -from complexipy import rust -import csv -from enum import Enum +from .types import ( + DetailTypes, + Level, +) +from .utils import ( + create_table_file_level, + create_table_function_level, +) +from complexipy import ( + rust, +) +from complexipy.rust import ( + FileComplexity, +) import os +from pathlib import ( + Path, +) import re -from rich.console import Console -from rich.table import Table +from rich.align import ( + Align, +) +from rich.console import ( + Console, +) import time import typer @@ -15,11 +32,6 @@ version = "0.3.0" -class DetailTypes(Enum): - low = "low" # Show only files with complexity above the max_complexity - normal = "normal" # Show all files with their complexity - - @app.command() def main( path: str = typer.Argument( @@ -29,7 +41,7 @@ def main( 15, "--max-complexity", "-c", - help="The maximum complexity allowed per file, set this value as 0 to set it as unlimited.", + help="The maximum complexity allowed per file, set this value as 0 to set it as unlimited. Default is 15.", ), output: bool = typer.Option( False, "--output", "-o", help="Output the results to a CSV file." @@ -38,58 +50,54 @@ def main( DetailTypes.normal.value, "--details", "-d", - help="Specify how detailed should be output.", + help="Specify how detailed should be output, it can be 'low' or 'normal'. Default is 'normal'.", + ), + level: Level = typer.Option( + Level.function.value, + "--level", + "-l", + help="Specify the level of measurement, it can be 'function' or 'file'. Default is 'function'.", ), ): - has_success = True is_dir = Path(path).is_dir() _url_pattern = ( r"^(https:\/\/|http:\/\/|www\.|git@)(github|gitlab)\.com(\/[\w.-]+){2,}$" ) is_url = bool(re.match(_url_pattern, path)) invocation_path = os.getcwd() + file_level = level == Level.file console.rule(f"complexipy {version} :octopus:") with console.status("Analyzing the complexity of the code...", spinner="dots"): start_time = time.time() - files = rust.main(path, is_dir, is_url, max_complexity) + files: list[FileComplexity] = rust.main( + path, is_dir, is_url, max_complexity, file_level + ) execution_time = time.time() - start_time - console.rule(":tada: Analysis completed!:tada:") + output_csv_path = f"{invocation_path}/complexipy.csv" - if output: - with open(f"{invocation_path}/complexipy.csv", "w", newline="") as file: - writer = csv.writer(file) - writer.writerow(["Path", "File Name", "Cognitive Complexity"]) - for file in files: - writer.writerow([file.path, file.file_name, file.complexity]) - console.print(f"Results saved to {invocation_path}/complexipy.csv") + if output and file_level: + rust.output_csv_file_level(output_csv_path, files) + console.print(f"Results saved in {output_csv_path}") + if output and not file_level: + rust.output_csv_function_level(output_csv_path, files) + console.print(f"Results saved in {output_csv_path}") # Summary - table = Table( - title="Summary", show_header=True, header_style="bold magenta", show_lines=True - ) - table.add_column("Path") - table.add_column("File") - table.add_column("Complexity") - total_complexity = 0 - for file in files: - total_complexity += file.complexity - if file.complexity > max_complexity and max_complexity != 0: - table.add_row( - f"{file.path}", - f"[green]{file.file_name}[/green]", - f"[red]{file.complexity}[/red]", - ) - has_success = False - elif details != DetailTypes.low or max_complexity == 0: - table.add_row( - f"{file.path}", - f"[green]{file.file_name}[/green]", - f"[blue]{file.complexity}[/blue]", - ) - console.print(table) + if file_level: + table, has_success, total_complexity = create_table_file_level( + files, max_complexity, details + ) + else: + table, has_success, total_complexity = create_table_function_level( + files, max_complexity, details + ) + console.print(Align.center(table)) console.print(f":brain: Total Cognitive Complexity in {path}: {total_complexity}") - console.print(f"{len(files)} files analyzed in {execution_time:.4f} seconds") + console.print( + f"{len(files)} file{'s' if len(files)> 1 else ''} analyzed in {execution_time:.4f} seconds" + ) + console.rule(":tada: Analysis completed! :tada:") if not has_success: raise typer.Exit(code=1) diff --git a/complexipy/types.py b/complexipy/types.py new file mode 100644 index 0000000..261263f --- /dev/null +++ b/complexipy/types.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class DetailTypes(Enum): + low = "low" # Show only files with complexity above the max_complexity + normal = "normal" # Show all files with their complexity + + +class Level(Enum): + function = "function" + file = "file" diff --git a/complexipy/utils.py b/complexipy/utils.py new file mode 100644 index 0000000..cb0ef5e --- /dev/null +++ b/complexipy/utils.py @@ -0,0 +1,72 @@ +from .types import ( + DetailTypes, +) +from complexipy.rust import ( + FileComplexity, +) +from rich.table import Table + + +def create_table_file_level( + files: list[FileComplexity], max_complexity: int, details: DetailTypes +) -> tuple[Table, bool, int]: + has_success = True + + table = Table( + title="Summary", show_header=True, header_style="bold magenta", show_lines=True + ) + table.add_column("Path") + table.add_column("File") + table.add_column("Complexity") + total_complexity = 0 + for file in files: + total_complexity += file.complexity + if file.complexity > max_complexity and max_complexity != 0: + table.add_row( + f"{file.path}", + f"[green]{file.file_name}[/green]", + f"[red]{file.complexity}[/red]", + ) + has_success = False + elif details != DetailTypes.low or max_complexity == 0: + table.add_row( + f"{file.path}", + f"[green]{file.file_name}[/green]", + f"[blue]{file.complexity}[/blue]", + ) + return table, has_success, total_complexity + + +def create_table_function_level( + files: list[FileComplexity], complexity: int, details: DetailTypes +) -> tuple[Table, bool, int]: + has_success = True + + table = Table( + title="Summary", show_header=True, header_style="bold magenta", show_lines=True + ) + table.add_column("Path") + table.add_column("File") + table.add_column("Function") + table.add_column("Complexity") + total_complexity = 0 + for file in files: + total_complexity += file.complexity + for function in file.functions: + total_complexity += function.complexity + if function.complexity > complexity and complexity != 0: + table.add_row( + f"{file.path}", + f"[green]{file.file_name}[/green]", + f"[green]{function.name}[/green]", + f"[red]{function.complexity}[/red]", + ) + has_success = False + elif details != DetailTypes.low or complexity == 0: + table.add_row( + f"{file.path}", + f"[green]{file.file_name}[/green]", + f"[green]{function.name}[/green]", + f"[blue]{function.complexity}[/blue]", + ) + return table, has_success, total_complexity diff --git a/docs/index.md b/docs/index.md index acca846..fd7d13a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -140,7 +140,7 @@ $ complexipy https://github.com/rohaquinlop/complexipy -o ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file +This project is licensed under the MIT License - see the [LICENSE](https://github.com/rohaquinlop/complexipy/blob/main/LICENSE) file for details. ## Acknowledgments diff --git a/src/classes/mod.rs b/src/classes/mod.rs index 6055ed0..fe98a5d 100644 --- a/src/classes/mod.rs +++ b/src/classes/mod.rs @@ -1,5 +1,6 @@ use pyo3::prelude::*; +#[derive(Clone)] #[pyclass(module = "complexipy", get_all)] pub struct FunctionComplexity { pub name: String, @@ -11,6 +12,6 @@ pub struct FunctionComplexity { pub struct FileComplexity { pub path: String, pub file_name: String, - // pub functions: Vec, + pub functions: Vec, pub complexity: u64, } diff --git a/src/cognitive_complexity/mod.rs b/src/cognitive_complexity/mod.rs index d341f7f..55ff6cb 100644 --- a/src/cognitive_complexity/mod.rs +++ b/src/cognitive_complexity/mod.rs @@ -1,6 +1,6 @@ -mod utils; +pub mod utils; -use crate::classes::FileComplexity; +use crate::classes::{FileComplexity, FunctionComplexity}; use ignore::Walk; use pyo3::prelude::*; use rayon::prelude::*; @@ -21,24 +21,10 @@ pub fn main( is_dir: bool, is_url: bool, max_complexity: usize, + file_level: bool, ) -> PyResult> { let mut ans: Vec = Vec::new(); - if is_dir { - println!( - "Analyzing files in {}", - path::Path::new(path) - .canonicalize() - .unwrap() - .to_str() - .unwrap() - ); - } - - if is_url { - println!("Analyzing files in {}", path); - } - if is_url { let dir = tempdir()?; let repo_name = get_repo_name(path); @@ -52,32 +38,38 @@ pub fn main( let repo_path = dir.path().join(&repo_name).to_str().unwrap().to_string(); - match evaluate_dir(&repo_path, max_complexity) { + match evaluate_dir(&repo_path, max_complexity, file_level) { Ok(files_complexity) => ans = files_complexity, Err(e) => return Err(e), } dir.close()?; } else if is_dir { - match evaluate_dir(path, max_complexity) { + match evaluate_dir(path, max_complexity, file_level) { Ok(files_complexity) => ans = files_complexity, Err(e) => return Err(e), } } else { let parent_dir = path::Path::new(path).parent().unwrap().to_str().unwrap(); - match file_cognitive_complexity(path, parent_dir, max_complexity) { + match cognitive_complexity(path, parent_dir, max_complexity, file_level) { Ok(file_complexity) => ans.push(file_complexity), Err(e) => return Err(e), } } - ans.sort_by_key(|f| f.path.clone()); + ans.iter_mut() + .for_each(|f| f.functions.sort_by_key(|f| (f.complexity, f.name.clone()))); + + ans.sort_by_key(|f| (f.path.clone(), f.file_name.clone(), f.complexity)); Ok(ans) } -#[pyfunction] -pub fn evaluate_dir(path: &str, max_complexity: usize) -> PyResult> { +fn evaluate_dir( + path: &str, + max_complexity: usize, + file_level: bool, +) -> PyResult> { let mut files_paths: Vec = Vec::new(); let parent_dir = path::Path::new(path).parent().unwrap().to_str().unwrap(); @@ -94,12 +86,12 @@ pub fn evaluate_dir(path: &str, max_complexity: usize) -> PyResult, PyErr> = files_paths .par_iter() - .map( - |file_path| match file_cognitive_complexity(file_path, parent_dir, max_complexity) { + .map(|file_path| { + match cognitive_complexity(file_path, parent_dir, max_complexity, file_level) { Ok(file_complexity) => Ok(file_complexity), Err(e) => Err(e), - }, - ) + } + }) .collect(); match files_complexity_result { @@ -110,10 +102,11 @@ pub fn evaluate_dir(path: &str, max_complexity: usize) -> PyResult PyResult { let code = std::fs::read_to_string(file_path)?; let ast = ast::Suite::parse(&code, "").unwrap(); @@ -121,22 +114,80 @@ pub fn file_cognitive_complexity( let mut complexity: u64 = 0; let path = path::Path::new(file_path); let file_name = path.file_name().unwrap().to_str().unwrap(); + let mut functions: Vec = Vec::new(); let relative_path = path.strip_prefix(base_path).unwrap().to_str().unwrap(); - for node in ast.iter() { - complexity += statement_cognitive_complexity(node.clone(), 0)?; + if _file_level { + for node in ast.iter() { + complexity += statement_cognitive_complexity(node.clone(), 0)?; + } + } else { + let (f, c) = function_level_cognitive_complexity(&ast)?; + functions = f; + complexity = c; } - println!("- Finished analysis in {}", file_name); - Ok(FileComplexity { path: relative_path.to_string(), file_name: file_name.to_string(), complexity: complexity, + functions: functions, }) } +fn function_level_cognitive_complexity( + ast: &Vec, +) -> PyResult<(Vec, u64)> { + let mut functions: Vec = Vec::new(); + let mut complexity: u64 = 0; + + for node in ast.iter() { + match node { + Stmt::FunctionDef(f) => { + let function_complexity = FunctionComplexity { + name: f.name.to_string(), + complexity: statement_cognitive_complexity(node.clone(), 0)?, + }; + functions.push(function_complexity); + } + Stmt::AsyncFunctionDef(f) => { + let function_complexity = FunctionComplexity { + name: f.name.to_string(), + complexity: statement_cognitive_complexity(node.clone(), 0)?, + }; + functions.push(function_complexity); + } + Stmt::ClassDef(c) => { + for node in c.body.iter() { + match node { + Stmt::FunctionDef(f) => { + let function_complexity = FunctionComplexity { + name: format!("{}::{}", c.name, f.name), + complexity: statement_cognitive_complexity(node.clone(), 0)?, + }; + functions.push(function_complexity); + } + Stmt::AsyncFunctionDef(f) => { + let function_complexity = FunctionComplexity { + name: format!("{}::{}", c.name, f.name), + complexity: statement_cognitive_complexity(node.clone(), 0)?, + }; + functions.push(function_complexity); + } + _ => {} + } + } + } + _ => { + complexity += statement_cognitive_complexity(node.clone(), 0)?; + } + } + } + + Ok((functions, complexity)) +} + /// Calculate the cognitive complexity of a python statement fn statement_cognitive_complexity(statement: Stmt, nesting_level: u64) -> PyResult { let mut complexity: u64 = 0; diff --git a/src/cognitive_complexity/utils.rs b/src/cognitive_complexity/utils.rs index e0e33b6..efa1017 100644 --- a/src/cognitive_complexity/utils.rs +++ b/src/cognitive_complexity/utils.rs @@ -1,5 +1,49 @@ +use crate::classes::FileComplexity; +use csv::Writer; +use pyo3::prelude::*; use rustpython_parser::ast::{self, Stmt}; +#[pyfunction] +pub fn output_csv_file_level(invocation_path: &str, files_complexity: Vec) { + let mut writer = Writer::from_path(invocation_path).unwrap(); + + writer + .write_record(&["Path", "File Name", "Cognitive Complexity"]) + .unwrap(); + + for file in files_complexity { + writer + .write_record(&[&file.path, &file.file_name, &file.complexity.to_string()]) + .unwrap(); + } + + writer.flush().unwrap(); +} + +#[pyfunction] +pub fn output_csv_function_level(invocation_path: &str, functions_complexity: Vec) { + let mut writer = Writer::from_path(invocation_path).unwrap(); + + writer + .write_record(&["Path", "File Name", "Function Name", "Cognitive Complexity"]) + .unwrap(); + + for file in functions_complexity { + for function in file.functions { + writer + .write_record(&[ + &file.path, + &file.file_name, + &function.name, + &function.complexity.to_string(), + ]) + .unwrap(); + } + } + + writer.flush().unwrap(); +} + pub fn get_repo_name(url: &str) -> String { let url = url.trim_end_matches('/'); diff --git a/src/lib.rs b/src/lib.rs index 1ea7f97..fc62d74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,16 +1,18 @@ mod classes; mod cognitive_complexity; -use classes::FileComplexity; -use cognitive_complexity::{evaluate_dir, file_cognitive_complexity, main}; +use classes::{FileComplexity, FunctionComplexity}; +use cognitive_complexity::main; +use cognitive_complexity::utils::{output_csv_file_level, output_csv_function_level}; use pyo3::prelude::*; /// A Python module implemented in Rust. #[pymodule] fn rust(_py: Python, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(main, m)?)?; - m.add_function(wrap_pyfunction!(evaluate_dir, m)?)?; - m.add_function(wrap_pyfunction!(file_cognitive_complexity, m)?)?; + m.add_function(wrap_pyfunction!(output_csv_file_level, m)?)?; + m.add_function(wrap_pyfunction!(output_csv_function_level, m)?)?; m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/tests/main.py b/tests/main.py index 58fbb19..92bffef 100644 --- a/tests/main.py +++ b/tests/main.py @@ -24,6 +24,12 @@ def test_break_continue(self): total_complexity = sum([file.complexity for file in files]) self.assertEqual(3, total_complexity) + def test_class(self): + path = self.local_path / "src/test_class.py" + files = rust.main(path.resolve().as_posix(), False, False, 15) + total_complexity = sum([file.complexity for file in files]) + self.assertEqual(1, total_complexity) + def test_decorator(self): path = self.local_path / "src/test_decorator.py" files = rust.main(path.resolve().as_posix(), False, False, 15) diff --git a/tests/src/test_class.py b/tests/src/test_class.py new file mode 100644 index 0000000..c203d14 --- /dev/null +++ b/tests/src/test_class.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + + +@dataclass +class TestClass: + name: str + age: int + emails: list[str] + + def total_emails(self): + ans = 0 + for email in self.emails: + ans += 1 + return ans