import difflib from typing import Dict, List, Optional, Tuple from .colors import Colors class DiffStats: def __init__(self): self.insertions = 0 self.deletions = 0 self.modifications = 0 self.files_changed = 0 @property def total_changes(self): return self.insertions + self.deletions def __str__(self): return f"{self.files_changed} file(s) changed, {self.insertions} insertions(+), {self.deletions} deletions(-)" class DiffLine: def __init__( self, line_type: str, content: str, old_line_num: Optional[int] = None, new_line_num: Optional[int] = None, ): self.line_type = line_type self.content = content self.old_line_num = old_line_num self.new_line_num = new_line_num def format(self, show_line_nums: bool = True) -> str: color = { "add": Colors.GREEN, "delete": Colors.RED, "context": Colors.GRAY, "header": Colors.CYAN, "stats": Colors.BLUE, }.get(self.line_type, Colors.RESET) prefix = { "add": "+ ", "delete": "- ", "context": " ", "header": "", "stats": "", }.get(self.line_type, " ") if show_line_nums and self.line_type in ("add", "delete", "context"): old_num = str(self.old_line_num) if self.old_line_num else " " new_num = str(self.new_line_num) if self.new_line_num else " " line_num_str = f"{Colors.YELLOW}{old_num:>4} {new_num:>4}{Colors.RESET} " else: line_num_str = "" return f"{line_num_str}{color}{prefix}{self.content}{Colors.RESET}" class DiffDisplay: def __init__(self, context_lines: int = 3): self.context_lines = context_lines def create_diff( self, old_content: str, new_content: str, filename: str = "file" ) -> Tuple[List[DiffLine], DiffStats]: old_lines = old_content.splitlines(keepends=True) new_lines = new_content.splitlines(keepends=True) diff_lines = [] stats = DiffStats() stats.files_changed = 1 diff = difflib.unified_diff( old_lines, new_lines, fromfile=f"a/{filename}", tofile=f"b/{filename}", n=self.context_lines, ) old_line_num = 0 new_line_num = 0 for line in diff: if line.startswith("---") or line.startswith("+++"): diff_lines.append(DiffLine("header", line.rstrip())) elif line.startswith("@@"): diff_lines.append(DiffLine("header", line.rstrip())) old_line_num, new_line_num = self._parse_hunk_header(line) elif line.startswith("+"): stats.insertions += 1 diff_lines.append(DiffLine("add", line[1:].rstrip(), None, new_line_num)) new_line_num += 1 elif line.startswith("-"): stats.deletions += 1 diff_lines.append(DiffLine("delete", line[1:].rstrip(), old_line_num, None)) old_line_num += 1 elif line.startswith(" "): diff_lines.append( DiffLine("context", line[1:].rstrip(), old_line_num, new_line_num) ) old_line_num += 1 new_line_num += 1 stats.modifications = min(stats.insertions, stats.deletions) return diff_lines, stats def _parse_hunk_header(self, header: str) -> Tuple[int, int]: try: parts = header.split("@@")[1].strip().split() old_start = int(parts[0].split(",")[0].replace("-", "")) new_start = int(parts[1].split(",")[0].replace("+", "")) return old_start, new_start except (IndexError, ValueError): return 0, 0 def render_diff( self, diff_lines: List[DiffLine], stats: DiffStats, show_line_nums: bool = True, show_stats: bool = True, ) -> str: output = [] if show_stats: output.append(f"\n{Colors.BOLD}{Colors.BLUE}{'=' * 60}{Colors.RESET}") output.append(f"{Colors.BOLD}{Colors.BLUE}DIFF SUMMARY{Colors.RESET}") output.append(f"{Colors.BOLD}{Colors.BLUE}{'=' * 60}{Colors.RESET}") output.append(f"{Colors.BLUE}{stats}{Colors.RESET}\n") for line in diff_lines: output.append(line.format(show_line_nums)) if show_stats: output.append(f"\n{Colors.BOLD}{Colors.BLUE}{'=' * 60}{Colors.RESET}\n") return "\n".join(output) def display_file_diff( self, old_content: str, new_content: str, filename: str = "file", show_line_nums: bool = True, ) -> str: diff_lines, stats = self.create_diff(old_content, new_content, filename) if not diff_lines: return f"{Colors.GRAY}No changes detected{Colors.RESET}" return self.render_diff(diff_lines, stats, show_line_nums) def display_side_by_side( self, old_content: str, new_content: str, filename: str = "file", width: int = 80, ) -> str: old_lines = old_content.splitlines() new_lines = new_content.splitlines() matcher = difflib.SequenceMatcher(None, old_lines, new_lines) output = [] output.append(f"\n{Colors.BOLD}{Colors.BLUE}{'=' * width}{Colors.RESET}") output.append( f"{Colors.BOLD}{Colors.BLUE}SIDE-BY-SIDE COMPARISON: {filename}{Colors.RESET}" ) output.append(f"{Colors.BOLD}{Colors.BLUE}{'=' * width}{Colors.RESET}\n") half_width = (width - 5) // 2 for tag, i1, i2, j1, j2 in matcher.get_opcodes(): if tag == "equal": for i, (old_line, new_line) in enumerate(zip(old_lines[i1:i2], new_lines[j1:j2])): old_display = old_line[:half_width].ljust(half_width) new_display = new_line[:half_width].ljust(half_width) output.append(f"{Colors.GRAY}{old_display} | {new_display}{Colors.RESET}") elif tag == "replace": max_lines = max(i2 - i1, j2 - j1) for i in range(max_lines): old_line = old_lines[i1 + i] if i1 + i < i2 else "" new_line = new_lines[j1 + i] if j1 + i < j2 else "" old_display = old_line[:half_width].ljust(half_width) new_display = new_line[:half_width].ljust(half_width) output.append( f"{Colors.RED}{old_display}{Colors.RESET} | {Colors.GREEN}{new_display}{Colors.RESET}" ) elif tag == "delete": for old_line in old_lines[i1:i2]: old_display = old_line[:half_width].ljust(half_width) output.append(f"{Colors.RED}{old_display} | {' ' * half_width}{Colors.RESET}") elif tag == "insert": for new_line in new_lines[j1:j2]: new_display = new_line[:half_width].ljust(half_width) output.append(f"{' ' * half_width} | {Colors.GREEN}{new_display}{Colors.RESET}") output.append(f"\n{Colors.BOLD}{Colors.BLUE}{'=' * width}{Colors.RESET}\n") return "\n".join(output) def display_diff( old_content: str, new_content: str, filename: str = "file", format_type: str = "unified", context_lines: int = 3, ) -> str: displayer = DiffDisplay(context_lines) if format_type == "side-by-side": return displayer.display_side_by_side(old_content, new_content, filename) else: return displayer.display_file_diff(old_content, new_content, filename) def get_diff_stats(old_content: str, new_content: str) -> Dict[str, int]: displayer = DiffDisplay() _, stats = displayer.create_diff(old_content, new_content) return { "insertions": stats.insertions, "deletions": stats.deletions, "modifications": stats.modifications, "total_changes": stats.total_changes, "files_changed": stats.files_changed, }