274 lines
9.4 KiB
Python
274 lines
9.4 KiB
Python
"""
|
|
Diff engine for computing line-level and character-level text differences.
|
|
|
|
Uses Python's built-in difflib module for robust diff computation.
|
|
Line numbers are 1-based in all outputs.
|
|
"""
|
|
|
|
import difflib
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
# Regex pattern to split text into words and whitespace tokens
|
|
WHITESPACE_SPLIT_PATTERN = r'(\s+)'
|
|
|
|
|
|
@dataclass
|
|
class DiffLine:
|
|
"""Represents a single line in the diff output."""
|
|
|
|
line_num_a: Optional[int] # Line number in text_a (None if addition)
|
|
line_num_b: Optional[int] # Line number in text_b (None if deletion)
|
|
content: str
|
|
change_type: str # 'unchanged', 'added', 'deleted'
|
|
char_diffs: Optional[List[Tuple[str, str]]] = None # (change_type, text) pairs
|
|
is_paired: bool = False # True only for lines from replace opcode (paired with another line)
|
|
|
|
|
|
@dataclass
|
|
class DiffResult:
|
|
"""Contains the complete diff result with lines and statistics."""
|
|
|
|
lines: List[DiffLine] = field(default_factory=list)
|
|
stats: dict = field(default_factory=lambda: {"additions": 0, "deletions": 0, "unchanged": 0})
|
|
|
|
|
|
def compute_text_diff(
|
|
text_a: str, text_b: str, context_lines: int = 3
|
|
) -> DiffResult:
|
|
"""
|
|
Compute line-by-line diff with character-level highlighting for changed lines.
|
|
|
|
Uses difflib.SequenceMatcher for line-level comparison, then applies
|
|
character-level diff for modified (replaced) lines.
|
|
|
|
Args:
|
|
text_a: The original text
|
|
text_b: The modified text
|
|
context_lines: Number of unchanged lines to show around changes (-1 = show all)
|
|
|
|
Returns:
|
|
DiffResult containing all diff lines and statistics
|
|
"""
|
|
# Handle edge cases
|
|
if text_a == text_b:
|
|
lines = text_a.splitlines(keepends=True)
|
|
diff_lines = [
|
|
DiffLine(
|
|
line_num_a=i + 1,
|
|
line_num_b=i + 1,
|
|
content=line,
|
|
change_type="unchanged",
|
|
char_diffs=None,
|
|
)
|
|
for i, line in enumerate(lines)
|
|
]
|
|
return DiffResult(
|
|
lines=diff_lines,
|
|
stats={"additions": 0, "deletions": 0, "unchanged": len(lines)},
|
|
)
|
|
|
|
lines_a = text_a.splitlines(keepends=True)
|
|
lines_b = text_b.splitlines(keepends=True)
|
|
|
|
# Handle empty inputs
|
|
if not lines_a and not text_a:
|
|
lines_a = []
|
|
elif not lines_a:
|
|
lines_a = [text_a]
|
|
|
|
if not lines_b and not text_b:
|
|
lines_b = []
|
|
elif not lines_b:
|
|
lines_b = [text_b]
|
|
|
|
# Use SequenceMatcher for matching blocks
|
|
# autojunk=False ensures accurate diffs (no heuristic skipping of "junk" elements)
|
|
matcher = difflib.SequenceMatcher(None, lines_a, lines_b, autojunk=False)
|
|
diff_lines: List[DiffLine] = []
|
|
|
|
stats = {"additions": 0, "deletions": 0, "unchanged": 0}
|
|
|
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
if tag == "equal":
|
|
# Unchanged lines
|
|
for idx, line in enumerate(lines_a[i1:i2]):
|
|
diff_lines.append(
|
|
DiffLine(
|
|
line_num_a=i1 + idx + 1,
|
|
line_num_b=j1 + idx + 1,
|
|
content=line,
|
|
change_type="unchanged",
|
|
char_diffs=None,
|
|
)
|
|
)
|
|
stats["unchanged"] += 1
|
|
|
|
elif tag == "replace":
|
|
# Modified lines - compute character-level diffs
|
|
old_lines = lines_a[i1:i2]
|
|
new_lines = lines_b[j1:j2]
|
|
|
|
# Pair up old and new lines for character diff
|
|
max_len = max(len(old_lines), len(new_lines))
|
|
for idx in range(max_len):
|
|
old_line = old_lines[idx] if idx < len(old_lines) else ""
|
|
new_line = new_lines[idx] if idx < len(new_lines) else ""
|
|
|
|
# Compute character-level diff for this pair
|
|
char_diffs = compute_char_diff(old_line, new_line)
|
|
|
|
if old_line:
|
|
diff_lines.append(
|
|
DiffLine(
|
|
line_num_a=i1 + idx + 1 if idx < len(old_lines) else None,
|
|
line_num_b=None,
|
|
content=old_line,
|
|
change_type="deleted",
|
|
char_diffs=char_diffs,
|
|
is_paired=True,
|
|
)
|
|
)
|
|
stats["deletions"] += 1
|
|
|
|
if new_line:
|
|
diff_lines.append(
|
|
DiffLine(
|
|
line_num_a=None,
|
|
line_num_b=j1 + idx + 1 if idx < len(new_lines) else None,
|
|
content=new_line,
|
|
change_type="added",
|
|
char_diffs=char_diffs,
|
|
is_paired=True,
|
|
)
|
|
)
|
|
stats["additions"] += 1
|
|
|
|
elif tag == "delete":
|
|
# Lines only in text_a
|
|
for idx, line in enumerate(lines_a[i1:i2]):
|
|
diff_lines.append(
|
|
DiffLine(
|
|
line_num_a=i1 + idx + 1,
|
|
line_num_b=None,
|
|
content=line,
|
|
change_type="deleted",
|
|
char_diffs=None,
|
|
)
|
|
)
|
|
stats["deletions"] += 1
|
|
|
|
elif tag == "insert":
|
|
# Lines only in text_b
|
|
for idx, line in enumerate(lines_b[j1:j2]):
|
|
diff_lines.append(
|
|
DiffLine(
|
|
line_num_a=None,
|
|
line_num_b=j1 + idx + 1,
|
|
content=line,
|
|
change_type="added",
|
|
char_diffs=None,
|
|
)
|
|
)
|
|
stats["additions"] += 1
|
|
|
|
# Apply context line filtering
|
|
if context_lines >= 0:
|
|
diff_lines = _filter_context_lines(diff_lines, context_lines)
|
|
# Recalculate stats after filtering
|
|
stats = {"additions": 0, "deletions": 0, "unchanged": 0}
|
|
for line in diff_lines:
|
|
if line.change_type == "added":
|
|
stats["additions"] += 1
|
|
elif line.change_type == "deleted":
|
|
stats["deletions"] += 1
|
|
else:
|
|
stats["unchanged"] += 1
|
|
|
|
return DiffResult(lines=diff_lines, stats=stats)
|
|
|
|
|
|
def _filter_context_lines(diff_lines: List[DiffLine], context_lines: int) -> List[DiffLine]:
|
|
"""
|
|
Filter diff lines to only show changed lines and surrounding context.
|
|
|
|
Args:
|
|
diff_lines: Full list of diff lines
|
|
context_lines: Number of unchanged lines to keep around changes
|
|
|
|
Returns:
|
|
Filtered list with only relevant lines
|
|
"""
|
|
if not diff_lines:
|
|
return diff_lines
|
|
|
|
# Find indices of all changed lines
|
|
changed_indices = {
|
|
i for i, line in enumerate(diff_lines)
|
|
if line.change_type != "unchanged"
|
|
}
|
|
|
|
if not changed_indices:
|
|
return diff_lines # No changes, return all
|
|
|
|
# Include lines within context_lines distance of any change
|
|
included = set()
|
|
for idx in changed_indices:
|
|
for offset in range(-context_lines, context_lines + 1):
|
|
target = idx + offset
|
|
if 0 <= target < len(diff_lines):
|
|
included.add(target)
|
|
|
|
return [line for i, line in enumerate(diff_lines) if i in included]
|
|
|
|
|
|
def compute_char_diff(old_line: str, new_line: str) -> List[Tuple[str, str]]:
|
|
"""
|
|
Compute word-level differences between two lines.
|
|
|
|
Tokenizes by words (preserving whitespace) and compares word-by-word
|
|
for more readable diffs than character-by-character comparison.
|
|
|
|
Args:
|
|
old_line: The original line
|
|
new_line: The modified line
|
|
|
|
Returns:
|
|
List of (change_type, text) tuples where change_type is one of:
|
|
'equal', 'delete', 'insert'
|
|
"""
|
|
if not old_line and not new_line:
|
|
return []
|
|
|
|
if not old_line:
|
|
return [("insert", new_line)]
|
|
|
|
if not new_line:
|
|
return [("delete", old_line)]
|
|
|
|
# Split into tokens (words and whitespace separately)
|
|
old_tokens = re.split(WHITESPACE_SPLIT_PATTERN, old_line)
|
|
new_tokens = re.split(WHITESPACE_SPLIT_PATTERN, new_line)
|
|
|
|
# Filter out empty strings from split
|
|
old_tokens = [t for t in old_tokens if t]
|
|
new_tokens = [t for t in new_tokens if t]
|
|
|
|
matcher = difflib.SequenceMatcher(None, old_tokens, new_tokens, autojunk=False)
|
|
result: List[Tuple[str, str]] = []
|
|
|
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
if tag == "equal":
|
|
result.append(("equal", "".join(old_tokens[i1:i2])))
|
|
elif tag == "replace":
|
|
# For replace, show both delete and insert
|
|
result.append(("delete", "".join(old_tokens[i1:i2])))
|
|
result.append(("insert", "".join(new_tokens[j1:j2])))
|
|
elif tag == "delete":
|
|
result.append(("delete", "".join(old_tokens[i1:i2])))
|
|
elif tag == "insert":
|
|
result.append(("insert", "".join(new_tokens[j1:j2])))
|
|
|
|
return result
|