diff --git a/src/cli/main.py b/src/cli/main.py new file mode 100644 index 0000000..7bec042 --- /dev/null +++ b/src/cli/main.py @@ -0,0 +1,341 @@ +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console +from rich.table import Table +from rich.tree import Tree +from rich.panel import Panel +from rich.text import Text + +from src.parsers.base import BaseParser +from src.parsers.python import PythonParser +from src.parsers.javascript import JavaScriptParser +from src.parsers.go import GoParser +from src.parsers.rust import RustParser +from src.graph.builder import GraphBuilder, GraphType +from src.analyzers.dependencies import DependencyAnalyzer +from src.analyzers.complexity import ComplexityCalculator +from src.exporters.dot import DOTExporter +from src.exporters.json_exporter import JSONExporter +from src.exporters.png import PNGExporter + + +console = Console() + + +@click.group() +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") +@click.option("--config", "-c", type=click.Path(), help="Path to config file") +@click.pass_context +def cli(ctx: click.Context, verbose: bool, config: Optional[str]): + ctx.ensure_object(dict) + ctx.obj["verbose"] = verbose + ctx.obj["config"] = config + + +@cli.command() +@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.option("--language", "-l", type=click.Choice(["python", "javascript", "go", "rust", "auto"]), default="auto") +@click.option("--output", "-o", type=click.Path(), help="Output file path") +@click.option("--format", "-f", type=click.Choice(["dot", "json", "png"]), default="json") +@click.option("--include-files/--no-include-files", default=True, help="Include file nodes") +@click.option("--include-functions/--no-include-functions", default=True, help="Include function nodes") +@click.option("--include-classes/--no-include-classes", default=True, help="Include class nodes") +@click.pass_context +def analyze( + ctx: click.Context, + path: str, + language: str, + output: Optional[str], + format: str, + include_files: bool, + include_functions: bool, + include_classes: bool, +): + verbose = ctx.obj.get("verbose", False) + path_obj = Path(path) + + console.print(f"[bold]Analyzing codebase: {path_obj}[/bold]") + + parser = _get_parser(language, path_obj) + + if verbose: + console.print(f"Using parser: {parser.__class__.__name__}") + + results = _parse_files(path_obj, parser, verbose) + + if verbose: + console.print(f"Parsed {len(results)} files") + + graph_builder = GraphBuilder(GraphType.DIRECTED) + graph_builder.build_from_parser_results(results) + + if verbose: + nodes = graph_builder.get_nodes() + console.print(f"Graph contains {len(nodes)} nodes") + + if format == "dot": + exporter = DOTExporter(graph_builder) + content = exporter.get_string() + if output: + Path(output).write_text(content) + console.print(f"Exported DOT to: {output}") + else: + console.print(content) + elif format == "json": + exporter = JSONExporter(graph_builder) + content = exporter.get_string() + if output: + Path(output).write_text(content) + console.print(f"Exported JSON to: {output}") + else: + console.print(content) + elif format == "png": + if output: + exporter = PNGExporter(graph_builder) + exporter.export(Path(output)) + console.print(f"Exported PNG to: {output}") + else: + console.print("[red]Error: PNG format requires output file path[/red]") + + _display_summary(graph_builder, results, verbose) + + +@cli.command() +@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.option("--language", "-l", type=click.Choice(["python", "javascript", "go", "rust", "auto"]), default="auto") +@click.pass_context +def visualize(ctx: click.Context, path: str, language: str): + path_obj = Path(path) + console.print(f"[bold]Generating visualization: {path_obj}[/bold]") + + parser = _get_parser(language, path_obj) + results = _parse_files(path_obj, parser, False) + + graph_builder = GraphBuilder(GraphType.DIRECTED) + graph_builder.build_from_parser_results(results) + + tree = Tree(f"[bold]{path_obj.name}[/bold]") + + for result in results: + file_branch = tree.add(f"[cyan]{result.file_path.name}[/cyan]") + + for entity in result.entities: + if entity.entity_type.value in ["function", "method"]: + file_branch.add(f"[green]f: {entity.name}()[/green]") + elif entity.entity_type.value == "class": + class_branch = file_branch.add(f"[yellow]c: {entity.name}[/yellow]") + for child in entity.children: + class_branch.add(f"[green]m: {child.name}()[/green]") + + console.print(tree) + + +@cli.command() +@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.option("--language", "-l", type=click.Choice(["python", "javascript", "go", "rust", "auto"]), default="auto") +@click.pass_context +def complexity(ctx: click.Context, path: str, language: str): + path_obj = Path(path) + console.print(f"[bold]Analyzing complexity: {path_obj}[/bold]") + + parser = _get_parser(language, path_obj) + results = _parse_files(path_obj, parser, False) + + calculator = ComplexityCalculator() + + all_entities = [] + for result in results: + all_entities.extend(result.entities) + + complexity_report = calculator.calculate_project_complexity(all_entities) + + table = Table(title="Complexity Analysis") + table.add_column("Metric", style="cyan") + table.add_column("Value", style="magenta") + + table.add_row("Total Functions", str(complexity_report["total_functions"])) + table.add_row("Total Classes", str(complexity_report["total_classes"])) + table.add_row("Total Complexity", str(complexity_report["total_cyclomatic_complexity"])) + table.add_row("Average Complexity", str(complexity_report["average_complexity"])) + table.add_row("High Complexity Functions", str(complexity_report["high_complexity_count"])) + + console.print(table) + + dist = complexity_report["complexity_distribution"] + dist_table = Table(title="Complexity Distribution") + dist_table.add_column("Level", style="cyan") + dist_table.add_column("Count", style="magenta") + dist_table.add_row("Low (1-5)", str(dist.get("low", 0))) + dist_table.add_row("Medium (6-10)", str(dist.get("medium", 0))) + dist_table.add_row("High (11-20)", str(dist.get("high", 0))) + dist_table.add_row("Very High (21+)", str(dist.get("very_high", 0))) + console.print(dist_table) + + if complexity_report["high_complexity_count"] > 0: + console.print("\n[bold red]High Complexity Functions:[/bold red]") + for func in complexity_report["functions"]: + if func["is_complex"]: + console.print( + f" - {func['name']} (score: {func['complexity_score']}, " + f"lines: {func['lines_of_code']})" + ) + + +@cli.command() +@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.option("--language", "-l", type=click.Choice(["python", "javascript", "go", "rust", "auto"]), default="auto") +@click.pass_context +def deps(ctx: click.Context, path: str, language: str): + path_obj = Path(path) + console.print(f"[bold]Analyzing dependencies: {path_obj}[/bold]") + + parser = _get_parser(language, path_obj) + results = _parse_files(path_obj, parser, False) + + graph_builder = GraphBuilder(GraphType.DIRECTED) + graph_builder.build_from_parser_results(results) + + analyzer = DependencyAnalyzer(graph_builder) + report = analyzer.analyze() + + table = Table(title="Dependency Analysis") + table.add_column("Metric", style="cyan") + table.add_column("Value", style="magenta") + + table.add_row("Total Files", str(report.total_files)) + table.add_row("Total Functions", str(report.total_functions)) + table.add_row("Total Classes", str(report.total_classes)) + + console.print(table) + + if report.circular_dependencies: + console.print("\n[bold red]Circular Dependencies Found:[/bold red]") + for i, cycle in enumerate(report.circular_dependencies, 1): + console.print(f" Cycle {i}: {' -> '.join(cycle)}") + + if report.orphan_files: + console.print("\n[bold yellow]Orphan Files (no dependencies):[/bold yellow]") + for orphan in report.orphan_files[:10]: + console.print(f" - {orphan}") + if len(report.orphan_files) > 10: + console.print(f" ... and {len(report.orphan_files) - 10} more") + + layers = analyzer.get_architecture_layers() + if layers: + console.print("\n[bold]Architecture Layers:[/bold]") + for layer, files in layers.items(): + console.print(f" {layer}: {len(files)} files") + + +@cli.command() +@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.option("--language", "-l", type=click.Choice(["python", "javascript", "go", "rust", "auto"]), default="auto") +@click.option("--output", "-o", type=click.Path(), required=True) +@click.pass_context +def export(ctx: click.Context, path: str, language: str, output: str): + path_obj = Path(path) + output_path = Path(output) + + console.print(f"[bold]Exporting graph: {path_obj} -> {output_path}[/bold]") + + parser = _get_parser(language, path_obj) + results = _parse_files(path_obj, parser, False) + + graph_builder = GraphBuilder(GraphType.DIRECTED) + graph_builder.build_from_parser_results(results) + + if output_path.suffix == ".dot": + exporter = DOTExporter(graph_builder) + exporter.export(output_path) + elif output_path.suffix == ".json": + exporter = JSONExporter(graph_builder) + exporter.export(output_path) + elif output_path.suffix in [".png", ".svg"]: + exporter = PNGExporter(graph_builder) + exporter.export(output_path, format=output_path.suffix[1:]) + else: + console.print(f"[red]Unsupported output format: {output_path.suffix}[/red]") + return + + console.print(f"[green]Exported successfully to: {output_path}[/green]") + + +def _get_parser(language: str, path: Path) -> BaseParser: + if language == "python": + return PythonParser() + elif language == "javascript": + return JavaScriptParser() + elif language == "go": + return GoParser() + elif language == "rust": + return RustParser() + else: + for ext in [".py", ".pyi"]: + if path.glob(f"**/{ext}"): + return PythonParser() + for ext in [".js", ".jsx"]: + if path.glob(f"**/{ext}"): + return JavaScriptParser() + for ext in [".go"]: + if path.glob(f"**/{ext}"): + return GoParser() + for ext in [".rs"]: + if path.glob(f"**/{ext}"): + return RustParser() + return PythonParser() + + +def _parse_files(path: Path, parser: BaseParser, verbose: bool): + results = [] + extensions = parser.SUPPORTED_EXTENSIONS + + for ext in extensions: + for file_path in path.rglob(f"*{ext}"): + if "test_" in file_path.name or "_test." in file_path.name: + continue + + try: + content = file_path.read_text(encoding="utf-8") + result = parser.parse(file_path, content) + + if result.errors and verbose: + console.print(f"[yellow]Warning: {result.errors}[/yellow]") + + if result.entities: + results.append(result) + + except Exception as e: + if verbose: + console.print(f"[red]Error parsing {file_path}: {e}[/red]") + + return results + + +def _display_summary(graph_builder: GraphBuilder, results: list, verbose: bool): + nodes = graph_builder.get_nodes() + file_count = len([n for n in nodes if n.node_type.value == "file"]) + func_count = len([n for n in nodes if n.node_type.value == "function"]) + class_count = len([n for n in nodes if n.node_type.value == "class"]) + + summary = Panel( + Text( + f"Files: {file_count}\n" + f"Functions: {func_count}\n" + f"Classes: {class_count}\n" + f"Total Nodes: {len(nodes)}", + justify="left" + ), + title="Analysis Summary", + style="blue" + ) + console.print(summary) + + +def main(): + cli(obj={}) + + +if __name__ == "__main__": + main()