ReMe/reme/tool/fs/edit_diff.py

164 lines
4.8 KiB
Python

"""Diff utilities for edit tool."""
import re
from dataclasses import dataclass
from difflib import unified_diff
def detect_line_ending(content: str) -> str:
"""Detect line ending style (CRLF or LF)."""
crlf_idx = content.find("\r\n")
lf_idx = content.find("\n")
if lf_idx == -1:
return "\n"
if crlf_idx == -1:
return "\n"
return "\r\n" if crlf_idx < lf_idx else "\n"
def normalize_to_lf(text: str) -> str:
"""Normalize line endings to LF."""
return text.replace("\r\n", "\n").replace("\r", "\n")
def restore_line_endings(text: str, ending: str) -> str:
"""Restore original line endings."""
return text.replace("\n", ending) if ending == "\r\n" else text
def normalize_for_fuzzy_match(text: str) -> str:
"""Normalize text for fuzzy matching: strip trailing whitespace, normalize quotes/dashes."""
lines = text.split("\n")
normalized = "\n".join(line.rstrip() for line in lines)
# Smart quotes → ASCII
normalized = re.sub(r"[\u2018\u2019\u201A\u201B]", "'", normalized)
normalized = re.sub(r"[\u201C\u201D\u201E\u201F]", '"', normalized)
# Dashes → hyphen
normalized = re.sub(r"[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]", "-", normalized)
# Special spaces → regular space
normalized = re.sub(r"[\u00A0\u2002-\u200A\u202F\u205F\u3000]", " ", normalized)
return normalized
@dataclass
class FuzzyMatchResult:
"""Result of fuzzy text matching."""
found: bool
index: int
match_length: int
used_fuzzy_match: bool
content_for_replacement: str
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
"""Find old_text in content, trying exact match first, then fuzzy match."""
# Try exact match
exact_index = content.find(old_text)
if exact_index != -1:
return FuzzyMatchResult(
found=True,
index=exact_index,
match_length=len(old_text),
used_fuzzy_match=False,
content_for_replacement=content,
)
# Try fuzzy match
fuzzy_content = normalize_for_fuzzy_match(content)
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
fuzzy_index = fuzzy_content.find(fuzzy_old_text)
if fuzzy_index == -1:
return FuzzyMatchResult(
found=False,
index=-1,
match_length=0,
used_fuzzy_match=False,
content_for_replacement=content,
)
return FuzzyMatchResult(
found=True,
index=fuzzy_index,
match_length=len(fuzzy_old_text),
used_fuzzy_match=True,
content_for_replacement=fuzzy_content,
)
def strip_bom(content: str) -> tuple[str, str]:
"""Strip UTF-8 BOM, return (bom, text_without_bom)."""
if content.startswith("\ufeff"):
return "\ufeff", content[1:]
return "", content
@dataclass
class DiffResult:
"""Result of diff generation."""
diff: str
first_changed_line: int | None
def generate_diff_string(old_content: str, new_content: str, context_lines: int = 4) -> DiffResult:
"""Generate unified diff with line numbers."""
old_lines = old_content.split("\n")
new_lines = new_content.split("\n")
# Use difflib to get the changes
diff_lines = list(
unified_diff(
old_lines,
new_lines,
lineterm="",
n=context_lines,
),
)
if not diff_lines:
return DiffResult(diff="", first_changed_line=None)
# Parse and format the diff
output = []
first_changed_line = None
max_line_num = max(len(old_lines), len(new_lines))
line_num_width = len(str(max_line_num))
old_line_num = 1
new_line_num = 1
for line in diff_lines[2:]: # Skip header lines
if line.startswith("@@"):
# Parse hunk header
match = re.match(r"@@ -(\d+),?\d* \+(\d+),?\d* @@", line)
if match:
old_line_num = int(match.group(1))
new_line_num = int(match.group(2))
continue
if line.startswith("+"):
if first_changed_line is None:
first_changed_line = new_line_num
line_num = str(new_line_num).rjust(line_num_width)
output.append(f"+{line_num} {line[1:]}")
new_line_num += 1
elif line.startswith("-"):
if first_changed_line is None:
first_changed_line = new_line_num
line_num = str(old_line_num).rjust(line_num_width)
output.append(f"-{line_num} {line[1:]}")
old_line_num += 1
else:
# Context line
line_num = str(old_line_num).rjust(line_num_width)
output.append(f" {line_num} {line[1:] if line.startswith(' ') else line}")
old_line_num += 1
new_line_num += 1
return DiffResult(diff="\n".join(output), first_changed_line=first_changed_line)