Skip to content

Text Diff Viewer — Python source

Compare two pieces of text and see exactly what changed. Highlights added and removed lines, words, or characters, shows a per-side summary, and exports a unified diff you can paste into a PR or commit. Runs 100% in your browser.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""text-diff — Python port (CosmoDev polyglot showcase).

Computes a diff between two strings at line / word / char granularity using a
classic LCS (longest-common-subsequence) dynamic-programming table. No
third-party dependencies, fully deterministic.

Ported from ``src/lib/text-diff.ts`` — display source, part of CosmoDev's
polyglot tool pages (dev.cosmolabs.org). Behavior is functionally equivalent to
the canonical TypeScript implementation.

Token invariant: every tokenizer splits a string into tokens whose exact
concatenation reconstructs the original, so concatenating every ``DiffPart.text``
in order reconstructs the *changed* string ``b`` (under default options).
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Literal, Optional


DiffType = Literal["equal", "added", "removed"]
Granularity = Literal["line", "word", "char"]

# Word splitting: the capture group keeps whitespace runs as their own tokens so
# concatenation reconstructs the input exactly.
_WORD_SPLIT = re.compile(r"(\s+)")
# Whitespace collapse for the ignoreWhitespace comparison key.
_WS_RUN = re.compile(r"\s+")


@dataclass
class DiffPart:
    """A single merged run of the diff: a category and its text."""

    type: DiffType
    text: str


@dataclass
class DiffOptions:
    """Comparison-key normalization. The original token is always emitted.

    With all flags False (the default) normalization is the identity function,
    so the reconstruction invariant holds exactly; opting in may relax it.
    """

    ignore_case: bool = False
    trim: bool = False
    ignore_whitespace: bool = False


@dataclass
class DiffSummary:
    """Per-category character counts (granularity-agnostic)."""

    added: int = 0
    removed: int = 0
    unchanged: int = 0


@dataclass
class UnifiedHeaders:
    """Filename labels for the ``---``/``+++`` header lines."""

    old: str = ""
    new: str = ""


def _normalize_key(token: str, opts: DiffOptions) -> str:
    """Normalize a token for COMPARISON only; the original is always emitted.

    Order matters: ignoreWhitespace first (collapse + trim), then an explicit
    trim, then case folding. ``str.lower`` mirrors JavaScript's ``toLowerCase``
    (a Unicode lowercase mapping, not the more aggressive ``casefold``).
    """
    s = token
    if opts.ignore_whitespace:
        s = _WS_RUN.sub(" ", s).strip()
    if opts.trim:
        s = s.strip()
    if opts.ignore_case:
        s = s.lower()
    return s


def tokenize(text: str, granularity: Granularity) -> list[str]:
    """Split ``text`` into reconstructable tokens.

    * ``char`` — code-point split (Python strings are Unicode); concatenation === text.
    * ``word`` — alternating maximal whitespace runs and non-whitespace runs;
      concatenation === text.
    * ``line`` — content-only lines (``text.split('\\n')``); a trailing ``''``
      marks that the text ends with a newline. Reconstruction joins with ``\\n``.
    """
    if text == "":
        return []
    if granularity == "char":
        return list(text)
    if granularity == "word":
        # The capture group keeps separators as tokens; drop the empty strings
        # that appear at the ends / between adjacent runs.
        return [t for t in _WORD_SPLIT.split(text) if t]
    return text.split("\n")  # line


def diff(
    a: str,
    b: str,
    granularity: Granularity = "line",
    opts: Optional[DiffOptions] = None,
) -> list[DiffPart]:
    """Compute a diff between ``a`` (original) and ``b`` (changed).

    Returns merged runs of ``DiffPart``. Uses an LCS dynamic-programming table.
    Opt-in normalization compares on a normalized key but emits the ORIGINAL
    token, so an equal part may carry ``a``'s text.
    """
    if opts is None:
        opts = DiffOptions()

    A = tokenize(a, granularity)
    B = tokenize(b, granularity)
    # Compare on a normalized key; emit the original token.
    a_key = [_normalize_key(t, opts) for t in A]
    b_key = [_normalize_key(t, opts) for t in B]
    n, m = len(a_key), len(b_key)

    # dp[i][j] = length of the LCS of a_key[i..] and b_key[j..], built backwards
    # so each cell only depends on already-computed cells (i+1, j+1).
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n - 1, -1, -1):
        row, nrow, aki = dp[i], dp[i + 1], a_key[i]
        for j in range(m - 1, -1, -1):
            if aki == b_key[j]:
                row[j] = nrow[j + 1] + 1
            elif nrow[j] >= row[j + 1]:
                row[j] = nrow[j]
            else:
                row[j] = row[j + 1]

    # Greedy walk: equal on a key match; otherwise drop the side whose remaining
    # LCS is larger. The ``>=`` tie favors 'removed', matching the canonical walk.
    raw: list[DiffPart] = []
    i = j = 0
    while i < n and j < m:
        if a_key[i] == b_key[j]:
            raw.append(DiffPart("equal", A[i]))
            i += 1
            j += 1
        elif dp[i + 1][j] >= dp[i][j + 1]:
            raw.append(DiffPart("removed", A[i]))
            i += 1
        else:
            raw.append(DiffPart("added", B[j]))
            j += 1
    while i < n:
        raw.append(DiffPart("removed", A[i]))
        i += 1
    while j < m:
        raw.append(DiffPart("added", B[j]))
        j += 1

    # Merge consecutive runs of the same type. Lines rejoin with '\n'; char/word
    # tokens already carry their separators and concatenate with ''.
    sep = "\n" if granularity == "line" else ""
    merged: list[DiffPart] = []
    for r in raw:
        if merged and merged[-1].type == r.type:
            merged[-1].text += sep + r.text
        else:
            merged.append(DiffPart(r.type, r.text))
    return merged


def summary(parts: list[DiffPart]) -> DiffSummary:
    """Count characters per diff category. ``len()`` counts Unicode code points.

    Under opt-in normalization an equal part carries ``a``'s text, so
    ``unchanged`` reflects ``a``'s length, not ``b``'s.
    """
    out = DiffSummary()
    for p in parts:
        length = len(p.text)
        if p.type == "added":
            out.added += length
        elif p.type == "removed":
            out.removed += length
        else:
            out.unchanged += length
    return out


# Lines of context kept around each change in unified output.
_CONTEXT = 3


def _prefix_for(t: DiffType) -> str:
    return "+" if t == "added" else "-" if t == "removed" else " "


def _expand_lines(parts: list[DiffPart]) -> list[DiffPart]:
    """One entry per output line. A trailing newline yields no phantom empty line
    — it terminates the preceding line."""
    entries: list[DiffPart] = []
    for p in parts:
        segs = p.text.split("\n")
        if p.text.endswith("\n"):
            segs.pop()
        for s in segs:
            entries.append(DiffPart(p.type, s))
    return entries


def to_unified_diff(
    parts: list[DiffPart],
    headers: Optional[UnifiedHeaders] = None,
    granularity: Granularity = "line",
) -> str:
    """Render a unified-diff string.

    Emits ``---``/``+++`` header lines when ``headers`` is given. For ``line``
    granularity, groups changes into hunks with 3 lines of context and a
    ``@@ -oldStart,oldLen +newStart,newLen @@`` header per hunk. For
    ``word``/``char`` emits one prefixed line per output line with no hunks.
    """
    out: list[str] = []
    if headers is not None:
        out.append(f"--- {headers.old}")
        out.append(f"+++ {headers.new}")

    entries = _expand_lines(parts)

    if granularity != "line":
        for e in entries:
            out.append(_prefix_for(e.type) + e.text)
        return "\n".join(out)

    # Line granularity: group changes into hunks bounded by _CONTEXT lines.
    changed_idx = [k for k, e in enumerate(entries) if e.type != "equal"]
    if not changed_idx:
        return "\n".join(out)

    last = len(entries) - 1

    # Merge changes within 2*_CONTEXT of each other into one hunk [start..end].
    ranges: list[tuple[int, int]] = []
    cur_start = max(0, changed_idx[0] - _CONTEXT)
    cur_end = min(last, changed_idx[0] + _CONTEXT)
    for k in changed_idx[1:]:
        s = max(0, k - _CONTEXT)
        if s <= cur_end + 1:
            cur_end = min(last, k + _CONTEXT)
        else:
            ranges.append((cur_start, cur_end))
            cur_start, cur_end = s, min(last, k + _CONTEXT)
    ranges.append((cur_start, cur_end))

    for r_start, r_end in ranges:
        # Old side = entries that are not 'added'; new side = not 'removed'.
        old_before = sum(1 for k in range(r_start) if entries[k].type != "added")
        new_before = sum(1 for k in range(r_start) if entries[k].type != "removed")
        old_len = sum(1 for k in range(r_start, r_end + 1) if entries[k].type != "added")
        new_len = sum(1 for k in range(r_start, r_end + 1) if entries[k].type != "removed")
        # Empty-side convention: a length-0 side reports the preceding line number
        # (or 0 at the very start of an empty file).
        old_start = old_before if old_len == 0 else old_before + 1
        new_start = new_before if new_len == 0 else new_before + 1
        out.append(f"@@ -{old_start},{old_len} +{new_start},{new_len} @@")
        for k in range(r_start, r_end + 1):
            out.append(_prefix_for(entries[k].type) + entries[k].text)

    return "\n".join(out)

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →