diff --git a/app/depnav/src/depnav/renderer.py b/app/depnav/src/depnav/renderer.py new file mode 100644 index 0000000..dda50a4 --- /dev/null +++ b/app/depnav/src/depnav/renderer.py @@ -0,0 +1,368 @@ +"""ASCII dependency graph renderer using Rich.""" + +from pathlib import Path +from typing import Any, Optional + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from .graph import DependencyGraph + + +class GraphStyle: + """Styling configuration for graph rendering.""" + + def __init__( + self, + node_style: str = "cyan", + edge_style: str = "dim", + highlight_style: str = "yellow", + cycle_style: str = "red", + max_depth: int = 3, + show_imports: bool = True, + ): + self.node_style = node_style + self.edge_style = edge_style + self.highlight_style = highlight_style + self.cycle_style = cycle_style + self.max_depth = max_depth + self.show_imports = show_imports + + +DEFAULT_STYLE = GraphStyle() + + +class ASCIIRenderer: + """Renderer for ASCII dependency graphs.""" + + def __init__(self, console: Optional[Console] = None): + self.console = console or Console() + self.style = DEFAULT_STYLE + + def set_style(self, style: GraphStyle) -> None: + """Update the rendering style.""" + self.style = style + + def render_graph( + self, + graph: DependencyGraph, + focus_file: Optional[Path] = None, + max_nodes: int = 50, + ) -> Panel: + """Render the dependency graph as ASCII art.""" + nodes = graph.get_nodes() + + if not nodes: + content = Text("No files found in the project.", style="dim") + return Panel(content, title="Dependency Graph") + + if len(nodes) > max_nodes: + content = Text( + f"Showing {max_nodes} of {len(nodes)} files. " + "Use --depth to limit traversal.", + style="dim", + ) + + if focus_file is not None: + return self._render_focused_graph( + graph, focus_file, max_nodes + ) + return self._render_full_graph(graph, max_nodes) + + def _render_full_graph( + self, graph: DependencyGraph, max_nodes: int + ) -> Panel: + """Render the full dependency graph.""" + nodes = graph.get_nodes() + edges = graph.get_edges() + + table = Table( + box=box.ROUNDED, + show_header=False, + expand=True, + padding=(0, 1), + ) + + table.add_column("File", style="bold " + self.style.node_style) + table.add_column("Dependencies", style=self.style.edge_style) + + for i, node in enumerate(nodes[:max_nodes]): + deps = graph.get_dependencies(node) + if deps: + dep_strs = [ + f"[{self.style.edge_style}][/]{d.name}" + for d in deps[:5] + ] + if len(deps) > 5: + dep_strs.append(f"+{len(deps) - 5} more") + deps_display = " ".join(dep_strs) + else: + deps_display = f"[{self.style.edge_style}](no deps)[/]" + table.add_row(node.name, deps_display) + + if len(nodes) > max_nodes: + table.add_row( + f"... and {len(nodes) - max_nodes} more files", "" + ) + + return Panel( + table, + title="Dependency Graph", + subtitle=f"{len(nodes)} files, {len(edges)} dependencies", + ) + + def _render_focused_graph( + self, graph: DependencyGraph, focus_file: Path, max_nodes: int + ) -> Panel: + """Render a focused view centered on a specific file.""" + focus_deps = graph.get_dependencies(focus_file) + focus_deps_rev = graph.get_dependents(focus_file) + + table = Table( + box=box.ROUNDED, + show_header=False, + expand=True, + padding=(0, 1), + ) + + table.add_column("Type", style="dim") + table.add_column("File", style="bold " + self.style.node_style) + + table.add_row( + "[bold magenta]→ DEPENDS ON[/]", + f"[bold {self.style.highlight_style}]{focus_file.name}[/]", + ) + + for dep in focus_deps[:max_nodes - 1]: + table.add_row(" └──", dep.name) + + if focus_deps: + table.add_row( + f" [{self.style.edge_style}]({len(focus_deps)} total deps)[/]", + "", + ) + + if focus_deps_rev: + table.add_row("") + table.add_row( + "[bold magenta]← DEPENDED BY[/]", + f"[bold {self.style.highlight_style}]{focus_file.name}[/]", + ) + for dep in focus_deps_rev[:max_nodes - 2]: + table.add_row(" └──", dep.name) + + if len(focus_deps_rev) > max_nodes - 2: + table.add_row( + f" [{self.style.edge_style}]({len(focus_deps_rev)} total)[/]", + "", + ) + + return Panel( + table, + title=f"Dependencies: {focus_file.name}", + subtitle=f"{len(focus_deps)} imports, {len(focus_deps_rev)} importers", + ) + + def render_tree( + self, + graph: DependencyGraph, + root: Optional[Path] = None, + max_depth: int = 3, + ) -> Panel: + """Render the project structure as a tree.""" + nodes = graph.get_nodes() + if not nodes: + content = Text("No files found.", style="dim") + return Panel(content, title="Project Tree") + + if root is None: + root = graph.project_root + + tree_lines = self._build_tree_lines(graph, root, 0, max_depth) + + tree_str = "\n".join(tree_lines) + content = Text(tree_str, style=self.style.node_style) + + return Panel( + content, + title="Project Structure", + subtitle=f"{len(nodes)} files", + ) + + def _build_tree_lines( + self, + graph: DependencyGraph, + path: Path, + depth: int, + max_depth: int, + prefix: str = "", + is_last: bool = True, + ) -> list[str]: + """Recursively build tree display lines.""" + if depth > max_depth: + return [] + + lines = [] + + connector = "└── " if is_last else "├── " + if depth == 0: + connector = "" + + indent = " " if is_last else "│ " + lines.append(f"{prefix}{connector}{path.name}") + + if path.is_dir(): + try: + children = sorted( + [ + p + for p in path.iterdir() + if p.exists() and not p.name.startswith(".") + ], + key=lambda x: (x.is_file(), x.name), + ) + except PermissionError: + return lines + + for i, child in enumerate(children): + is_child_last = i == len(children) - 1 + lines.extend( + self._build_tree_lines( + graph, + child, + depth + 1, + max_depth, + prefix + indent, + is_child_last, + ) + ) + else: + deps = graph.get_dependencies(path) + if deps and depth < max_depth: + dep_indent = prefix + indent + " " + for j, dep in enumerate(deps[:3]): + dep_is_last = j == min(len(deps), 3) - 1 + dep_connector = "└── " if dep_is_last else "├── " + lines.append( + f"{dep_indent}{dep_connector}[{self.style.edge_style}]{dep.name}[/]" + ) + if len(deps) > 3: + lines.append( + f"{dep_indent} +{len(deps) - 3} more" + ) + + return lines + + def render_cycles(self, cycles: list[list[Path]]) -> Panel: + """Render detected dependency cycles.""" + if not cycles: + content = Text( + "✓ No circular dependencies detected.", + style="green", + ) + return Panel(content, title="Cycle Detection") + + lines = [ + Text( + f"⚠ Found {len(cycles)} circular dependency(ies):", + style=self.style.cycle_style, + ), + "", + ] + + for i, cycle in enumerate(cycles, 1): + cycle_str = " → ".join(p.name for p in cycle) + cycle_str += f" → {cycle[0].name}" + lines.append(Text(f" {i}. {cycle_str}", style="red")) + + content = Text("\n".join(str(line) for line in lines)) + + return Panel(content, title="Circular Dependencies", style="red") + + def render_statistics(self, graph: DependencyGraph) -> Panel: + """Render dependency graph statistics.""" + stats = self._compute_statistics(graph) + + table = Table(box=box.ROUNDED, show_header=False) + table.add_column("Metric", style="dim") + table.add_column("Value", style="bold") + + for metric, value in stats.items(): + table.add_row(metric, str(value)) + + return Panel(table, title="Statistics") + + def _compute_statistics( + self, graph: DependencyGraph + ) -> dict[str, Any]: + """Compute statistics about the dependency graph.""" + nodes = graph.get_nodes() + edges = graph.get_edges() + cycles = graph.detect_cycles() + + total_deps = sum(len(graph.get_dependencies(n)) for n in nodes) + total_imports = sum(len(graph.get_dependents(n)) for n in nodes) + + max_deps_node = max(nodes, key=lambda n: len(graph.get_dependencies(n))) + max_imports_node = max( + nodes, key=lambda n: len(graph.get_dependents(n)) + ) + + return { + "Files": len(nodes), + "Dependencies": len(edges), + "Total Imports": total_deps, + "Total Importers": total_imports, + "Circular Dependencies": len(cycles), + "Most Imported": f"{max_deps_node.name} ({len(graph.get_dependencies(max_deps_node))} deps)", + "Most Imported By": f"{max_imports_node.name} ({len(graph.get_dependents(max_imports_node))} importers)", + } + + def render_json(self, graph: DependencyGraph) -> str: + """Render the graph as JSON for export.""" + import json + + nodes = graph.get_nodes() + edges = graph.get_edges() + + data = { + "project_root": str(graph.project_root), + "nodes": [str(n) for n in nodes], + "edges": [(str(u), str(v)) for u, v in edges], + "statistics": self._compute_statistics(graph), + } + + return json.dumps(data, indent=2) + + def render_dot(self, graph: DependencyGraph) -> str: + """Render the graph in DOT format for Graphviz.""" + lines = [ + "digraph dependency_graph {", + ' rankdir=LR;', + ' node [shape=box, style=rounded];', + "", + ] + + for node in graph.get_nodes(): + lines.append(f' "{node.name}";') + + for u, v in graph.get_edges(): + lines.append(f' "{u.name}" -> "{v.name}";') + + lines.append("}") + return "\n".join(lines) + + def render_stats_only(self, graph: DependencyGraph) -> str: + """Render brief statistics for terminal output.""" + nodes = graph.get_nodes() + edges = graph.get_edges() + cycles = graph.detect_cycles() + + return ( + f"[bold]Files:[/] {len(nodes)} | " + f"[bold]Dependencies:[/] {len(edges)} | " + f"[bold]Cycles:[/] {len(cycles)}" + )