import difflib from typing import List, Tuple, Dict, Optional 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 }