diff --git a/src/depnav/cli.py b/src/depnav/cli.py index c4bca03..9a862bc 100644 --- a/src/depnav/cli.py +++ b/src/depnav/cli.py @@ -1,3 +1,5 @@ +"""Main CLI interface for depnav.""" + from pathlib import Path from typing import Optional @@ -11,363 +13,352 @@ from .graph import DependencyGraph from .navigator import Navigator from .renderer import ASCIIRenderer, GraphStyle - console = Console() @click.group() -def main(): - """CLI tool for navigating code dependencies.""" - pass - - -@main.command() -@click.argument("path", type=click.Path(exists=True), default=".") @click.option( - "--theme", - default="default", - help="Color theme for the graph (default, dark, light, monokai)", + "--config", + "-c", + type=click.Path(exists=True, path_type=Path), + help="Path to configuration file", ) @click.option( - "--max-nodes", - default=50, + "--theme", + type=click.Choice(["default", "dark", "light", "mono"]), + default="default", + help="Color theme", +) +@click.option( + "--pager/--no-pager", + default=True, + help="Use pager for output", +) +@click.pass_context +def main( + ctx: click.Context, + config: Optional[Path], + theme: str, + pager: bool, +): + """Depnav - CLI Dependency Graph Navigator.""" + ctx.ensure_object(dict) + ctx.obj["config"] = config + ctx.obj["theme"] = theme + ctx.obj["pager"] = pager + + cfg = load_config(config) + ctx.obj["config_obj"] = cfg + + +@main.command("graph") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path), + default=Path("."), +) +@click.option( + "--depth", + "-d", type=int, - help="Maximum number of nodes to display", + default=3, + help="Maximum traversal depth", ) @click.option( "--focus", - "focus_file", - type=click.Path(exists=True), + "-f", + type=str, default=None, - help="Focus the graph on a specific file", + help="Focus on a specific file", ) @click.option( - "--include-extension", - multiple=True, - default=None, - help="Only include files with this extension (e.g., .py)", -) -@click.option( - "--exclude-pattern", - multiple=True, - default=None, - help="Exclude files matching this pattern", -) -@click.option( - "--max-depth", + "--max-nodes", + "-m", type=int, - default=None, - help="Maximum dependency depth to traverse", -) -@click.option( - "--direction", - type=click.Choice(["forward", "backward", "both"]), - default="forward", - help="Dependency direction to show", -) -@click.option( - "--layout", - type=click.Choice(["tree", "graph", "cluster"]), - default="tree", - help="Layout style for the graph", -) -def graph( - path: str, - theme: str, - max_nodes: int, - focus_file: Optional[str], - include_extension: tuple[str, ...], - exclude_pattern: tuple[str, ...], - max_depth: Optional[int], - direction: str, - layout: str, -) -> None: - """Display dependency graph for a project. - - PATH is the path to the project root (default: current directory). - """ - config = load_config() - - if theme != "default": - config.theme = theme - - project_root = Path(path).resolve() - graph = DependencyGraph(project_root) - - extensions = list(include_extension) if include_extension else config.include_extensions - patterns = list(exclude_pattern) if exclude_pattern else config.exclude_patterns - - graph.build_from_directory( - project_root, - include_extensions=extensions, - exclude_patterns=patterns, - ) - - if focus_file: - focus_path = Path(focus_file).resolve() - else: - focus_path = None - - renderer = ASCIIRenderer(console, style=GraphStyle.from_name(config.theme)) - - if direction == "backward": - graph = graph.to_undirected() - - if layout == "tree": - panel = renderer.render_tree(graph, focus_file=focus_path, max_nodes=max_nodes) - elif layout == "cluster": - panel = renderer.render_clusters(graph, focus_file=focus_path, max_nodes=max_nodes) - else: - panel = renderer.render_graph( - graph, - focus_file=focus_path, - max_nodes=max_nodes, - max_depth=max_depth, - ) - - console.print(panel) - - -@main.command() -@click.argument("path", type=click.Path(exists=True), default=".") -@click.option( - "--theme", - default="default", - help="Color theme for the tree (default, dark, light, monokai)", -) -def tree(path: str, theme: str) -> None: - """Display project structure as a tree. - - PATH is the path to the project root (default: current directory). - """ - config = load_config() - - if theme != "default": - config.theme = theme - - project_root = Path(path).resolve() - graph = DependencyGraph(project_root) - - graph.build_from_directory( - project_root, - include_extensions=config.include_extensions, - exclude_patterns=config.exclude_patterns, - ) - - renderer = ASCIIRenderer(console, style=GraphStyle.from_name(config.theme)) - panel = renderer.render_tree(graph) - - console.print(panel) - - -@main.command() -@click.argument("path", type=click.Path(exists=True), default=".") -@click.option( - "--severity", - type=click.Choice(["info", "warning", "error"]), - default="warning", - help="Minimum severity level to report", -) -@click.option( - "--max-depth", - type=int, - default=5, - help="Maximum depth for cycle detection", + default=50, + help="Maximum number of nodes to display", ) @click.option( "--output", - type=click.Choice(["text", "json", "dot"]), - default="text", - help="Output format for the report", -) -@click.option( - "--export", - type=click.Path(), - default=None, - help="Export report to a file", -) -def cycles( - path: str, - severity: str, - max_depth: int, - output: str, - export: Optional[str], -) -> None: - """Detect and display circular dependencies. - - PATH is the path to the project root (default: current directory). - """ - project_root = Path(path).resolve() - graph = DependencyGraph(project_root) - - graph.build_from_directory( - project_root, - include_extensions=[".py", ".js", ".ts", ".go"], - exclude_patterns=["__pycache__", ".git", "node_modules", "*.egg-info"], - ) - - detector = CycleDetector(graph) - - report = detector.get_report(max_depth=max_depth, min_severity=severity) - - if output == "json": - output_text = detector.export_report(report, format="json") - elif output == "dot": - output_text = detector.export_report(report, format="dot") - else: - output_text = report - - if export: - Path(export).write_text(output_text) - click.echo(f"Report exported to {export}") - else: - console.print(output_text) - - -@main.command() -@click.argument("path", type=click.Path(exists=True), default=".") -@click.option( - "--format", - type=click.Choice(["text", "json"]), - default="text", + "-o", + type=click.Choice(["ascii", "json", "dot"]), + default="ascii", help="Output format", ) -def stats(path: str, format: str) -> None: - """Display project statistics. - - PATH is the path to the project root (default: current directory). - """ - project_root = Path(path).resolve() - graph = DependencyGraph(project_root) +@click.pass_context +def graph_command( + ctx: click.Context, + path: Path, + depth: int, + focus: Optional[str], + max_nodes: int, + output: str, +): + """Generate a dependency graph for the project.""" + cfg: load_config = ctx.obj.get("config_obj") + if cfg: + depth = cfg.get_depth() or depth + max_nodes = cfg.get_max_nodes() or max_nodes + graph = DependencyGraph(path if path.is_dir() else path.parent) graph.build_from_directory( - project_root, - include_extensions=[".py", ".js", ".ts", ".go"], - exclude_patterns=["__pycache__", ".git", "node_modules", "*.egg-info"], + directory=path if path.is_dir() else None, + max_depth=depth, ) - stats = graph.get_statistics() + theme = ctx.obj.get("theme", "default") + style = _get_style_from_theme(theme) - if format == "json": - import json + renderer = ASCIIRenderer(console) + renderer.set_style(style) - output = json.dumps(stats, indent=2) + if output == "json": + click.echo(renderer.render_json(graph)) + elif output == "dot": + click.echo(renderer.render_dot(graph)) else: - lines = [ - f"Files: {stats['file_count']}", - f"Dependencies: {stats['dependency_count']}", - f"Cycles: {stats['cycle_count']}", - f"Max Depth: {stats['max_depth']}", - f"Connected Components: {stats['components']}", - ] - output = "\n".join(lines) - - console.print(output) + focus_path = None + if focus: + focus_path = graph.get_node_by_name(focus) + panel = renderer.render_graph(graph, focus_path, max_nodes) + console.print(panel, soft_wrap=True) -@main.command() -@click.argument("file", type=click.Path(exists=True)) -@click.argument("target", type=click.Path(), default=None, required=False) -@click.option( - "--max-depth", - type=int, - default=5, - help="Maximum dependency depth to follow", +@main.command("tree") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path("."), ) -def navigate(file: str, target: Optional[str], max_depth: int) -> None: - """Navigate dependencies interactively. +@click.option( + "--depth", + "-d", + type=int, + default=2, + help="Maximum tree depth", +) +@click.pass_context +def tree_command( + ctx: click.Context, + path: Path, + depth: int, +): + """Show the project structure as a tree.""" + graph = DependencyGraph(path) + graph.build_from_directory(directory=path, max_depth=depth + 2) - FILE is the starting file. - TARGET is an optional target file to find a path to. - """ - project_root = Path(file).resolve().parent + theme = ctx.obj.get("theme", "default") + style = _get_style_from_theme(theme) - graph = DependencyGraph(project_root) - graph.build_from_directory( - project_root, - include_extensions=[".py", ".js", ".ts", ".go"], - exclude_patterns=["__pycache__", ".git", "node_modules", "*.egg-info"], - ) + renderer = ASCIIRenderer(console) + renderer.set_style(style) - start_file = Path(file).resolve() - navigator = Navigator(graph) + panel = renderer.render_tree(graph, path, depth) + console.print(panel, soft_wrap=True) - if target: - target_file = Path(target).resolve() - path = navigator.find_path(start_file, target_file, max_depth=max_depth) - if path: - console.print("Path:") - for i, node in enumerate(path): - prefix = " -> " if i > 0 else " * " - console.print(f"{prefix}{node}") + +@main.command("cycles") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path("."), +) +@click.option( + "--report", + "-r", + type=click.Path(path_type=Path), + default=None, + help="Export report to file", +) +@click.pass_context +def cycles_command( + ctx: click.Context, + path: Path, + report: Optional[Path], +): + """Detect and report circular dependencies.""" + graph = DependencyGraph(path) + graph.build_from_directory() + + detector = CycleDetector(graph) + cycles = detector.detect_all_cycles() + + theme = ctx.obj.get("theme", "default") + style = _get_style_from_theme(theme) + + renderer = ASCIIRenderer(console) + renderer.set_style(style) + + panel = renderer.render_cycles(cycles) + console.print(panel, soft_wrap=True) + + if report: + detector.export_cycle_report(report) + click.echo(f"Report exported to {report}") + + +@main.command("navigate") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path("."), +) +@click.option( + "--file", + "-f", + type=str, + default=None, + help="Initial file to navigate to", +) +@click.pass_context +def navigate_command( + ctx: click.Context, + path: Path, + file: Optional[str], +): + """Start interactive navigation mode.""" + graph = DependencyGraph(path) + graph.build_from_directory() + + theme = ctx.obj.get("theme", "default") + style = _get_style_from_theme(theme) + + navigator = Navigator(graph, console, style) + + if file: + file_path = graph.get_node_by_name(file) + if file_path: + navigator.navigate_to(file_path) + + _run_interactive_mode(navigator, graph) + + +def _run_interactive_mode(navigator: Navigator, graph: DependencyGraph): + """Run the interactive navigation mode.""" + click.echo("Interactive navigation mode. Type 'help' for commands.") + + while True: + try: + cmd = click.prompt( + "depnav", + type=str, + default="", + show_default=False, + ).strip() + except click.exceptions.ClickException: + break + + if not cmd: + continue + + parts = cmd.split() + action = parts[0].lower() + + if action in ("quit", "exit", "q"): + break + elif action == "help": + _show_help() + elif action == "current": + click.echo(navigator.show_current()) + elif action == "deps" and len(parts) > 1: + target = graph.get_node_by_name(parts[1]) + if target: + click.echo(navigator.show_dependencies(target)) + elif action == "imported" and len(parts) > 1: + target = graph.get_node_by_name(parts[1]) + if target: + click.echo(navigator.show_dependents(target)) + elif action == "path" and len(parts) > 2: + source = graph.get_node_by_name(parts[1]) + target = graph.get_node_by_name(parts[2]) + if source and target: + click.echo(navigator.find_path(source, target)) + elif action == "search" and len(parts) > 1: + results = navigator.search(parts[1]) + for r in results: + click.echo(f" {r}") + elif action == "stats": + renderer = ASCIIRenderer(console) + console.print(renderer.render_statistics(graph)) + elif action == "back": + prev = navigator.back() + if prev: + click.echo(f"Back to: {prev}") + else: + click.echo("No previous file") + elif action == "list": + nodes = graph.get_nodes() + for n in nodes[:20]: + click.echo(f" {n}") + if len(nodes) > 20: + click.echo(f" ... and {len(nodes) - 20} more") else: - console.print("No path found", style="red") - else: - current = start_file - history = [] - - while True: - info = navigator.get_file_info(current) - - console.print(Panel(info, title=f"Current: {current.name}")) - - deps = navigator.show_dependencies(current) - dependents = navigator.show_dependents(current) - - if deps: - console.print("\nDependencies:") - for dep in deps[:5]: - console.print(f" - {dep}") - - if dependents: - console.print("\nDependents:") - for dep in dependents[:5]: - console.print(f" - {dep}") - - suggestions = navigator.get_suggestions(current) - if suggestions: - console.print("\nJump to:") - for i, suggestion in enumerate(suggestions[:5]): - console.print(f" {i + 1}. {suggestion}") - - if dependents: - console.print("\n[q] Quit") - if history: - console.print("[b] Back") - - choice = click.prompt("Choose", type=str, default="q") - - if choice == "q": - break - elif choice == "b" and history: - current = history.pop() - elif choice.isdigit(): - idx = int(choice) - 1 - if 0 <= idx < len(suggestions): - history.append(current) - current = suggestions[idx] + click.echo(f"Unknown command: {action}. Type 'help' for available commands.") -@main.command() -@click.argument("file", type=click.Path(exists=True)) -def info(file: str) -> None: - """Show information about a specific file. +def _show_help(): + """Show help for interactive mode.""" + help_text = """ +Available commands: + current Show current file info + deps Show dependencies of a file + imported Show files importing a file + path Show shortest path between files + search Search for files + stats Show graph statistics + back Go back to previous file + list List all files + help Show this help + quit Exit +""" + click.echo(help_text) - FILE is the path to the file. - """ - project_root = Path(file).resolve().parent - graph = DependencyGraph(project_root) - graph.build_from_directory( - project_root, - include_extensions=[".py", ".js", ".ts", ".go"], - exclude_patterns=["__pycache__", ".git", "node_modules", "*.egg-info"], - ) +@main.command("stats") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path("."), +) +@click.pass_context +def stats_command(ctx: click.Context, path: Path): + """Show dependency graph statistics.""" + graph = DependencyGraph(path) + graph.build_from_directory() + + renderer = ASCIIRenderer(console) + console.print(renderer.render_statistics(graph)) + + +@main.command("info") +@click.argument( + "file", + type=click.Path(exists=True, path_type=Path), +) +@click.option( + "--path", + "-p", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path("."), + help="Project root path", +) +@click.pass_context +def info_command( + ctx: click.Context, + file: Path, + path: Path, +): + """Show detailed information about a file.""" + graph = DependencyGraph(path) + graph.build_from_directory() + + if not str(file).startswith("/"): + file = path / file try: - rel_file = graph.get_node_by_name(Path(file).resolve()) + rel_file = file.relative_to(path) except ValueError: - rel_file = Path(file) + rel_file = file navigator = Navigator(graph) @@ -376,53 +367,78 @@ def info(file: str) -> None: console.print(Panel(f"{info}", title=f"Info: {rel_file.name}")) -@main.command() -@click.argument("path", type=click.Path(exists=True), default=".") +@main.command("export") +@click.argument( + "path", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path("."), +) @click.option( "--format", - type=click.Choice(["json", "dot", "plantuml"]), + "-f", + type=click.Choice(["json", "dot"]), default="json", help="Export format", ) @click.option( - "--focus", - "focus_file", - type=click.Path(exists=True), - default=None, - help="Focus the export on a specific file", + "--output", + "-o", + type=click.Path(path_type=Path), + default=Path("depgraph.json"), + help="Output file", ) -@click.option( - "--max-depth", - type=int, - default=None, - help="Maximum dependency depth", -) -def export(path: str, format: str, focus_file: Optional[str], max_depth: Optional[int]) -> None: - """Export dependency graph to a file. - - PATH is the path to the project root (default: current directory). - """ - project_root = Path(path).resolve() - graph = DependencyGraph(project_root) - - graph.build_from_directory( - project_root, - include_extensions=[".py", ".js", ".ts", ".go"], - exclude_patterns=["__pycache__", ".git", "node_modules", "*.egg-info"], - ) +@click.pass_context +def export_command( + ctx: click.Context, + path: Path, + format: str, + output: Path, +): + """Export the dependency graph to a file.""" + graph = DependencyGraph(path) + graph.build_from_directory() renderer = ASCIIRenderer(console) - if focus_file: - focus_path = Path(focus_file).resolve() - else: - focus_path = None - if format == "json": - output = renderer.render_json(graph, focus_file=focus_path, max_depth=max_depth) - elif format == "dot": - output = renderer.render_dot(graph, focus_file=focus_path, max_depth=max_depth) + content = renderer.render_json(graph) else: - output = renderer.render_plantuml(graph, focus_file=focus_path, max_depth=max_depth) + content = renderer.render_dot(graph) - console.print(output) + output.write_text(content) + click.echo(f"Exported to {output}") + + +def _get_style_from_theme(theme: str) -> GraphStyle: + """Get a GraphStyle from a theme name.""" + themes = { + "default": GraphStyle( + node_style="cyan", + edge_style="dim", + highlight_style="yellow", + cycle_style="red", + ), + "dark": GraphStyle( + node_style="green", + edge_style="dim green", + highlight_style="white", + cycle_style="red", + ), + "light": GraphStyle( + node_style="blue", + edge_style="dim blue", + highlight_style="magenta", + cycle_style="red", + ), + "mono": GraphStyle( + node_style="", + edge_style="dim", + highlight_style="bold", + cycle_style="bold", + ), + } + return themes.get(theme, themes["default"]) + + +if __name__ == "__main__": + main()