What changed between two texts — by line, word or character — and how alike two strings are, as typed values, on any Apple platform.
import TextDiff
TextDiff.unified("alpha\nbeta\ngamma", "alpha\ndelta\ngamma")
// @@ -1,3 +1,3 @@
// alpha
// -beta
// +delta
// gamma
TextDiff.inline("The quick brown fox", "The slow red fox")
// The [-quick brown-]{+slow red+} fox
TextDiff.stats("a\nb\nc", "a\nx\nc").changed // 1 — not "1 added, 1 removed"
try Patch.apply(diff, to: original) // the new text, exactly, or a named refusal
Levenshtein.distance("kitten", "sitting") // 3
JaroWinkler.similarity("MARTHA", "MARHTA") // 0.961
Fuzzy.bestMatch("zurich", in: ["Geneva", "Zürich"]) // Zürich, score 1| Question | The wrong answer, and why it is tempting |
|---|---|
| How many lines changed? | "3 added, 3 removed." That is the edit script, not the summary; a replaced line is one change, and stats pairs them off. |
| Do these two texts differ? | !=, which says yes to a trailing newline, a CRLF, or a second space. The options say which of those you meant. |
| Which word changed? | A line diff, which replaces the whole line. The word diff keeps the spacing as tokens, so the inline form reproduces the sentence. |
Is café one edit from cafe? |
Two or three, counting scalars. Distances here count characters as a person sees them. |
| Which entry did they mean? | contains, which finds nothing for Genva. Jaro–Winkler finds Geneva, and folding finds Zürich for zurich. |
| Can I put this diff back? | Splicing lines by number, which corrupts the file silently when the base moved. Patch checks every context line and refuses with the line named. |
.package(url: "https://github.com/arraypress/swift-text-diff.git", from: "1.0.0")Foundation only. No dependencies, every Apple platform, macOS 14+.
TextDiff.diff(old, new) // DiffResult: operations, added, removed, unchanged
TextDiff.words(old, new) // tokens are words and runs of whitespace
TextDiff.characters(old, new) // one grapheme cluster eachAll three produce the same DiffResult: an ordered list of .equal, .insert and
.delete operations, each carrying its token and its 0-based position, from Myers'
algorithm in linear space. Reading the operations in order rebuilds either text — keep
the equal and deleted tokens for the old, the equal and inserted for the new.
The line rule: CRLF and a lone CR become LF, then the text splits on LF keeping the
trailing element. A trailing newline is a real, empty final line, so a\nb and a\nb\n
differ. An empty text is one empty line.
DiffOptions decides what "the same" means — ignoreCase, ignoreWhitespace
(collapse runs and trim on lines; any run of whitespace equals any other in words and
characters), ignoreBlankLines (lines only). Every option affects the comparison alone;
the operations always carry the original text.
TextDiff.unified(old, new, context: 3)
TextDiff.unified(old, new, oldName: "before.txt", newName: "after.txt") // with --- / +++
try Patch.parse(diff) // [Hunk]
try Patch.apply(diff, to: old) // newunified writes the @@ -a,b +c,d @@ form every tool reads, with the GNU convention
for one-sided hunks. Patch reads it back — and what git and diff write too: file
headers, a function name after the header, \ No newline at end of file, a count-less
@@ -5 +5 @@, an empty context line whose leading space an editor stripped. Applying
checks every context and deleted line against the text and refuses a mismatch with the
hunk and line named, because a patch that guesses corrupts a file without a sound.
TextDiff.inline("The quick brown fox", "The slow red fox")
// The [-quick brown-]{+slow red+} foxBoth versions in one line, in wdiff's notation. Whitespace between two changed words belongs to the change, so a replaced phrase is one bracket, not one per word.
let stats = TextDiff.stats(old, new)
stats.added, stats.removed, stats.changed, stats.unchanged, stats.similarityWithin each run of changes, deletes and inserts pair off as changed; the leftovers
are added or removed. similarity is the unchanged share of the longer text, 0 to 1,
and 1 for two empty texts.
Levenshtein.distance("kitten", "sitting") // 3
Levenshtein.similarity("kitten", "sitting") // 0.571 — the unedited share of the longer
JaroWinkler.similarity("DWAYNE", "DUANE") // 0.840
JaroWinkler.jaro("DIXON", "DICKSONX") // 0.767, without the prefix bonusLevenshtein counts characters as a person sees them — grapheme clusters — so café
and cafe are one edit apart however the accent was encoded. Jaro–Winkler suits names
and short labels: characters that match near each other, less the ones out of order,
plus a bonus for a shared prefix of up to four characters. The bonus is applied whatever
the base score, as the common implementations do; the published test values are the
same either way.
Fuzzy.bestMatch("Genva", in: cities) // Geneva
Fuzzy.rank("Genva", in: cities, threshold: 0.6) // every candidate at or above, best first
Fuzzy.score("zurich", "Zürich") // 1
Fuzzy.Options(ignoreCase: false, ignoreDiacritics: false, algorithm: .levenshtein)Query and candidates are folded first — case and diacritics, both on by default — and an exact match after folding scores 1 outright. Ties keep list order, so the answer is the same every run.
60 tests, fixed strings with one right answer each: every shape of change round-tripped
through unified and Patch.apply at three context widths, insertion at the very start
and end, git's headers and markers, a mismatch refused with its line, hunks out of range
and overlapping; words joining back to the exact original; combining marks, emoji, RTL
and CRLF as single characters; the published Levenshtein and Jaro–Winkler values to three
decimals; folding on and off; ties; ten thousand lines diffed, rendered and re-applied
under two seconds; every model through JSON.
MIT.