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": dot_exporter = DOTExporter(graph_builder) content = dot_exporter.get_string() if output: Path(output).write_text(content) console.print(f"Exported DOT to: {output}") else: console.print(content) elif format == "json": json_exporter = JSONExporter(graph_builder) content = json_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: png_exporter = PNGExporter(graph_builder) png_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": dot_exporter = DOTExporter(graph_builder) dot_exporter.export(output_path) elif output_path.suffix == ".json": json_exporter = JSONExporter(graph_builder) json_exporter.export(output_path) elif output_path.suffix in [".png", ".svg"]: png_exporter = PNGExporter(graph_builder) png_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()