Files
vdiff/vdiff/parse.py
2026-09-29 15:14:02 +02:00

132 lines
4.7 KiB
Python

""" Unified diff -> lines with old/new numbers, change blocks, word-level ranges; no sublime imports """
import difflib
import re
from collections import Counter
from itertools import groupby
from typing import Callable, Iterator, List, NamedTuple, Optional, Sequence, Tuple, TypeVar
class Line(NamedTuple):
"""kind is " " (context), "-" (deleted) or "+" (inserted); the missing number is None"""
kind: str
old_no: Optional[int]
new_no: Optional[int]
text: str
class Diff(NamedTuple):
lines: List[Line]
binary: bool
# the value `runs` groups by, so a caller gets its own key type back rather than `object`
Key = TypeVar("Key")
# an (start, end) index range of consecutive changed lines
Block = Tuple[int, int]
# a (start, end) character range inside one line's text
CharRange = Tuple[int, int]
# word-level marks only when the pair is mostly the same line, and never on huge lines (quadratic)
CHAR_DIFF_MIN_RATIO = 0.5
CHAR_DIFF_MAX_CHARS = 2000
_HUNK = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)")
def parse_unified(text: str) -> Diff:
"""Parse one file's unified diff (any number of hunks) into a Diff"""
lines: List[Line] = []
binary = False
old_no = new_no = 0
in_hunk = False
for raw in text.split("\n"):
if raw.startswith("@@"):
match = _HUNK.match(raw)
old_no, new_no = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
in_hunk = True
continue
if not in_hunk:
if raw.startswith("Binary files") or raw.startswith("GIT binary patch"):
binary = True
continue
if raw == "":
# only the trailing split remainder; an empty context line is " "
continue
marker, body = raw[0], raw[1:]
if marker == " ":
lines.append(Line(" ", old_no, new_no, body))
old_no += 1
new_no += 1
elif marker == "-":
lines.append(Line("-", old_no, None, body))
old_no += 1
elif marker == "+":
lines.append(Line("+", None, new_no, body))
new_no += 1
elif raw.startswith("diff "):
# a second file in the output; we asked for one, ignore the rest
break
# "\ No newline at end of file" and anything else is skipped
return Diff(lines, binary)
def runs(items: Sequence[Line], key: Callable[[Line], Key]) -> Iterator[Tuple[Key, int, int]]:
"""(key value, start, end) for each maximal run of consecutive items with the same key"""
for value, group in groupby(enumerate(items), key=lambda pair: key(pair[1])):
indexes = [i for i, _ in group]
yield value, indexes[0], indexes[-1] + 1
def change_blocks(lines: Sequence[Line]) -> List[Block]:
"""[(start, end)) index ranges of consecutive changed lines"""
return [(start, end) for changed, start, end in runs(lines, lambda line: line.kind != " ") if changed]
def counts(lines: Sequence[Line]) -> Tuple[int, int]:
"""(insertions, deletions)"""
tally = Counter(line.kind for line in lines)
return tally["+"], tally["-"]
def paired_lines(lines: Sequence[Line], block: Block) -> List[Tuple[int, int]]:
"""(deleted_index, inserted_index) pairs of a block with equal - and + counts"""
start, end = block
deleted = [i for i in range(start, end) if lines[i].kind == "-"]
inserted = [i for i in range(start, end) if lines[i].kind == "+"]
if not deleted or len(deleted) != len(inserted):
return []
return list(zip(deleted, inserted))
def char_ranges(old: str, new: str) -> Tuple[List[CharRange], List[CharRange]]:
"""Character ranges that differ: ([(a, b) in old], [(a, b) in new]); empty when too different or too long"""
if len(old) + len(new) > CHAR_DIFF_MAX_CHARS:
return [], []
matcher = difflib.SequenceMatcher(None, old, new, autojunk=False)
if matcher.quick_ratio() < CHAR_DIFF_MIN_RATIO or matcher.ratio() < CHAR_DIFF_MIN_RATIO:
return [], []
old_ranges: List[CharRange] = []
new_ranges: List[CharRange] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag in ("replace", "delete") and i2 > i1:
old_ranges.append((i1, i2))
if tag in ("replace", "insert") and j2 > j1:
new_ranges.append((j1, j2))
return old_ranges, new_ranges
def line_for_open(lines: Sequence[Line], index: int) -> int:
"""New-file line number to open for a row: a deleted row maps to the next surviving line"""
if not lines:
return 1
index = max(0, min(index, len(lines) - 1))
for line in lines[index:]:
if line.new_no is not None:
return line.new_no
for line in reversed(lines[:index]):
if line.new_no is not None:
return line.new_no
return 1