|
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
|
|
}
|