2025-11-04 05:17:27 +01:00
|
|
|
import difflib
|
2025-11-04 08:09:12 +01:00
|
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
|
|
2025-11-04 05:17:27 +01:00
|
|
|
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:
|
2025-11-04 08:09:12 +01:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
line_type: str,
|
|
|
|
|
content: str,
|
|
|
|
|
old_line_num: Optional[int] = None,
|
|
|
|
|
new_line_num: Optional[int] = None,
|
|
|
|
|
):
|
2025-11-04 05:17:27 +01:00
|
|
|
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 = {
|
2025-11-04 08:09:12 +01:00
|
|
|
"add": Colors.GREEN,
|
|
|
|
|
"delete": Colors.RED,
|
|
|
|
|
"context": Colors.GRAY,
|
|
|
|
|
"header": Colors.CYAN,
|
|
|
|
|
"stats": Colors.BLUE,
|
2025-11-04 05:17:27 +01:00
|
|
|
}.get(self.line_type, Colors.RESET)
|
|
|
|
|
|
|
|
|
|
prefix = {
|
2025-11-04 08:09:12 +01:00
|
|
|
"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 " "
|
2025-11-04 05:17:27 +01:00
|
|
|
line_num_str = f"{Colors.YELLOW}{old_num:>4} {new_num:>4}{Colors.RESET} "
|
|
|
|
|
else:
|
2025-11-04 08:09:12 +01:00
|
|
|
line_num_str = ""
|
2025-11-04 05:17:27 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2025-11-04 08:09:12 +01:00
|
|
|
def create_diff(
|
|
|
|
|
self, old_content: str, new_content: str, filename: str = "file"
|
|
|
|
|
) -> Tuple[List[DiffLine], DiffStats]:
|
2025-11-04 05:17:27 +01:00
|
|
|
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(
|
2025-11-04 08:09:12 +01:00
|
|
|
old_lines,
|
|
|
|
|
new_lines,
|
2025-11-04 05:17:27 +01:00
|
|
|
fromfile=f"a/{filename}",
|
|
|
|
|
tofile=f"b/{filename}",
|
2025-11-04 08:09:12 +01:00
|
|
|
n=self.context_lines,
|
2025-11-04 05:17:27 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
old_line_num = 0
|
|
|
|
|
new_line_num = 0
|
|
|
|
|
|
|
|
|
|
for line in diff:
|
2025-11-04 08:09:12 +01:00
|
|
|
if line.startswith("---") or line.startswith("+++"):
|
|
|
|
|
diff_lines.append(DiffLine("header", line.rstrip()))
|
|
|
|
|
elif line.startswith("@@"):
|
|
|
|
|
diff_lines.append(DiffLine("header", line.rstrip()))
|
2025-11-04 05:17:27 +01:00
|
|
|
old_line_num, new_line_num = self._parse_hunk_header(line)
|
2025-11-04 08:09:12 +01:00
|
|
|
elif line.startswith("+"):
|
2025-11-04 05:17:27 +01:00
|
|
|
stats.insertions += 1
|
2025-11-04 08:09:12 +01:00
|
|
|
diff_lines.append(
|
|
|
|
|
DiffLine("add", line[1:].rstrip(), None, new_line_num)
|
|
|
|
|
)
|
2025-11-04 05:17:27 +01:00
|
|
|
new_line_num += 1
|
2025-11-04 08:09:12 +01:00
|
|
|
elif line.startswith("-"):
|
2025-11-04 05:17:27 +01:00
|
|
|
stats.deletions += 1
|
2025-11-04 08:09:12 +01:00
|
|
|
diff_lines.append(
|
|
|
|
|
DiffLine("delete", line[1:].rstrip(), old_line_num, None)
|
|
|
|
|
)
|
2025-11-04 05:17:27 +01:00
|
|
|
old_line_num += 1
|
2025-11-04 08:09:12 +01:00
|
|
|
elif line.startswith(" "):
|
|
|
|
|
diff_lines.append(
|
|
|
|
|
DiffLine("context", line[1:].rstrip(), old_line_num, new_line_num)
|
|
|
|
|
)
|
2025-11-04 05:17:27 +01:00
|
|
|
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:
|
2025-11-04 08:09:12 +01:00
|
|
|
parts = header.split("@@")[1].strip().split()
|
|
|
|
|
old_start = int(parts[0].split(",")[0].replace("-", ""))
|
|
|
|
|
new_start = int(parts[1].split(",")[0].replace("+", ""))
|
2025-11-04 05:17:27 +01:00
|
|
|
return old_start, new_start
|
|
|
|
|
except (IndexError, ValueError):
|
|
|
|
|
return 0, 0
|
|
|
|
|
|
2025-11-04 08:09:12 +01:00
|
|
|
def render_diff(
|
|
|
|
|
self,
|
|
|
|
|
diff_lines: List[DiffLine],
|
|
|
|
|
stats: DiffStats,
|
|
|
|
|
show_line_nums: bool = True,
|
|
|
|
|
show_stats: bool = True,
|
|
|
|
|
) -> str:
|
2025-11-04 05:17:27 +01:00
|
|
|
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")
|
|
|
|
|
|
2025-11-04 08:09:12 +01:00
|
|
|
return "\n".join(output)
|
2025-11-04 05:17:27 +01:00
|
|
|
|
2025-11-04 08:09:12 +01:00
|
|
|
def display_file_diff(
|
|
|
|
|
self,
|
|
|
|
|
old_content: str,
|
|
|
|
|
new_content: str,
|
|
|
|
|
filename: str = "file",
|
|
|
|
|
show_line_nums: bool = True,
|
|
|
|
|
) -> str:
|
2025-11-04 05:17:27 +01:00
|
|
|
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)
|
|
|
|
|
|
2025-11-04 08:09:12 +01:00
|
|
|
def display_side_by_side(
|
|
|
|
|
self,
|
|
|
|
|
old_content: str,
|
|
|
|
|
new_content: str,
|
|
|
|
|
filename: str = "file",
|
|
|
|
|
width: int = 80,
|
|
|
|
|
) -> str:
|
2025-11-04 05:17:27 +01:00
|
|
|
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}")
|
2025-11-04 08:09:12 +01:00
|
|
|
output.append(
|
|
|
|
|
f"{Colors.BOLD}{Colors.BLUE}SIDE-BY-SIDE COMPARISON: {filename}{Colors.RESET}"
|
|
|
|
|
)
|
2025-11-04 05:17:27 +01:00
|
|
|
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():
|
2025-11-04 08:09:12 +01:00
|
|
|
if tag == "equal":
|
|
|
|
|
for i, (old_line, new_line) in enumerate(
|
|
|
|
|
zip(old_lines[i1:i2], new_lines[j1:j2])
|
|
|
|
|
):
|
2025-11-04 05:17:27 +01:00
|
|
|
old_display = old_line[:half_width].ljust(half_width)
|
|
|
|
|
new_display = new_line[:half_width].ljust(half_width)
|
2025-11-04 08:09:12 +01:00
|
|
|
output.append(
|
|
|
|
|
f"{Colors.GRAY}{old_display} | {new_display}{Colors.RESET}"
|
|
|
|
|
)
|
|
|
|
|
elif tag == "replace":
|
2025-11-04 05:17:27 +01:00
|
|
|
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)
|
2025-11-04 08:09:12 +01:00
|
|
|
output.append(
|
|
|
|
|
f"{Colors.RED}{old_display}{Colors.RESET} | {Colors.GREEN}{new_display}{Colors.RESET}"
|
|
|
|
|
)
|
|
|
|
|
elif tag == "delete":
|
2025-11-04 05:17:27 +01:00
|
|
|
for old_line in old_lines[i1:i2]:
|
|
|
|
|
old_display = old_line[:half_width].ljust(half_width)
|
2025-11-04 08:09:12 +01:00
|
|
|
output.append(
|
|
|
|
|
f"{Colors.RED}{old_display} | {' ' * half_width}{Colors.RESET}"
|
|
|
|
|
)
|
|
|
|
|
elif tag == "insert":
|
2025-11-04 05:17:27 +01:00
|
|
|
for new_line in new_lines[j1:j2]:
|
|
|
|
|
new_display = new_line[:half_width].ljust(half_width)
|
2025-11-04 08:09:12 +01:00
|
|
|
output.append(
|
|
|
|
|
f"{' ' * half_width} | {Colors.GREEN}{new_display}{Colors.RESET}"
|
|
|
|
|
)
|
2025-11-04 05:17:27 +01:00
|
|
|
|
|
|
|
|
output.append(f"\n{Colors.BOLD}{Colors.BLUE}{'=' * width}{Colors.RESET}\n")
|
2025-11-04 08:09:12 +01:00
|
|
|
return "\n".join(output)
|
2025-11-04 05:17:27 +01:00
|
|
|
|
|
|
|
|
|
2025-11-04 08:09:12 +01:00
|
|
|
def display_diff(
|
|
|
|
|
old_content: str,
|
|
|
|
|
new_content: str,
|
|
|
|
|
filename: str = "file",
|
|
|
|
|
format_type: str = "unified",
|
|
|
|
|
context_lines: int = 3,
|
|
|
|
|
) -> str:
|
2025-11-04 05:17:27 +01:00
|
|
|
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 {
|
2025-11-04 08:09:12 +01:00
|
|
|
"insertions": stats.insertions,
|
|
|
|
|
"deletions": stats.deletions,
|
|
|
|
|
"modifications": stats.modifications,
|
|
|
|
|
"total_changes": stats.total_changes,
|
|
|
|
|
"files_changed": stats.files_changed,
|
2025-11-04 05:17:27 +01:00
|
|
|
}
|