diff_text
Compare two texts given inline: a unified diff with a chosen number of context lines, side-by-side rows (and a rendered table), a word-level diff with [-removed-]{+added+} markers, line counts and similarity ratios as exact fractions. Built on Python's difflib, matching its unified_diff output and marking a missing final newline as GNU diff does.
Install
/tools install diff_text /tools install diff_text@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · diff_text 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool diff_text; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
a | string | yes | Old text. length ≤ 200000 | |
b | string | yes | New text. length ≤ 200000 | |
from_name | string | no | "a" | Label of a in the --- header |
to_name | string | no | "b" | Label of b in the +++ header |
context | integer | no | 3 | ≥ 0; ≤ 1000 |
side_by_side | boolean | no | false | |
width | integer | no | 40 | Column width of the rendered side-by-side table. ≥ 8; ≤ 200 |
words | boolean | no | false | Add a word-level diff |
Input schema (JSON)
{
"type": "object",
"required": [
"a",
"b"
],
"additionalProperties": false,
"properties": {
"a": {
"type": "string",
"maxLength": 200000,
"description": "Old text"
},
"b": {
"type": "string",
"maxLength": 200000,
"description": "New text"
},
"from_name": {
"type": "string",
"default": "a",
"description": "Label of a in the --- header"
},
"to_name": {
"type": "string",
"default": "b",
"description": "Label of b in the +++ header"
},
"context": {
"type": "integer",
"minimum": 0,
"maximum": 1000,
"default": 3
},
"side_by_side": {
"type": "boolean",
"default": false
},
"width": {
"type": "integer",
"minimum": 8,
"maximum": 200,
"default": 40,
"description": "Column width of the rendered side-by-side table"
},
"words": {
"type": "boolean",
"default": false,
"description": "Add a word-level diff"
}
}
}
Example
Input
{
"a": "The quick brown fox jumps.",
"b": "The quick red fox leaped.",
"words": true
}
Output (stdout)
{
"words": {
"inline": "The quick [-brown-]{+red+} fox [-jumps-]{+leaped+}.",
"changes": [
{
"op": "replace",
"old": "brown",
"new": "red"
},
{
"op": "replace",
"old": "jumps",
"new": "leaped"
}
]
}
}
Files
The command the install card registers is python3 diff_text.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
diff_text.py · sha256 ca13639f8bdcd073f49a17a3b9b9e20041e2d9d53e4874b0e11d075de8c9688e
#!/usr/bin/env python3
"""Compare two texts: unified diff, side-by-side rows, word-level diff with inline markers and
similarity ratios (Python difflib).
Input (JSON on stdin):
a, b the two texts (old and new)
from_name label of a in the unified header (default "a")
to_name label of b (default "b")
context context lines around each hunk (default 3)
side_by_side true: add side-by-side rows and a rendered table
width column width of the rendered side-by-side table (default 40)
words true: add a word-level diff with [-removed-]{+added+} markers
"""
import difflib
import json
import re
import sys
from fractions import Fraction
MAX_TEXT = 200_000
CHAR_RATIO_LIMIT = 20_000
NO_EOL = "\\ No newline at end of file\n"
KNOWN = {"a", "b", "from_name", "to_name", "context", "side_by_side", "width", "words"}
def fail(message):
print(json.dumps({"error": message}, ensure_ascii=False))
sys.exit(2)
def ratio(matcher):
total = len(matcher.a) + len(matcher.b)
matched = sum(block.size for block in matcher.get_matching_blocks())
exact = Fraction(2 * matched, total) if total else Fraction(1)
return {"exact": f"{exact.numerator}/{exact.denominator}", "value": round(float(exact), 6),
"matched": matched, "total": total}
def unified(a_lines, b_lines, from_name, to_name, context):
out = []
for line in difflib.unified_diff(a_lines, b_lines, fromfile=from_name, tofile=to_name, n=context):
if line.startswith(("---", "+++", "@@")):
out.append(line if line.endswith("\n") else line + "\n")
elif line.endswith("\n"):
out.append(line)
else:
out.append(line + "\n" + NO_EOL)
return "".join(out)
def side_rows(a_lines, b_lines):
rows = []
sm = difflib.SequenceMatcher(None, a_lines, b_lines, autojunk=False)
strip = lambda s: s.rstrip("\n")
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
for k in range(i2 - i1):
rows.append({"left_line": i1 + k + 1, "left": strip(a_lines[i1 + k]), "op": " ",
"right_line": j1 + k + 1, "right": strip(b_lines[j1 + k])})
else:
n = max(i2 - i1, j2 - j1)
for k in range(n):
li, rj = i1 + k, j1 + k
left = li < i2
right = rj < j2
op = "|" if left and right else ("<" if left else ">")
rows.append({"left_line": li + 1 if left else None, "left": strip(a_lines[li]) if left else None,
"op": op,
"right_line": rj + 1 if right else None, "right": strip(b_lines[rj]) if right else None})
return rows
def render(rows, width):
def cell(text):
text = "" if text is None else text.expandtabs(4)
return (text[:width - 1] + "…") if len(text) > width else text.ljust(width)
lines = []
for r in rows:
ln = f"{r['left_line']:>4}" if r["left_line"] else " "
rn = f"{r['right_line']:>4}" if r["right_line"] else " "
lines.append(f"{ln} {cell(r['left'])} {r['op']} {rn} {cell(r['right'])}".rstrip())
return "\n".join(lines) + ("\n" if lines else "")
TOKEN = re.compile(r"\s+|\w+|[^\w\s]", re.UNICODE)
def word_diff(a, b):
ta, tb = TOKEN.findall(a), TOKEN.findall(b)
sm = difflib.SequenceMatcher(None, ta, tb, autojunk=False)
inline, changes = [], []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
old, new = "".join(ta[i1:i2]), "".join(tb[j1:j2])
if tag == "equal":
inline.append(old)
continue
if old:
inline.append(f"[-{old}-]")
if new:
inline.append(f"{{+{new}+}}")
changes.append({"op": {"replace": "replace", "delete": "delete", "insert": "insert"}[tag],
"old": old, "new": new})
return {"inline": "".join(inline), "changes": changes, "ratio": ratio(sm)}
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("stdin must be a JSON object")
extra = sorted(set(request) - KNOWN)
if extra:
fail(f"unknown field(s): {', '.join(extra)}")
a, b = request.get("a"), request.get("b")
if not isinstance(a, str) or not isinstance(b, str):
fail("a and b must be strings (the two texts)")
if len(a) > MAX_TEXT or len(b) > MAX_TEXT:
fail(f"each text must be at most {MAX_TEXT} characters")
from_name, to_name = request.get("from_name", "a"), request.get("to_name", "b")
if not isinstance(from_name, str) or not isinstance(to_name, str) or "\n" in from_name + to_name:
fail("from_name and to_name must be one-line strings")
context = request.get("context", 3)
if not isinstance(context, int) or isinstance(context, bool) or not 0 <= context <= 1000:
fail("context must be an integer from 0 to 1000")
width = request.get("width", 40)
if not isinstance(width, int) or isinstance(width, bool) or not 8 <= width <= 200:
fail("width must be an integer from 8 to 200")
for key in ("side_by_side", "words"):
if not isinstance(request.get(key, False), bool):
fail(f"{key} must be true or false")
a_lines, b_lines = a.splitlines(keepends=True), b.splitlines(keepends=True)
lines_sm = difflib.SequenceMatcher(None, a_lines, b_lines, autojunk=False)
added = removed = 0
for tag, i1, i2, j1, j2 in lines_sm.get_opcodes():
if tag != "equal":
removed += i2 - i1
added += j2 - j1
diff = unified(a_lines, b_lines, from_name, to_name, context)
result = {"identical": a == b, "unified": diff, "hunks": sum(1 for l in diff.splitlines() if l.startswith("@@")),
"lines": {"a": len(a_lines), "b": len(b_lines), "added": added, "removed": removed},
"line_ratio": ratio(lines_sm)}
if len(a) <= CHAR_RATIO_LIMIT and len(b) <= CHAR_RATIO_LIMIT:
result["char_ratio"] = ratio(difflib.SequenceMatcher(None, a, b, autojunk=False))
else:
result["char_ratio"] = None
result["note"] = f"char_ratio is only computed for texts up to {CHAR_RATIO_LIMIT} characters"
if request.get("side_by_side"):
rows = side_rows(a_lines, b_lines)
result["side_by_side"] = {"rows": rows, "rendered": render(rows, width)}
if request.get("words"):
result["words"] = word_diff(a, b)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/diff_text/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/diff_text/1.0.0/diff_text.py