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
}